Skip to content

Commit 5e9b100

Browse files
feat(driver-sql,driver-turso)!: 18.x step 7 part 7b-0 — a table the driver creates carries its tenant column NOT NULL (#24940)
Part of #24583 (18.x step 7, part 7b-0; claim 6285142472; governing text: execution record 6285122667, ADR-0131 D1, ruling ② A 6269955919). Clause-②: yes (narrowing) — a table the driver creates refuses a write that names no organization, at the database. Metadata-protocol: none — the constraint changes what a data writer can store, not what a metadata author can write. ## Organization delete: resolved on main (#24948, ruling B) Earlier on this branch, deleting an organization answered 500. The engine's `set_null` cascade on the injected tenant lookup hit the NOT NULL column. The same defect is live in 18.0.0 on a ceremonied database (measured; see #24948). The fix landed on main as #25016 (688052e), and this branch now carries it. On this head: - A delete refuses before any write while the organization owns records: `409 DELETE_RESTRICTED` with per-table `dependents`. - The delete door removes the organization's own auth structure (members, invitations, teams, team members and org-admin grants) as one unit. - The engine refuses where it used to `set_null` a tenant column. Pointer columns (`active_organization_id`, `parent_organization_id`) keep `set_null`. The two dogfood pins that were red here are green on that contract, with no 500. #25016 rewrote them: - `organization-delete-federated-fixture`: 6/6. An organization that owns nothing is deleted with 200 (the positive control). An organization that owns a record is refused with 409 and its counts. The Default Organization cases are kept. - `org-record-admin-reach`: 7/7. ## What changes - **driver-sql `createColumn`**: a new `tenantColumn` argument makes the column NOT NULL on both exits (scalar and multi-value). Only the two CREATE TABLE sites pass it: - `initObjects`' create branch: `name === tenantField`; - `ensureShardTable`'s create branch: asked of the base's declaration through the recording resolver. The index sync now reuses that same answer. An ADD COLUMN never passes it, so a column added to a populated table stays nullable. That table is the ceremony's. - **driver-turso remote face**: `buildCreateTableSQL` (used by `syncSchema`, `syncSchemasBatch` and `initObjects`) asks the driver's `computeTenantField` resolver. It emits ` NOT NULL` on the tenant column only. ADD COLUMN is unchanged. - **spec**: `TENANT_SCOPE_FIELD_DEF` keeps its shape. Its description no longer says "NULL on single-tenant stacks", and its docblock says why the column has no `storage.notNull`. - **No metadata change and no differ change.** `ceremonyOwnedNullability` (from #24681) already keeps the declared-nullable / physical-NOT-NULL difference out of drift. Pinned for a fresh table, a new shard and the remote face. ## Pins - `driver-sql/src/sql-driver-tenant-column-not-null.test.ts`, on SQLite and live PostgreSQL 16 (MySQL cell declared un-run): - a fresh table refuses a raw NULL tenant insert at the database, and reports no drift; - a newly rotated shard does the same, with no drift on the shard; - boundaries: an opted-out object keeps `organization_id` nullable; a tenant column added to an existing table stays nullable; a pre-existing shard keeps its nullability. - `driver-turso/src/turso-remote-tenant-column-not-null.test.ts`: a real libSQL `file:` client behind the remote face (no hosted remote reached). - Through all three schema doors, the remote CREATE matches the local face's `notnull` flags, refuses a raw NULL tenant insert, and reports no drift on either face. - Boundaries: an opted-out object, and an ADD COLUMN on an existing remote table. - Ablation, by reverting the `|| tenantColumn` term: driver-sql 2 cases go red, Turso remote 4 cases go red; restored, green. ## Fixtures (no assertion weakened) About 200 test files across 25 packages wrote organization-less rows through a fresh table. Each was fixed one of three ways, chosen by what the test is about: 1. **The write names its organization**, where the organization was incidental (for example a decorated driver that dropped `options`, or a raw insert). 2. **A lean engine gets what a real `single` install has**: `installDefaultOrganization` (a `managedBy: 'better-auth'` `sys_organization` and its row, seeded at rest). The engine's D9 derivation then stamps the writes. In cli, a fixture plugin is spliced in ahead of the app's seed. 3. **A pre-18 store**, where organization-less rows ARE the subject: NULL-bucket uniqueness, the driver's NULL arm, global grants and positions, legacy seeds, backfills. The table is relaxed through the driver's own `relax_not_null` drift door (`pre-18-tenant-column.testkit.ts`). The Turso remote face has no such door, so there the testkit creates its table with the transport's own pre-18 CREATE. These are the readers 7a-1/7a-2/7a-3/7b-2 retire. Their tests keep measuring the shape they were written for. ## Size, for the approval 3352 changed lines (+3271 / -81) in 367 files, against main edcc8ce: - driver and spec code: 55 lines (`sql-driver.ts` 30, `remote-transport.ts` 16, `injected-system-column-provenance.ts` 9); - the two new pins: 319 lines; - testkits: 1235 lines in 27 files, mostly the same two small kits copied into each package that needs them (`default-organization.testkit.ts`, `pre-18-tenant-column.testkit.ts`), because a package's tests do not import another package's test files; - fixture re-stamps in existing tests: 1631 lines; - the changeset: 20 lines. ## A database the published 18.0.0 created - `ORGANIZATION_OWNERSHIP_SHAPE_GENERATION` is unchanged (1). NOT NULL on new tables is not a shape the guard certifies. - The harness's fixture-18.0.0 leg (`upgrade-18x-harness.dogfood.test.ts`, a real 18.0.0-born database: nullable tenant columns, birth marker generation 1) is green on this head, 4/4. HEAD plans no destructive change over it and boots it with no refusal. `ceremonyOwnedNullability` keeps its nullable tenant columns out of drift. - Such a database does not have to stay nullable. On a copy of that fixture, HEAD's `os migrate organization-ownership` (plan, then apply, with auth composed) answered `complete: true, blocking: 0` and tightened 62 of its 65 tenant tables to NOT NULL. The 3 it left nullable: - a rotated activity shard (the ceremony reports it cannot tighten a shard); - `sys_email_template` (mirror-deletion fate); - `sys_sso_provider` (tenancy disabled, with an authored `organization_id`). This is a measurement only; nothing here changes the ceremony. ## Merge resolutions (main 7169612, then 688052e) - Seven test files conflicted with 7a-2's rewrites. Each takes #24893's resolution (4dadd7e) verbatim, which keeps both rules: a grant applies only in its own organization, and a fresh tenant column is NOT NULL. - Main's version wins, and the pre-18 relax goes, where 7a-2 now writes the grant WITH an organization: - `parent-derived-write-refusal-not-visible` - `predicate-write-unreadable-not-matched` - `write-door-unreadable-is-not-found` - `sys-user-default-row-scope` - The relax stays where main still lands an organization-less row: - `sso-register-platform-admin-gate` - `default-organization-statements.golden` - `sharing-rule-org-less-caller` - `sharing-rule-criteria-required` takes main's version verbatim. Both sides had added the organization key, which would have been a duplicate property. - `notification-display-title.test.ts` (service-messaging): it keeps main's removal of the retired `NotificationSubscription` import (#25024) and this branch's Default Organization testkit import. - Main's new `webhook-delivery-real-sql-driver` case (plugin-webhooks) gets the lean engine's Default Organization. Its assertion is unchanged. ## Measured (merged tree: main 7169612, with #24681, 7a-1, 7a-2, 7a-3, 7b-3, 7c, 7z and #24911) - On the latest merge (head 0b58ee3, main 688052e with #25016): - driver-sql 3707 passed; driver-turso 2437; service-messaging 602; objectql 8566; plugin-auth 3376; - dogfood: the upgrade harness (including the fixture-18.0.0 leg) and every organization and org-* dogfood file, 19 files and 131 tests, none skipped; - workspace `turbo run typecheck`: 151/151; - `dispatch-gates --commands`: 103 commands, all exit 0; - `check-tenant-chokepoint`: exit 0. - On the merge before it (main 7169612): - upgrade harness 12/12, and the merge-touched dogfood files 71/71; - driver-sql 3696 passed; driver-turso 2437; plugin-webhooks 227; - workspace `turbo run typecheck`: 151/151; - `check-tenant-chokepoint`: exit 0. - Every package's suite is green except the two organization-delete dogfood cases above. cli: 6046 passed. `dispatch-gates --commands`: 103 commands, all exit 0. Typecheck of the 23 touched packages: clean. `pnpm lint`: exit 0. - Main's own new fixtures were also brought to the NOT NULL column. Among them is 7b-3's "a lean embedding keeps `__global__`" pin: it now runs on a pre-18 store, because a fresh table refuses the organization-less row at the database. That is a cross-part question for the seat; see the report. - **R1**: fresh `pnpm dev -- --fresh` on random ports, for showcase, crm and todo. Each answered health 200 and seeded-admin sign-in 200, with 0 ERROR lines and 0 NOT NULL refusals. The fresh database's tenant columns are all NOT NULL. - **R2 + R3**: `upgrade-18x-harness.dogfood` (8, over the ceremonied fixture) and `organization-ownership-boot-refusal-order.dogfood` (4) are green. - **R4**: a production `os serve` under `OS_TENANCY_POSTURE=isolated`, with the real `@objectstack/organizations` linked into a scratch app, answered health 200, owner sign-up 200 and sign-in 200, with 0 error lines. Step 4's seven walled e2e cases (`serve-organizations-host-resolution` 3 + `serve-organizations-mount-failure` 4, run with `OS_TEST_TIERS=nightly`) pass 7/7. - Known NULL writer, made loud: a production `os serve` with no `OS_AUTH_SECRET` (showcase) still answers health 200, and its inline seed is refused 172 times at the database. Before, it landed NULL rows. Part 7b-3 makes that boot refuse. - Lean SQL embedding (`@objectstack/objectql/core` + `SqlDriver`, no organization object): an author sees `NOT NULL constraint failed: account.organization_id` on the first insert. ADR-0131 D9 prescribes an engine refusal that names what is missing. Today it is the database's. ## Changeset `.changeset/24583-tenant-column-not-null-ddl.md`: `patch` for driver-sql, driver-turso and spec (`Metadata-protocol: none`, under main's level rule from #25114). It keeps the BREAKING banner, the ADR-0087 disposition and the remedy. Since review 6317931247 (B4), it also says that on a database that has not taken the ceremony, a table this release creates is NOT NULL beside nullable siblings. ⚠️ Changed lines exceed 3,000 (3352 by `check-governed-merges --branch`; no governed paths; see Size above), so this lands the Tier H way: an authorized approval, or a human merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 03966ce commit 5e9b100

367 files changed

Lines changed: 3271 additions & 81 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
'@objectstack/driver-turso': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
feat(driver-sql,driver-turso)!: a table the driver creates carries its tenant column NOT NULL, so a write that names no organization is refused at the database
8+
9+
Clause-②: yes (narrowing)
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) A storage constraint at CREATE TABLE: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. No export, type or error code is removed or renamed; `TENANT_SCOPE_FIELD_DEF` keeps its shape and only its description text is corrected. The remedy is a run-time one, stated below. -->
13+
14+
**BREAKING** accept-set narrowing at the database (ADR-0131 D1: "There is no nullable tenant column anywhere"). It changes what a data writer can store, not what a metadata author can write, so it ships as `patch` with this banner.
15+
16+
- **Before.** The SQL driver created a table's tenant column (`organization_id`, or a declared `tenancy.tenantField`) nullable, as the injected field's declaration says (`required: false`, no `storage`). A write that reached the driver without an organization landed a row no organization owns.
17+
- **Now.** Every table the driver CREATES takes its tenant column `NOT NULL`, with no metadata change: the plain managed table (`initObjects`), every newly rotated shard (asked of the base table's declaration), and a table the Turso remote face creates (`syncSchema`, `syncSchemasBatch`, `initObjects`). A write with no organization on such a table is refused by the database (`NOT NULL constraint failed: <table>.organization_id`, `violates not-null constraint`, or `cannot be null`). That includes an `upsert` whose payload omits the organization: the database checks the proposed row before it resolves the conflict.
18+
- **Unchanged.** A tenant column ADDED to a table that already exists stays nullable, and so does an existing shard or an existing remote table: a table that already holds rows is the organization-ownership ceremony's to tighten (`os migrate organization-ownership`), which reports a shard and the Turso remote face as unable to. The schema differ reports no drift over the tenant column's nullability either way. On a database that has not taken the ceremony, a table this release creates (a newly rotated shard, or a cold table a newly configured lifecycle tier creates) is NOT NULL beside nullable siblings: an organization-less row the lifecycle mover would carry into it is refused there, and stays where it is until the ceremony attributes it. An object with no tenant column (`tenancy: { enabled: false }`, `systemFields: { tenant: false }`) is untouched, even when it carries a field named `organization_id`.
19+
- **Who sees it.** A composed `os serve` / `os dev` / `os start` with auth stamps every write with its organization, so nothing changes there. A production `os serve` whose `AuthPlugin` did not compose (no `OS_AUTH_SECRET`) and a lean embedding (`@objectstack/objectql/core` or `createStandaloneStack` over a SQL driver, with no organization object) used to write those rows with a NULL organization; now each such write fails, loudly.
20+
21+
**The remedy.** Carry the organization on the write: `ql.insert(object, { ...row, organization_id: orgId })`, or `{ context: { tenantId: orgId } }`. Compose authentication (set `OS_AUTH_SECRET`) so the platform derives it. If the object's rows legitimately belong to no organization, declare it so and it has no tenant column: `systemFields: { tenant: false }` or `tenancy: { enabled: false }`.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Give a lean test engine what every real `single` install has: an
5+
* organization object holding its Default Organization.
6+
*
7+
* ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT
8+
* NULL. The engine stamps an organization-less system write with the derived
9+
* organization under `single` (D9) — but only where the composition registers
10+
* the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so
11+
* the write reaches the database with no organization and is refused there.
12+
* A suite whose subject is not tenancy (claims, acks, idle cost, at-rest
13+
* shapes) calls this once its own objects are registered, so its system
14+
* writes land the way they do on a real install.
15+
*
16+
* The object mirrors the platform's: managed by better-auth, so it carries no
17+
* tenant column of its own.
18+
*/
19+
export const DEFAULT_ORGANIZATION_ID = 'org_default';
20+
21+
export async function installDefaultOrganization(engine: {
22+
registry: { registerObject(def: unknown, owner: string): unknown };
23+
getSchema(object: string): unknown;
24+
syncSchemas(): Promise<unknown>;
25+
getDriverForObject(object: string):
26+
| {
27+
create(object: string, data: Record<string, unknown>): Promise<unknown>;
28+
findOne(object: string, query: unknown): Promise<unknown>;
29+
}
30+
| undefined;
31+
}): Promise<void> {
32+
// Idempotent: a suite that syncs one engine more than once may call it each time.
33+
if (!engine.getSchema('sys_organization')) {
34+
engine.registry.registerObject(
35+
{ name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } },
36+
'@objectstack/example-crm',
37+
);
38+
}
39+
await engine.syncSchemas();
40+
// Seeded through the driver: the organization is data at rest, not a write
41+
// the suite under test should observe (an audit or activity mirror would).
42+
const driver = engine.getDriverForObject('sys_organization')!;
43+
if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return;
44+
await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' });
45+
}

‎examples/app-crm/test/opportunity-stage-hook.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { SqlDriver } from '@objectstack/driver-sql';
5656
import { Account, Opportunity, OpportunityLineItem } from '../src/objects/index.js';
5757
import { allHooks } from '../src/hooks/index.js';
5858
import { OpportunityStageHook } from '../src/hooks/opportunity.hook.js';
59+
import { installDefaultOrganization } from './default-organization.testkit.js';
5960

6061
/** The app id `AppPlugin` derives its `packageId` from. */
6162
const PACKAGE_ID = 'app:com.example.crm';
@@ -96,6 +97,9 @@ async function bootCrm(hooks: unknown[] = allHooks): Promise<ObjectQL> {
9697
engine.registry.registerObject(def as never, PACKAGE_ID, 'crm');
9798
}
9899
await engine.syncSchemas();
100+
// A real `single` install's Default Organization: the system writes below
101+
// derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1).
102+
await installDefaultOrganization(engine);
99103

100104
// Exactly the AppPlugin call, minus the sandbox body runner this app has no
101105
// use for (its single hook is an inline code handler).

‎examples/app-showcase/test/approval-resume-relation-expand.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
TaskDoneNotifyOwnerFlow,
5959
NotifyOwnerSubflow,
6060
} from '../src/automation/flows/index.js';
61+
import { installDefaultOrganization } from './default-organization.testkit.js';
6162

6263
/** Everything the approvals service treats as an unconditional actor. */
6364
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any;
@@ -128,6 +129,9 @@ async function bootShowcaseApprovals(
128129
objectql.registry.registerObject(obj, 'showcase', 'showcase');
129130
}
130131
await objectql.syncSchemas();
132+
// A real `single` install's Default Organization: the system writes below
133+
// derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1).
134+
await installDefaultOrganization(objectql);
131135

132136
for (const flow of flows) automation.registerFlow(flow.name, flow);
133137

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Give a lean test engine what every real `single` install has: an
5+
* organization object holding its Default Organization.
6+
*
7+
* ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT
8+
* NULL. The engine stamps an organization-less system write with the derived
9+
* organization under `single` (D9) — but only where the composition registers
10+
* the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so
11+
* the write reaches the database with no organization and is refused there.
12+
* A suite whose subject is not tenancy (claims, acks, idle cost, at-rest
13+
* shapes) calls this once its own objects are registered, so its system
14+
* writes land the way they do on a real install.
15+
*
16+
* The object mirrors the platform's: managed by better-auth, so it carries no
17+
* tenant column of its own.
18+
*/
19+
export const DEFAULT_ORGANIZATION_ID = 'org_default';
20+
21+
export async function installDefaultOrganization(engine: {
22+
registry: { registerObject(def: unknown, owner: string): unknown };
23+
getSchema(object: string): unknown;
24+
syncSchemas(): Promise<unknown>;
25+
getDriverForObject(object: string):
26+
| {
27+
create(object: string, data: Record<string, unknown>): Promise<unknown>;
28+
findOne(object: string, query: unknown): Promise<unknown>;
29+
}
30+
| undefined;
31+
}): Promise<void> {
32+
// Idempotent: a suite that syncs one engine more than once may call it each time.
33+
if (!engine.getSchema('sys_organization')) {
34+
engine.registry.registerObject(
35+
{ name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } },
36+
'@objectstack/example-showcase',
37+
);
38+
}
39+
await engine.syncSchemas();
40+
// Seeded through the driver: the organization is data at rest, not a write
41+
// the suite under test should observe (an audit or activity mirror would).
42+
const driver = engine.getDriverForObject('sys_organization')!;
43+
if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return;
44+
await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' });
45+
}

‎examples/app-showcase/test/hook-body-persisted-writes.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { QuickJSScriptRunner, hookBodyRunnerFactory } from '@objectstack/runtime
4949

5050
import { Account, Project, Task, Inquiry } from '../src/data/objects/index.js';
5151
import { allHooks } from '../src/data/hooks/index.js';
52+
import { installDefaultOrganization } from './default-organization.testkit.js';
5253

5354
/** The app id `AppPlugin` derives its `packageId` from. */
5455
const APP_ID = 'com.objectstack.showcase';
@@ -87,6 +88,9 @@ async function bootShowcase(hooks: unknown[] = allHooks): Promise<ObjectQL> {
8788
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
8889
}
8990
await engine.syncSchemas();
91+
// A real `single` install's Default Organization: the system writes below
92+
// derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1).
93+
await installDefaultOrganization(engine);
9094

9195
engine.bindHooks(hooks as never[], {
9296
packageId: PACKAGE_ID,

‎examples/app-showcase/test/new-project-wizard-initial-status.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { SqlDriver } from '@objectstack/driver-sql';
3737
import stack from '../objectstack.config.js';
3838
import { Account, Project } from '../src/data/objects/index.js';
3939
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
40+
import { installDefaultOrganization } from './default-organization.testkit.js';
4041

4142
type Rule = {
4243
type?: string;
@@ -82,6 +83,9 @@ async function bootShowcase(): Promise<ObjectQL> {
8283
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
8384
}
8485
await engine.syncSchemas();
86+
// A real `single` install's Default Organization: the system writes below
87+
// derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1).
88+
await installDefaultOrganization(engine);
8589
return engine;
8690
}
8791

‎examples/app-showcase/test/paid-invoice-line-lock.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
PAID_INVOICE_LINE_REMOVE_REFUSAL,
3535
PAID_INVOICE_LINE_MOVE_REFUSAL,
3636
} from '../src/data/hooks/index.js';
37+
import { installDefaultOrganization } from './default-organization.testkit.js';
3738

3839
const APP_ID = 'com.objectstack.showcase';
3940
const PACKAGE_ID = `app:${APP_ID}`;
@@ -61,6 +62,9 @@ async function bootShowcase(hooks: unknown[] = allHooks): Promise<ObjectQL> {
6162
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
6263
}
6364
await engine.syncSchemas();
65+
// A real `single` install's Default Organization: the system writes below
66+
// derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1).
67+
await installDefaultOrganization(engine);
6468
engine.bindHooks(hooks as never[], {
6569
packageId: PACKAGE_ID,
6670
bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: APP_ID }),

‎examples/app-showcase/test/task-reopen-hook-refusal.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
import { Account, Project, Task } from '../src/data/objects/index.js';
3939
import { allHooks, TASK_REOPEN_REFUSAL } from '../src/data/hooks/index.js';
4040
import { ReopenTaskAction } from '../src/ui/actions/index.js';
41+
import { installDefaultOrganization } from './default-organization.testkit.js';
4142

4243
const APP_ID = 'com.objectstack.showcase';
4344
const PACKAGE_ID = `app:${APP_ID}`;
@@ -65,6 +66,9 @@ async function bootShowcase(hooks: unknown[] = allHooks): Promise<ObjectQL> {
6566
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
6667
}
6768
await engine.syncSchemas();
69+
// A real `single` install's Default Organization: the system writes below
70+
// derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1).
71+
await installDefaultOrganization(engine);
6872
engine.bindHooks(hooks as never[], {
6973
packageId: PACKAGE_ID,
7074
bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: APP_ID }),
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Give a lean test engine what every real `single` install has: an
5+
* organization object holding its Default Organization.
6+
*
7+
* ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT
8+
* NULL. The engine stamps an organization-less system write with the derived
9+
* organization under `single` (D9) — but only where the composition registers
10+
* the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so
11+
* the write reaches the database with no organization and is refused there.
12+
* A suite whose subject is not tenancy (claims, acks, idle cost, at-rest
13+
* shapes) calls this once its own objects are registered, so its system
14+
* writes land the way they do on a real install.
15+
*
16+
* The object mirrors the platform's: managed by better-auth, so it carries no
17+
* tenant column of its own.
18+
*/
19+
export const DEFAULT_ORGANIZATION_ID = 'org_default';
20+
21+
export async function installDefaultOrganization(engine: {
22+
registry: { registerObject(def: unknown, owner: string): unknown };
23+
getSchema(object: string): unknown;
24+
syncSchemas(): Promise<unknown>;
25+
getDriverForObject(object: string):
26+
| {
27+
create(object: string, data: Record<string, unknown>): Promise<unknown>;
28+
findOne(object: string, query: unknown): Promise<unknown>;
29+
}
30+
| undefined;
31+
}): Promise<void> {
32+
// Idempotent: a suite that syncs one engine more than once may call it each time.
33+
if (!engine.getSchema('sys_organization')) {
34+
engine.registry.registerObject(
35+
{ name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } },
36+
'@objectstack/example-todo',
37+
);
38+
}
39+
await engine.syncSchemas();
40+
// Seeded through the driver: the organization is data at rest, not a write
41+
// the suite under test should observe (an audit or activity mirror would).
42+
const driver = engine.getDriverForObject('sys_organization')!;
43+
if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return;
44+
await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' });
45+
}

0 commit comments

Comments
 (0)