feat: add team columns to group and jct_user_group

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.
This commit is contained in:
Juan Castro
2026-09-03 12:10:40 -04:00
parent 55fa5cadcb
commit 2a5c089dbe
5 changed files with 203 additions and 1 deletions
@@ -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.
@@ -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 {
@@ -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 <https://www.gnu.org/licenses/>.
-- 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;
@@ -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 <https://www.gnu.org/licenses/>.
-- 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;
@@ -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 <https://www.gnu.org/licenses/>.
-- 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;