Skip to content

Commit a674501

Browse files
committed
chore(driver-sql,driver-turso): Metadata-protocol none, a patch with its BREAKING banner; stale NULL-tenant comments corrected
- Changeset: `Metadata-protocol: none` (the constraint changes what a data writer can store, not what a metadata author can write), so driver-sql and driver-turso regrade from minor to patch under main's level rule. The launch-window sentence goes; the BREAKING banner, the ADR-0087 marker and the remedy stay. It now also says that, on a database that has not taken the ceremony, a table this release creates is NOT NULL beside nullable siblings, so the lifecycle mover cannot carry an organization-less row into it. - runtime default-organization-statements.golden: the delete-and-recreate case no longer relaxes every tenant table. The engine's cascade no longer sets a tenant column to NULL, and the case passes on the fresh store. - spec system-names and objectql registry docblocks: the tenant column is stamped on insert and never NULL, not "NULL on single-tenant stacks". Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7fe7c4e commit a674501

4 files changed

Lines changed: 12 additions & 21 deletions

File tree

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
---
2-
'@objectstack/driver-sql': minor
3-
'@objectstack/driver-turso': minor
2+
'@objectstack/driver-sql': patch
3+
'@objectstack/driver-turso': patch
44
'@objectstack/spec': patch
55
---
66

77
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
88

99
Clause-②: yes (narrowing)
10+
Metadata-protocol: none
1011

1112
<!-- 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. -->
1213

13-
**BREAKING** accept-set narrowing at the database, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D1: "There is no nullable tenant column anywhere").
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.
1415

1516
- **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.
1617
- **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.
17-
- **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. An object with no tenant column (`tenancy: { enabled: false }`, `systemFields: { tenant: false }`) is untouched, even when it carries a field named `organization_id`.
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`.
1819
- **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.
1920

2021
**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 }`.

‎packages/objectql/src/registry.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -597,10 +597,11 @@ export interface SchemaRegistryOptions {
597597
* Currently injects:
598598
* - `organization_id` — always provisioned (unless the object opts out via
599599
* `systemFields`/`tenancy.enabled:false` or is `better-auth` managed) so
600-
* the column never depends on the global multi-tenant flag. Required-false;
601-
* org-scoping populates it on insert in multi-tenant mode, and it stays
602-
* NULL on single-tenant stacks. Only the column's INDEX is gated on
603-
* `multiTenant` (no per-tenant filtering exists single-tenant).
600+
* the column never depends on the global multi-tenant flag. Required-false
601+
* in the declaration; org-scoping stamps it on insert (under `single` with
602+
* the Default Organization), and it is never NULL (ADR-0131 D1). Only the
603+
* column's INDEX is gated on `multiTenant` (no per-tenant filtering exists
604+
* single-tenant).
604605
* - `created_at` / `created_by` / `updated_at` / `updated_by` — audit
605606
* fields. Marked `system: true, readonly: true` so detail views can
606607
* surface them in a dedicated "System Information" section while

‎packages/runtime/src/default-organization-statements.golden.test.ts‎

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -196,17 +196,6 @@ describe('[#24523] statements per warm request: an organization-owned admin gran
196196

197197
describe('[#24523 / #21868] a sys_organization write retires the memoised Default Organization', () => {
198198
it('⭐ the Default Organization deleted and recreated: the next request reads the new id', async () => {
199-
// Deleting an organization runs the engine's referential cascade, whose
200-
// `set_null` on every `organization_id` lookup a fresh, NOT NULL tenant
201-
// column refuses (ADR-0131 D1). This test's subject is the memo, not the
202-
// cascade, so the store is a pre-18 one: every tenant table it holds is
203-
// relaxed through its driver's own drift apply door.
204-
for (const o of (engine as any).registry.getAllObjects() as Array<{ name: string; fields?: Record<string, unknown>; tenancy?: { enabled?: boolean } }>) {
205-
if (!o.fields?.organization_id || o.tenancy?.enabled === false) continue;
206-
const driver = (engine as any).getDriver(o.name);
207-
if (!(await driver?.knex?.schema.hasTable(o.name))) continue;
208-
await relaxTenantColumns(driver, [o.name]);
209-
}
210199
const [old] = await find('sys_organization', { slug: 'default' });
211200
await standing(); // the control above wrote the organization: read it once
212201
expect(await standing()).toEqual({ standing: true, organizationReads: 0 });

‎packages/spec/src/system/constants/system-names.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,8 @@ export const SystemFieldName = {
221221
OWNING_BUSINESS_UNIT_ID: 'owning_business_unit_id',
222222
/**
223223
* THE tenant isolation key — a lookup to `sys_organization`. INJECTED unless
224-
* tenancy is disabled for the object; org-scoping populates it on insert and
225-
* it stays NULL on single-tenant stacks.
224+
* tenancy is disabled for the object; org-scoping stamps it on insert (under
225+
* `single` with the Default Organization), and it is never NULL (ADR-0131 D1).
226226
*/
227227
ORGANIZATION_ID: 'organization_id',
228228
/**

0 commit comments

Comments
 (0)