From 2a5c089dbebc56e3dc55da94797fe516626f4fed Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 1 Sep 2026 11:00:27 -0400 Subject: [PATCH] feat: add team columns to group and jct_user_group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema foundations for teams. Ships dark — nothing reads these columns yet. group: kind, name, handle, plan_id, deleted_at jct_user_group: org_owned user: requires_password_change `kind = 'team'` marks a workspace; the seeded system groups keep it NULL so a team query can never surface them. `org_owned` distinguishes accounts the workspace created from the master account, deciding who pays rather than who may read. `requires_password_change` is a fourth `requires_*` flag for assertVerifiedAccount, needed by the phase 5 reset flow but shipped here to avoid a second three-dialect migration. `handle` uniqueness is case-insensitive, and the dialects disagree by default: mysql gets it from utf8mb4_unicode_ci, sqlite needs COLLATE NOCASE (as 0055 does for usernames), and postgres indexes lower(handle). Without this the same migration would accept `Design-Team` next to `design-team` on sqlite and postgres while mysql rejected it. Postgres handle lookups must therefore compare lower(handle) to use the index. idx_group_owner is sqlite-only: mysql and postgres already index that column. idx_jct_user_group_group is composite, unlike the existing single-column keys. --- .../database/SqliteDatabaseClient.test.ts | 65 ++++++++++++++++++- .../clients/database/SqliteDatabaseClient.ts | 1 + .../migrations/mysql/mysql_mig_32.sql | 58 +++++++++++++++++ .../migrations/postgres/postgres_mig_21.sql | 38 +++++++++++ .../database/migrations/sqlite/0077_teams.sql | 42 ++++++++++++ 5 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/backend/clients/database/migrations/mysql/mysql_mig_32.sql create mode 100644 src/backend/clients/database/migrations/postgres/postgres_mig_21.sql create mode 100644 src/backend/clients/database/migrations/sqlite/0077_teams.sql diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts index c74bffa7d..6bdbfa4ff 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.test.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts @@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js'; import { SqliteDatabaseClient } from './SqliteDatabaseClient.js'; /** Highest schema version the migration table can reach. */ -const CURRENT_SCHEMA_VERSION = 72; +const CURRENT_SCHEMA_VERSION = 73; /** * These suites migrate real files on disk. Idle they finish in well under a @@ -81,6 +81,69 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI expect(await userVersionOf(client)).toBe(CURRENT_SCHEMA_VERSION); }); + it('applies the team columns and indexes from 0077', async () => { + const columnsOf = async (table: string) => + ( + (await client.read( + `SELECT name FROM pragma_table_info('${table}')`, + )) as { name: string }[] + ).map((r) => r.name); + + expect(await columnsOf('group')).toEqual( + expect.arrayContaining([ + 'kind', + 'name', + 'handle', + 'deleted_at', + ]), + ); + expect(await columnsOf('jct_user_group')).toContain('org_owned'); + expect(await columnsOf('user')).toContain('requires_password_change'); + + const indexes = (await client.read( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name IN (?, ?, ?)", + ['idx_group_handle', 'idx_group_owner', 'idx_jct_user_group_group'], + )) as { name: string }[]; + expect(indexes.map((r) => r.name).sort()).toEqual([ + 'idx_group_handle', + 'idx_group_owner', + 'idx_jct_user_group_group', + ]); + }); + + it('keeps `handle` unique but lets the seeded groups share a NULL one', async () => { + const seeded = (await client.read( + 'SELECT COUNT(*) AS n FROM `group` WHERE `handle` IS NULL', + )) as { n: number }[]; + expect(seeded[0].n).toBeGreaterThan(1); + + await client.write( + 'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)', + ['taken'], + ); + await expect( + client.write( + 'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MAX(`id`) FROM `group`)', + ['taken'], + ), + ).rejects.toThrow(/UNIQUE/iu); + }); + + it('treats `handle` case-insensitively, as usernames are treated', async () => { + await client.write( + 'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)', + ['design-team'], + ); + // Without COLLATE NOCASE this differs per engine: MySQL rejects it via + // utf8mb4_unicode_ci while sqlite and postgres would let it through. + await expect( + client.write( + 'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MAX(`id`) FROM `group`)', + ['Design-Team'], + ), + ).rejects.toThrow(/UNIQUE/iu); + }); + it('runs the javascript migrations, not just the .sql ones', async () => { // The `system` user only exists because 0025 (a .dbmig.js file) ran // inside the migration VM. diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index 0c28b2d78..164af0e04 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -106,6 +106,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [69, ['0074_add_user_home.sql']], [70, ['0075_event-subscriptions.sql']], [71, ['0076_event-handlers.sql']], + [72, ['0077_teams.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_32.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_32.sql new file mode 100644 index 000000000..14ee32d66 --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_32.sql @@ -0,0 +1,58 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Teams. See sqlite/0077_teams.sql for the column rationale. +-- No per-file applied-state tracking, so every statement must tolerate a re-run: +-- columns go through _puter_add_col, indexes through the guarded procedure below. + +CALL _puter_add_col('group', 'kind', '`kind` varchar(20) DEFAULT NULL'); +CALL _puter_add_col('group', 'name', '`name` varchar(255) DEFAULT NULL'); +CALL _puter_add_col('group', 'handle', '`handle` varchar(64) DEFAULT NULL'); +CALL _puter_add_col('group', 'deleted_at', '`deleted_at` timestamp NULL DEFAULT NULL'); +CALL _puter_add_col('jct_user_group', 'org_owned', '`org_owned` tinyint(1) DEFAULT NULL'); +CALL _puter_add_col('user', 'requires_password_change', '`requires_password_change` tinyint(1) DEFAULT NULL'); + +DROP PROCEDURE IF EXISTS _puter_add_team_indexes; +DELIMITER // +CREATE PROCEDURE _puter_add_team_indexes() +BEGIN + -- Already case-insensitive: the table is utf8mb4_unicode_ci. NULLs stay distinct. + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'group' + AND INDEX_NAME = 'idx_group_handle' + ) THEN + ALTER TABLE `group` ADD UNIQUE INDEX `idx_group_handle` (`handle`); + END IF; + + -- Composite, unlike the existing single-column `group_id` key. + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'jct_user_group' + AND INDEX_NAME = 'idx_jct_user_group_group' + ) THEN + ALTER TABLE `jct_user_group` + ADD INDEX `idx_jct_user_group_group` (`group_id`, `user_id`); + END IF; +END// +DELIMITER ; + +CALL _puter_add_team_indexes(); + +DROP PROCEDURE IF EXISTS _puter_add_team_indexes; diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_21.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_21.sql new file mode 100644 index 000000000..9c31ad539 --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_21.sql @@ -0,0 +1,38 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Teams. See sqlite/0077_teams.sql for the column rationale. +-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking. + +ALTER TABLE "group" ADD COLUMN IF NOT EXISTS kind text; +ALTER TABLE "group" ADD COLUMN IF NOT EXISTS name text; +ALTER TABLE "group" ADD COLUMN IF NOT EXISTS handle text; +ALTER TABLE "group" ADD COLUMN IF NOT EXISTS deleted_at timestamp; + +-- On lower(handle): text is case-sensitive here, unlike MySQL's utf8mb4_unicode_ci. +-- Handle lookups must therefore compare lower(handle) to hit this index. +CREATE UNIQUE INDEX IF NOT EXISTS idx_group_handle ON "group" (lower(handle)); + +-- No idx_group_owner here: idx_group_owner_user_id already covers it. + +ALTER TABLE jct_user_group ADD COLUMN IF NOT EXISTS org_owned smallint; + +-- Composite, unlike the existing single-column idx_jct_user_group_group_id. +CREATE INDEX IF NOT EXISTS idx_jct_user_group_group + ON jct_user_group (group_id, user_id); + +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_password_change smallint; diff --git a/src/backend/clients/database/migrations/sqlite/0077_teams.sql b/src/backend/clients/database/migrations/sqlite/0077_teams.sql new file mode 100644 index 000000000..5140e27aa --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0077_teams.sql @@ -0,0 +1,42 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Teams: a `group` row with `kind = 'team'` owns accounts and pays for them. +-- Seeded system groups keep `kind` NULL, so a team query never returns them. + +ALTER TABLE `group` ADD COLUMN `kind` TEXT DEFAULT NULL; +ALTER TABLE `group` ADD COLUMN `name` TEXT DEFAULT NULL; +ALTER TABLE `group` ADD COLUMN `handle` TEXT DEFAULT NULL; +ALTER TABLE `group` ADD COLUMN `deleted_at` TIMESTAMP DEFAULT NULL; + +-- NOCASE so a handle is unique the way a username is (see 0055). MySQL gets this +-- from utf8mb4_unicode_ci; postgres needs a lower() index. NULLs stay distinct. +CREATE UNIQUE INDEX IF NOT EXISTS `idx_group_handle` + ON `group` (`handle` COLLATE NOCASE); + +-- mysql and postgres already index this column; sqlite does not. +CREATE INDEX IF NOT EXISTS `idx_group_owner` ON `group` (`owner_user_id`); + +-- 1 = workspace-created, 0 = the workspace owner. Decides who pays, not who may read. +ALTER TABLE `jct_user_group` ADD COLUMN `org_owned` INTEGER DEFAULT NULL; + +-- Covers "list this workspace's members"; the unique pair index lands in 0072. +CREATE INDEX IF NOT EXISTS `idx_jct_user_group_group` + ON `jct_user_group` (`group_id`, `user_id`); + +-- Fourth `requires_*` flag, enforced by assertVerifiedAccount. Set on admin reset. +ALTER TABLE `user` ADD COLUMN `requires_password_change` INTEGER DEFAULT NULL;