Skip to content

Commit 4e5e331

Browse files
committed
Merge remote-tracking branch 'origin/claude/issue-24583-7b0-tenant-not-null-ddl' into claude/issue-24583-7b12-index-plain-form
# Conflicts: # packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts # packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts
2 parents f60f1ce + 5a321e3 commit 4e5e331

19 files changed

Lines changed: 117 additions & 26 deletions

‎packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,9 @@ for (const plugin of stack.plugins) await kernel.use(plugin);
146146
await kernel.use(new PlatformObjectsPlugin());
147147
await runtime.start();
148148
const ql = kernel.getService('objectql');
149-
const SYSTEM = { context: { isSystem: true } };
149+
// The fixture's rows name an organization: a fresh tenant column is NOT NULL
150+
// (ADR-0131 D1), and this stack composes no authentication to derive one.
151+
const SYSTEM = { context: { isSystem: true, tenantId: 'org_fixture' } };
150152
await ql.insert('sys_metadata_history', {
151153
id: 'h_21529', event_seq: 1, name: 'os21529_note', type: 'object', version: 1,
152154
operation_type: 'create', recorded_by: 'system', recorded_at: new Date().toISOString(),
@@ -405,7 +407,9 @@ await kernel.use(new SettingsServicePlugin({
405407
await kernel.use(new StorageServicePlugin({ registerRoutes: false }));
406408
await runtime.start();
407409
const ql = kernel.getService('objectql');
408-
const SYSTEM = { context: { isSystem: true } };
410+
// The fixture's rows name an organization: a fresh tenant column is NOT NULL
411+
// (ADR-0131 D1), and this stack composes no authentication to derive one.
412+
const SYSTEM = { context: { isSystem: true, tenantId: 'org_fixture' } };
409413
await ql.insert('sys_metadata', {
410414
type: 'object', name: 'os21552_note', state: 'active',
411415
metadata: JSON.stringify({ name: 'os21552_note', fields: { title: { type: 'text' } } }),
@@ -414,6 +418,8 @@ await kernel.shutdown();
414418
415419
const raw = new SqlDriver({ client: 'better-sqlite3', connection: { filename: process.env.FIXTURE_DB }, useNullAsDefault: true });
416420
const k = raw.knex;
421+
// Rows planted below the writer name the fixture's organization: every table
422+
// this release created has a NOT NULL tenant column (ADR-0131 D1).
417423
await k.raw('CREATE TABLE sys_account (id text primary key, provider_id text, account_id text, issuer text, user_id text)');
418424
await k('sys_account').insert([
419425
{ id: 'acc_1', provider_id: 'github', account_id: 'g-1', issuer: 'github', user_id: 'u1' },
@@ -429,7 +435,7 @@ await k('sys_audit_log').insert({
429435
const shard = (await k.raw("select name from sqlite_master where type = 'table' and name glob 'sys_activity__r*'"))[0];
430436
if (!shard) throw new Error('the control has no sys_activity shard: the rotation did not run');
431437
await k(shard.name).insert({
432-
id: 'act_21552', timestamp: new Date().toISOString(), type: 'create', summary: 'created', object_name: 'sys_metadata', record_id: 'm_21552',
438+
organization_id: 'org_fixture', id: 'act_21552', timestamp: new Date().toISOString(), type: 'create', summary: 'created', object_name: 'sys_metadata', record_id: 'm_21552',
433439
metadata: JSON.stringify({
434440
old: null,
435441
new: {
@@ -446,23 +452,23 @@ await k('sys_audit_log').insert({
446452
new_value: JSON.stringify({ id: 'em_24693', subject: 's', body_text: 'mail-body-sentinel-24693', body_html: null }),
447453
});
448454
await k(shard.name).insert({
449-
id: 'act_24693', timestamp: new Date().toISOString(), type: 'create', summary: 'created', object_name: 'sys_email', record_id: 'em_24693',
455+
organization_id: 'org_fixture', id: 'act_24693', timestamp: new Date().toISOString(), type: 'create', summary: 'created', object_name: 'sys_email', record_id: 'em_24693',
450456
changed_fields: '[]',
451457
metadata: JSON.stringify({ old: null, new: { id: 'em_24693', subject: 's', body_text: 'mail-body-sentinel-24693', body_html: null } }),
452458
});
453459
await k('sys_comment').insert({
454-
id: 'cmt_23857', thread_id: 'sys_metadata:m_21552', body: 'planted below the writer', author_id: 'u1',
460+
organization_id: 'org_fixture', id: 'cmt_23857', thread_id: 'sys_metadata:m_21552', body: 'planted below the writer', author_id: 'u1',
455461
created_at: new Date().toISOString(),
456462
});
457463
await k('sys_secret').insert({
458464
id: 'sec_21552', namespace: 'os21552', key: 'orphan', kms_key_id: 'local', alg: 'aes-256-gcm', version: 1, ciphertext: 'not-a-real-ciphertext',
459465
});
460466
await k('sys_file').insert({
461-
id: 'file_21552', key: 'attachments/os21552.txt', name: 'os21552.txt', size: 12, scope: 'attachments', status: 'committed',
467+
organization_id: 'org_fixture', id: 'file_21552', key: 'attachments/os21552.txt', name: 'os21552.txt', size: 12, scope: 'attachments', status: 'committed',
462468
});
463469
// A retired field's column: in the table, in no metadata, holding a value.
464470
await k.raw('ALTER TABLE os21529_contact ADD COLUMN legacy_note text');
465-
await k('os21529_contact').insert({ id: 'con_21573', name: 'Ann', legacy_note: 'kept-21573' });
471+
await k('os21529_contact').insert({ id: 'con_21573', name: 'Ann', legacy_note: 'kept-21573', organization_id: 'org_fixture' });
466472
await raw.disconnect();
467473
process.stderr.write('[fixture] seeded\\n');
468474
process.exit(0);

‎packages/cli/src/commands/migrate/object-scope.integration.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,9 @@ for (const plugin of stack.plugins) await kernel.use(plugin);
109109
await kernel.use(new PlatformObjectsPlugin());
110110
await runtime.start();
111111
const ql = kernel.getService('objectql');
112-
const SYSTEM = { context: { isSystem: true } };
112+
// The fixture's rows name an organization: a fresh tenant column is NOT NULL
113+
// (ADR-0131 D1), and this stack composes no authentication to derive one.
114+
const SYSTEM = { context: { isSystem: true, tenantId: 'org_fixture' } };
113115
await ql.insert('os21644_account', { id: 'a1', name: 'Acme' }, SYSTEM);
114116
await ql.insert('os21644_site', { id: 's1', name: 'HQ', geo: { lat: 1, lng: 2 } }, SYSTEM);
115117
await ql.insert('os21644_product', { id: 'p1', name: 'Widget' }, SYSTEM);

‎packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { DECLARED_SHARING_RULE_SEEDER_RUNS } from '../../utils/organization-owne
5656
// through dynamic `import()`s at MODULE LOAD, not inside a clocked case.
5757
import '@objectstack/objectql';
5858
import '@objectstack/metadata-protocol';
59+
import { relaxTenantColumnsOnFile } from '../../utils/pre-18-store.testkit.js';
5960

6061
type Command = (typeof import('./organization-ownership.js'))['default'];
6162
let MigrateOrganizationOwnership: Command;
@@ -173,6 +174,10 @@ describe('os migrate organization-ownership on a database whose packages declare
173174
await stack.stop();
174175
}
175176
seeded = sql('SELECT name, managed_by, customized, organization_id FROM sys_sharing_rule ORDER BY name');
177+
// The rows planted below are an earlier release's, organization-less: its
178+
// tenant columns were nullable, which a table this release creates never is
179+
// (ADR-0131 D1). The file is relaxed to that release's shape first.
180+
await relaxTenantColumnsOnFile(file);
176181

177182
// What a 17.x runtime left beside it.
178183
const [{ id: organization }] = sql(`SELECT id FROM sys_organization WHERE slug = 'default'`) as Array<{ id: string }>;

‎packages/cli/src/commands/migrate/organization-ownership.no-organization.integration.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import { isExitSignal } from '../../utils/format.js';
6060
import '@objectstack/objectql';
6161
import '@objectstack/metadata-protocol';
6262
import '@objectstack/plugin-auth';
63+
import { relaxTenantColumnsOnFile } from '../../utils/pre-18-store.testkit.js';
6364

6465
type Command = (typeof import('./organization-ownership.js'))['default'];
6566
let MigrateOrganizationOwnership: Command;
@@ -168,6 +169,10 @@ describe('os migrate organization-ownership on a single-posture database that ho
168169

169170
const stack = await bootStack(stackConfig(), { databaseFile: file });
170171
await stack.stop();
172+
// The rows planted below are an earlier release's, organization-less: its
173+
// tenant columns were nullable, which a table this release creates never is
174+
// (ADR-0131 D1). The file is relaxed to that release's shape first.
175+
await relaxTenantColumnsOnFile(file);
171176
expect(sql('SELECT COUNT(*) AS n FROM crm_event')).toEqual([{ n: SUBJECTS.length }]);
172177

173178
// What a 17.x install whose seed ran before any organization existed left.

‎packages/cli/src/commands/migrate/unmapped-columns.integration.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ const kernel = runtime.getKernel();
122122
for (const plugin of stack.plugins) await kernel.use(plugin);
123123
await runtime.start();
124124
const ql = kernel.getService('objectql');
125-
const SYSTEM = { context: { isSystem: true } };
125+
// The fixture's rows name an organization: a fresh tenant column is NOT NULL
126+
// (ADR-0131 D1), and this stack composes no authentication to derive one.
127+
const SYSTEM = { context: { isSystem: true, tenantId: 'org_fixture' } };
126128
await ql.insert('um_contact', { id: 'c1', name: 'Ann', mailing_street: '1 Retired Way', mailing_city: 'Oldtown' }, SYSTEM);
127129
await ql.insert('um_contact', { id: 'c2', name: 'Bob', mailing_street: '2 Retired Way', mailing_city: 'Newtown' }, SYSTEM);
128130
await ql.insert('um_contact', { id: 'c3', name: 'Cy' }, SYSTEM);

‎packages/cli/src/commands/secret/orphans.driver-contract.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,8 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)',
151151
}
152152

153153
await secretDriver.create('sys_secret', { ...SEEDED_SECRET });
154-
await settingDriver.create('sys_setting', { ...SEEDED_SETTING });
154+
// The row names an organization: a fresh tenant column is NOT NULL (ADR-0131 D1).
155+
await settingDriver.create('sys_setting', { ...SEEDED_SETTING, organization_id: 'org_fixture' });
155156
}, 180_000);
156157

157158
afterAll(async () => {

‎packages/cli/src/commands/secret/rewrap.driver-contract.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ describe('os secret rewrap — the concrete driver and the command, end to end (
117117
for (const row of [SETTINGS_ROW, DATASOURCE_ROW, ORPHAN_ROW, PROBE_ROW]) {
118118
await secretDriver.create('sys_secret', { ...row });
119119
}
120-
await settingDriver.create('sys_setting', { namespace: 'smtp', key: 'password', value_enc: SETTINGS_ROW.id });
120+
// The row names an organization: a fresh tenant column is NOT NULL (ADR-0131 D1).
121+
await settingDriver.create('sys_setting', { namespace: 'smtp', key: 'password', value_enc: SETTINGS_ROW.id, organization_id: 'org_fixture' });
121122
writeFileSync(declaredFile, JSON.stringify([
122123
{ name: 'warehouse', external: { credentialsRef: `sys_secret:${String(DATASOURCE_ROW.id)}` } },
123124
]));

‎packages/cli/src/utils/organization-ownership-not-null.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ beforeEach(async () => {
3333
await driver.initObjects([
3434
{ name: 'crm_deal', fields: { name: { type: 'text' }, organization_id: { type: 'lookup', reference: 'sys_organization', required: false } } },
3535
] as never);
36+
// The ceremony tightens a table an earlier release built: its tenant column
37+
// is nullable, which a table this release creates never is (ADR-0131 D1).
38+
// Relaxed through the driver's own drift apply door, the shape of that store.
39+
const { applied } = await driver.applyMigrationEntries([
40+
{
41+
kind: 'nullability_mismatch', remoteName: 'crm_deal', table: 'crm_deal', column: 'organization_id', category: 'needs_confirm',
42+
op: { type: 'relax_not_null', table: 'crm_deal', column: 'organization_id' },
43+
} as never,
44+
]);
45+
if (applied.length !== 1) throw new Error('crm_deal was not returned to its pre-18 shape');
3646
await knex('crm_deal').insert([
3747
{ id: 'deal_1', name: 'Acme', organization_id: 'org_default' },
3848
{ id: 'deal_2', name: 'Globex', organization_id: 'org_default' },

‎packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import MigrateOrganizationOwnership from '../commands/migrate/organization-owner
7373
import '@objectstack/runtime';
7474
import '@objectstack/objectql';
7575
import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin';
76+
import { relaxTenantColumnsOnFile } from './pre-18-store.testkit.js';
7677

7778
const HERE = dirname(fileURLToPath(import.meta.url));
7879
const CLI_ROOT = resolve(HERE, '..', '..');
@@ -368,6 +369,9 @@ beforeAll(async () => {
368369

369370
templateDb = join(dir, 'template.db');
370371
await bootV17(templateDb);
372+
// A v17 database's tenant columns are nullable, which a table this release
373+
// creates never is (ADR-0131 D1): the template is relaxed to that shape first.
374+
await relaxTenantColumnsOnFile(templateDb);
371375
const db = new Database(templateDb);
372376
try {
373377
for (const table of HOST_OBJECTS) {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Return a SQLite file this release built to the shape an EARLIER release's
5+
* database has: every tenant column (`organization_id`) nullable.
6+
*
7+
* ADR-0131 D1: since 18.x the SQL driver creates a table's tenant column NOT
8+
* NULL. A v17 or 18.0 database still holds the nullable column, and the
9+
* organization-ownership ceremony is what tightens it. A suite that boots this
10+
* release over a file and then plants that earlier release's organization-less
11+
* rows calls this between the two, so the file is the store the ceremony
12+
* meets. Each column is relaxed through the driver's own drift apply door
13+
* (`relax_not_null`, the SQLite table rebuild), never a hand `ALTER`.
14+
*/
15+
export async function relaxTenantColumnsOnFile(file: string, column = 'organization_id'): Promise<string[]> {
16+
// A dynamic import: this file lives under `src/`, where a static driver import
17+
// is refused (`schema-migrate.lazy-driver-import.test.ts`).
18+
const { SqlDriver } = await import('@objectstack/driver-sql');
19+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true });
20+
const relaxed: string[] = [];
21+
try {
22+
const knex = (driver as any).knex;
23+
const tables = (await knex.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)) as Array<{ name: string }>;
24+
for (const { name } of tables) {
25+
const info = (await knex.raw(`SELECT name, "notnull" AS nn FROM pragma_table_info(?)`, [name])) as Array<{ name: string; nn: number }>;
26+
if (!info.some((c) => c.name === column && Number(c.nn) === 1)) continue;
27+
const op = { type: 'relax_not_null', table: name, column };
28+
const { applied } = await driver.applyMigrationEntries([
29+
{ kind: 'nullability_mismatch', remoteName: name, table: name, column, category: 'needs_confirm', op } as never,
30+
]);
31+
if (applied.length !== 1) throw new Error(`relaxTenantColumnsOnFile: ${name}.${column} was not relaxed`);
32+
relaxed.push(name);
33+
}
34+
} finally {
35+
await driver.disconnect();
36+
}
37+
return relaxed;
38+
}

0 commit comments

Comments
 (0)