From 012390d273148bbff06143eeae104266a2b96996 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 07:34:38 +0000 Subject: [PATCH 1/2] refactor(spec)!: retire `indexes[].type` and `indexes[].partial` (#5248, #4943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both keys were authorable with zero DDL consumers. `syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, and the drift differ's `DeclaredIndexInput` carries `name`/`fields`/`unique`/`nullSafeColumns` — so an authored `type` selected no access method and an authored `partial` produced a FULL index with the predicate silently discarded. `type` additionally carried `.default('btree')`, materializing an inert knob into every parse output (ADR-0078). Retirement kit: `retiredKey()` tombstones at the bottom of the IndexSchema shape (#5606 renderer note), ADR-0087 conversion `object-index-type-partial-removed` opening the protocol-18 step, both real producers flipped (`sys_metadata`, `sys_view_definition`) with their comments corrected, published skill + docs + liveness note + generated baselines. DDL equivalence is proven against the statements SQLite actually stores, not asserted: packages/drivers/driver-sql/src/declared-index-retired-keys.test.ts Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D --- .changeset/index-type-partial-removed.md | 70 ++++++ content/docs/data-modeling/objects.mdx | 30 ++- .../src/declared-index-retired-keys.test.ts | 162 ++++++++++++++ .../sql-driver-overlay-index-drift.test.ts | 8 +- .../src/objects/sys-metadata.object.ts | 28 ++- .../src/objects/sys-view-definition.object.ts | 17 +- .../src/objects/sys-presence.object.test.ts | 12 +- packages/spec/authorable-surface.base.json | 10 +- packages/spec/authorable-surface.json | 4 +- packages/spec/liveness/object.json | 3 +- packages/spec/src/conversions/registry.ts | 112 ++++++++++ .../data/object-strictness-batch20.test.ts | 22 +- packages/spec/src/data/object.test.ts | 56 +++++ packages/spec/src/data/object.zod.ts | 101 ++++++--- packages/spec/src/migrations/registry.ts | 49 +++++ skills/objectstack-data/SKILL.md | 16 +- skills/objectstack-data/rules/indexing.md | 199 +++++++----------- 17 files changed, 715 insertions(+), 184 deletions(-) create mode 100644 .changeset/index-type-partial-removed.md create mode 100644 packages/drivers/driver-sql/src/declared-index-retired-keys.test.ts diff --git a/.changeset/index-type-partial-removed.md b/.changeset/index-type-partial-removed.md new file mode 100644 index 0000000000..df490c081b --- /dev/null +++ b/.changeset/index-type-partial-removed.md @@ -0,0 +1,70 @@ +--- +"@objectstack/spec": major +"@objectstack/metadata-core": patch +--- + +refactor(spec)!: retire `indexes[].type` and `indexes[].partial` — two authorable index keys no driver ever read (#5248, #4943) + +`IndexSchema` declared five keys; only three of them ever reached a `CREATE +INDEX`. `SqlDriver.syncDeclaredIndexes` builds every declared index through +knex's `table.index(fields, name)` / `table.unique(fields, { indexName })`, and +the drift differ's `DeclaredIndexInput` carries `name` / `fields` / `unique` / +`nullSafeColumns`. So: + +- **`partial`** — documented as *"Partial index condition (SQL WHERE clause)"* — + produced a **full** index with the predicate silently discarded. This was the + damaging half, because it reads as a correctness control: the platform's own + `sys_metadata` declared `partial: "state = 'active'"` for overlay uniqueness, + and what the declaration alone materialized was an *unrestricted* unique index. +- **`type`** additionally carried `.default('btree')`, so it appeared in **every** + parse output of **every** index — an access-method knob that had never + influenced a single statement, rendered as live configuration. (It was pinned + as such in a `sys_presence` test, on an object that never declared it.) + +Both are the ADR-0078 no-silently-inert / ADR-0049 enforce-or-remove shape. +Remove was chosen over enforce: enforcing needs per-dialect algorithm mapping +(`gin`/`gist` Postgres-only, `fulltext` MySQL-family), raw-SQL `CREATE INDEX … +WHERE` on the dialects that have partial indexes at all (MySQL does not), and a +redesign of how `isSyncReproducibleIndex` excludes partial indexes from +incremental sync — design cost for a capability with no demand. If a real need +appears it returns enforce-first. + +## Migration + +| FROM | TO | +| :--- | :--- | +| `indexes: [{ fields: […], type: 'gin' }]` | `indexes: [{ fields: […] }]` — create the specialised index from a database-layer migration | +| `indexes: [{ fields: […], partial: "state = 'active'" }]` | `indexes: [{ fields: […] }]` — issue `CREATE [UNIQUE] INDEX … WHERE …` from a runtime migration | + +**One-line fix: delete the key.** Neither removal changes any DDL, because no +DDL ever depended on them — verified byte-for-byte against the `CREATE INDEX` +statements SQLite actually stores +(`packages/drivers/driver-sql/src/declared-index-retired-keys.test.ts`). + +Both capabilities remain available where they are implementable. The index +method is the driver/dialect's choice. A partial index is issued as raw SQL from +a runtime migration — exactly what `metadata-protocol`'s `ensureOverlayIndex` +already does for `sys_metadata`, and what actually delivers that table's +active-row-scoped uniqueness today. + +⚠️ **Not affected:** driver-sql's own `partial` flag (`parseIndexDdl` / +`introspectIndexes` / `isSyncReproducibleIndex`). That is a boolean parsed back +out of the *database's own* DDL for drift detection — the opposite direction — +so migration-created partial indexes stay recognized and exempt from incremental +sync, unchanged. + +## The retirement kit + +- `retiredKey()` tombstones at `IndexSchema` (the shape is deliberately + `.strip()`, so a plain delete would swap one silent no-op for another): writing + either key is now a `tsc` error and a parse error carrying the prescription. + They sit at the bottom of the shape per the #5606 renderer note. +- **ADR-0087 D2 conversion + D3 chain step** (`object-index-type-partial-removed`, + `toMajor: 18`, opening the protocol-18 step): strips both keys from `objects[]` + and `objectExtensions[]`; `os migrate meta --from 17` rewrites sources + mechanically. A pure lossless delete — there was no effect to lose. +- **Producers flipped:** `sys_metadata` (`idx_sys_metadata_overlay_active`, the + case #4943 named) and `sys_view_definition` (`idx_sys_view_def_active`), both + with their comments corrected to say what is actually materialized. +- Published skill (`objectstack-data`), `content/docs/data-modeling/objects.mdx`, + liveness ledger note and generated baselines updated. diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index 75e71bfcc1..31a42f88f1 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -209,20 +209,34 @@ Optimize query performance: {/* os:check */} ```typescript indexes: [ - { fields: ['name'], type: 'btree', unique: false }, - { fields: ['email'], type: 'btree', unique: 'organization' }, - { fields: ['type', 'status'], type: 'btree', unique: false }, + { fields: ['name'] }, + { fields: ['email'], unique: 'organization' }, + { fields: ['type', 'status'] }, ] ``` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | `fields` | `string[]` | ✅ | Fields in the index | -| `type` | `enum` | optional | `'btree'`, `'hash'`, `'gin'`, `'gist'`, `'fulltext'` (default: `'btree'`) | -| `unique` | `boolean` | optional | Enforce uniqueness (default: `false`) | -| `partial` | `string` | optional | Conditional index (SQL WHERE clause) | +| `unique` | `boolean \| 'global' \| 'organization'` | optional | Enforce uniqueness, and at which scope (default: `false`) | | `name` | `string` | optional | Index name (auto-generated if omitted) | + + **`type` and `partial` were retired in protocol 18** (#5248, #4943). Neither + had a driver consumer: declared indexes are created through knex's + `table.index()` / `table.unique()`, so an authored `type` selected no access + method and an authored `partial` produced a **full** index with the predicate + silently discarded. Writing either now fails `tsc` and the parse with a + migration prescription — run `os migrate meta --from 17` to strip them. + + Both capabilities remain available where they are actually implementable: the + index method is the driver/dialect's choice, and a partial index is issued as + raw SQL from a runtime migration (`CREATE [UNIQUE] INDEX … WHERE …`, the way + `metadata-protocol` builds `sys_metadata`'s overlay index). Drift detection + reads partiality back from the database's own DDL, so migration-created + partial indexes are recognized and left alone. + + ### Additional Properties | Property | Type | Description | @@ -347,8 +361,8 @@ export const ProjectTask = ObjectSchema.create({ }, indexes: [ - { fields: ['status'], type: 'btree', unique: false }, - { fields: ['project', 'status'], type: 'btree', unique: false }, + { fields: ['status'] }, + { fields: ['project', 'status'] }, ], enable: { diff --git a/packages/drivers/driver-sql/src/declared-index-retired-keys.test.ts b/packages/drivers/driver-sql/src/declared-index-retired-keys.test.ts new file mode 100644 index 0000000000..081aeb8370 --- /dev/null +++ b/packages/drivers/driver-sql/src/declared-index-retired-keys.test.ts @@ -0,0 +1,162 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, afterEach, vi } from 'vitest'; + +import { SqlDriver, normalizeDeclaredIndex } from '../src/index.js'; + +/** + * `indexes[].type` / `indexes[].partial` were retired at protocol 18 (#5248, + * #4943, ADR-0049 enforce-or-remove). The retirement rests on ONE factual + * claim, and this file is that claim's proof rather than its restatement: + * + * **Dropping either key changes no DDL, because no DDL ever read them.** + * + * The claim is worth pinning because the two platform objects that authored + * `partial` (`sys_metadata`, `sys_view_definition`) both did so for a + * correctness reason — active-row-scoped uniqueness — and a reader who sees + * the key disappear from those declarations is entitled to ask whether a + * constraint was just relaxed. It was not: the constraint the DECLARATION + * produced was always the unrestricted one. What actually delivers the + * active-row scoping on `sys_metadata` is `metadata-protocol`'s runtime + * `ensureOverlayIndex` migration, which issues `CREATE UNIQUE INDEX … + * WHERE state = 'active'` in raw SQL and is untouched by this retirement. + * + * ⚠️ Do NOT confuse this with the driver's own `partial`. `parseIndexDdl` / + * `introspectIndexes` / `isSyncReproducibleIndex` carry a `partial: boolean` + * parsed back out of the DATABASE's own `CREATE INDEX` statement, consumed by + * drift detection so a DB-authored partial index is exempt from incremental + * sync. That is a different fact in a different direction (DB → us, not us → + * DB) and keeps working exactly as before — the tests in + * `sql-driver-overlay-index-drift.test.ts` own it. + */ +describe('retired declared-index keys are DDL-inert (#5248 / #4943)', () => { + let tmp: string | undefined; + let knexInstance: any; + + const makeDriver = () => { + tmp = mkdtempSync(join(tmpdir(), 'os-idx-retire-')); + const d = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: join(tmp, 'test.db') }, + useNullAsDefault: true, + }); + knexInstance = (d as any).knex; + (d as any).logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn() }; + return d; + }; + + afterEach(async () => { + await knexInstance?.destroy(); + knexInstance = undefined; + if (tmp) rmSync(tmp, { recursive: true, force: true }); + tmp = undefined; + }); + + /** + * Create `table` with the given declared indexes and return the CREATE INDEX + * statements SQLite actually stored, normalized for comparison. + */ + const createdIndexDdl = async (driver: any, table: string, indexes: any[]): Promise => { + await driver.knex.schema.createTable(table, (t: any) => { + t.string('type'); + t.string('name'); + t.string('organization_id'); + t.string('package_id'); + t.string('owner'); + t.string('state'); + t.string('tags'); + }); + const physical = new Set(['type', 'name', 'organization_id', 'package_id', 'owner', 'state', 'tags']); + await driver.syncDeclaredIndexes(table, indexes, physical, null); + const rows = await driver + .knex('sqlite_master') + .where({ type: 'index', tbl_name: table }) + .whereNotNull('sql') + .orderBy('name'); + return rows.map((r: any) => String(r.sql).replace(/\s+/g, ' ').trim()); + }; + + // The two real producers this PR flipped, plus a `type`-carrying index. + const cases: Array<{ label: string; withKeys: any; without: any }> = [ + { + label: 'sys_metadata / idx_sys_metadata_overlay_active', + withKeys: { + name: 'idx_sys_metadata_overlay_active', + fields: ['type', 'name', 'organization_id', 'package_id'], + unique: true, + partial: "state = 'active'", + }, + without: { + name: 'idx_sys_metadata_overlay_active', + fields: ['type', 'name', 'organization_id', 'package_id'], + unique: true, + }, + }, + { + label: 'sys_view_definition / idx_sys_view_def_active', + withKeys: { + name: 'idx_sys_view_def_active', + fields: ['name', 'organization_id', 'owner'], + unique: true, + partial: "state = 'active'", + }, + without: { + name: 'idx_sys_view_def_active', + fields: ['name', 'organization_id', 'owner'], + unique: true, + }, + }, + { + label: 'a non-default index method (gin)', + withKeys: { name: 'idx_tags', fields: ['tags'], type: 'gin' }, + without: { name: 'idx_tags', fields: ['tags'] }, + }, + ]; + + for (const { label, withKeys, without } of cases) { + it(`${label}: identical CREATE INDEX with and without the retired key(s)`, async () => { + const a = makeDriver(); + const withDdl = await createdIndexDdl(a, 't_probe', [withKeys]); + await knexInstance.destroy(); + knexInstance = undefined; + rmSync(tmp!, { recursive: true, force: true }); + tmp = undefined; + + const b = makeDriver(); + const withoutDdl = await createdIndexDdl(b, 't_probe', [without]); + + // The real database's own stored DDL, byte-for-byte. + expect(withDdl).toEqual(withoutDdl); + expect(withDdl).toHaveLength(1); + // And it is a FULL index — no WHERE clause was ever emitted, which is + // exactly why the declared predicate was inert. + expect(withDdl[0]!.toLowerCase()).not.toContain(' where '); + }); + } + + it('normalizeDeclaredIndex ignores both keys (the single seam every index create goes through)', () => { + const normalized = normalizeDeclaredIndex( + 'sys_metadata', + { + name: 'idx_sys_metadata_overlay_active', + fields: ['type', 'name', 'organization_id', 'package_id'], + unique: true, + // Cast: `DeclaredIndexInput` never declared these — that is the point. + ...({ type: 'gin', partial: "state = 'active'" } as Record), + } as any, + null, + ); + expect(normalized).toEqual({ + name: 'idx_sys_metadata_overlay_active', + columns: ['type', 'name', 'organization_id', 'package_id'], + unique: true, + }); + // Neither key survives into the shape the create path consumes. + expect(normalized).not.toHaveProperty('partial'); + expect(normalized).not.toHaveProperty('type'); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver-overlay-index-drift.test.ts b/packages/drivers/driver-sql/src/sql-driver-overlay-index-drift.test.ts index 716bc5dab1..12a3580b4e 100644 --- a/packages/drivers/driver-sql/src/sql-driver-overlay-index-drift.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-overlay-index-drift.test.ts @@ -83,11 +83,17 @@ describe('overlay index drift on a fresh database (#4884)', () => { state: { type: 'string' }, }, indexes: [ + // Mirrors `metadata-core`'s sys-metadata.object.ts. It carried + // `partial: "state = 'active'"` until #5248 / #4943 retired the key; + // the declaration never produced a predicate (knex's `table.unique()` + // cannot express one), so dropping it here changes nothing this file + // measures — it keeps the fixture honest about what a real boot + // declares. The partial UNIQUE the drift cases below care about is the + // one `runEnsureOverlayIndex` issues in raw SQL. { name: 'idx_sys_metadata_overlay_active', fields: ['type', 'name', 'organization_id', 'package_id'], unique: true, - partial: "state = 'active'", }, { name: 'idx_sys_metadata_org_type', fields: ['organization_id', 'type'] }, { fields: ['state'] }, diff --git a/packages/metadata-core/src/objects/sys-metadata.object.ts b/packages/metadata-core/src/objects/sys-metadata.object.ts index 0281d79f7b..fda98fc776 100644 --- a/packages/metadata-core/src/objects/sys-metadata.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata.object.ts @@ -200,21 +200,33 @@ export const SysMetadataObject = ObjectSchema.create({ indexes: [ // ADR-0005 (revised 2026-05) + ADR-0048: overlay uniqueness is scoped by - // (type, name, organization_id, package_id), restricted to active rows so - // resets / archived versions don't collide. `package_id` is part of the + // (type, name, organization_id, package_id). `package_id` is part of the // discriminator so two installed packages shipping the same `type`/`name` // each get their OWN customization row (a package-less / global overlay // uses NULL). environment_id is deprecated and not part of the - // discriminator. The runtime layer (protocol.ts ensureOverlayIndex) issues - // a DROP-then-CREATE migration that uses `COALESCE(package_id,'')` so the - // package-less rows stay unique among themselves (SQLite treats NULLs as - // distinct in a plain unique index); this declaration is the fallback shape - // for drivers without the runtime migration. + // discriminator. + // + // ⚠️ The active-row restriction is NOT declared here, and never was in any + // effective sense: this entry carried `partial: "state = 'active'"` until + // #5248 / #4943 retired the key, and no driver ever emitted the predicate + // — `syncDeclaredIndexes` builds indexes through knex's `table.unique()`, + // which cannot express a `WHERE`. So the shape this declaration has always + // materialized is the UNRESTRICTED unique index below; dropping the key is + // a zero-DDL change (verified byte-for-byte against the created SQL). + // + // What actually delivers active-row scoping is the runtime layer: + // `protocol.ts ensureOverlayIndex` issues a DROP-then-CREATE migration for + // `CREATE UNIQUE INDEX … ON sys_metadata (type, name, organization_id, + // COALESCE(package_id,'')) WHERE state = 'active'` — which additionally + // fixes NULL-distinctness for package-less rows, something this + // declaration could not express either. This entry is therefore the + // coarser fallback that a driver without that runtime migration gets: it + // still prevents duplicate ACTIVE overlays, at the cost of also colliding + // with archived/reset rows. { name: 'idx_sys_metadata_overlay_active', fields: ['type', 'name', 'organization_id', 'package_id'], unique: true, - partial: "state = 'active'", }, { name: 'idx_sys_metadata_org_type', fields: ['organization_id', 'type'] }, { fields: ['type', 'scope'] }, diff --git a/packages/metadata-core/src/objects/sys-view-definition.object.ts b/packages/metadata-core/src/objects/sys-view-definition.object.ts index 8b0e6abe55..0f0be07148 100644 --- a/packages/metadata-core/src/objects/sys-view-definition.object.ts +++ b/packages/metadata-core/src/objects/sys-view-definition.object.ts @@ -119,13 +119,24 @@ export const SysViewDefinitionObject = ObjectSchema.create({ }, indexes: [ - // A given view name is unique per (organization, owner) among active rows — - // a shared view (owner NULL) and each user's personal views don't collide. + // A given view name is unique per (organization, owner) — a shared view + // (owner NULL) and each user's personal views don't collide. + // + // ⚠️ This entry carried `partial: "state = 'active'"` until #5248 / #4943 + // retired the key, intending "among ACTIVE rows". No driver ever emitted + // the predicate (`syncDeclaredIndexes` builds indexes through knex's + // `table.unique()`, which cannot express a `WHERE`), so the index that has + // always been created is the unrestricted one below — dropping the key is + // a zero-DDL change. Unlike `sys_metadata`, there is NO runtime migration + // issuing the partial form for this table, so the active-row scoping is + // simply not delivered anywhere today: an archived/reset view still + // occupies its (name, organization_id, owner) slot. Tracked separately — + // deciding whether this table wants an `ensureOverlayIndex`-style + // migration is a behaviour change, out of scope for the key retirement. { name: 'idx_sys_view_def_active', fields: ['name', 'organization_id', 'owner'], unique: true, - partial: "state = 'active'", }, // The switcher query: views for one object within a tenant. { name: 'idx_sys_view_def_object', fields: ['organization_id', 'object'] }, diff --git a/packages/services/service-realtime/src/objects/sys-presence.object.test.ts b/packages/services/service-realtime/src/objects/sys-presence.object.test.ts index 9f3e84e1b0..8b18ac8b08 100644 --- a/packages/services/service-realtime/src/objects/sys-presence.object.test.ts +++ b/packages/services/service-realtime/src/objects/sys-presence.object.test.ts @@ -60,10 +60,16 @@ describe('SysPresence object definition', () => { }); it('should have indexes on user_id, session_id, and status', () => { + // No `type` here: `sys-presence.object.ts` never declared one. It used to + // show up anyway because `IndexSchema.type` carried `.default('btree')`, + // so the parse materialized an access-method knob that no driver has ever + // read into every index of every object — this assertion was pinning that + // phantom. The key was retired at protocol 18 (#5248), and the parsed + // shape is now exactly what the author wrote. expect(SysPresence.indexes).toEqual([ - { fields: ['user_id'], unique: false, type: 'btree' }, - { fields: ['session_id'], unique: true, type: 'btree' }, - { fields: ['status'], unique: false, type: 'btree' }, + { fields: ['user_id'], unique: false }, + { fields: ['session_id'], unique: true }, + { fields: ['status'], unique: false }, ]); }); diff --git a/packages/spec/authorable-surface.base.json b/packages/spec/authorable-surface.base.json index 4da66f15de..5b95013956 100644 --- a/packages/spec/authorable-surface.base.json +++ b/packages/spec/authorable-surface.base.json @@ -1,6 +1,6 @@ { "description": "In-tree anchor for the authorable-surface deletion gate (#4650, #5235): a verbatim copy of the keys in authorable-surface.json as they stood at `baseRev`, a commit on origin/main. A build that CAN reach origin/main anchors on the merge base instead, and re-verifies this file against `baseRev` — so a PR that edits it to hide a deletion goes red wherever the network exists. A build that CANNOT reach GitHub (image-build stages, air-gapped, fork, historical-tag reproduction) anchors here instead of failing. Written only by `gen:schema`, only from a git-resolved baseline — never from the build that is being checked. See #5235.", - "baseRev": "5acb93add66435880ffed0d3aff79db29ae1e932", + "baseRev": "fc5f536a13b3217c74e18788566200903644e0eb", "keys": [ "ai/AIModelConfig:maxTokens", "ai/AIModelConfig:model", @@ -529,6 +529,7 @@ "api/ApiRoutes:data", "api/ApiRoutes:discovery", "api/ApiRoutes:i18n", + "api/ApiRoutes:mcp", "api/ApiRoutes:metadata", "api/ApiRoutes:notifications", "api/ApiRoutes:packages", @@ -875,6 +876,7 @@ "api/Discovery:name", "api/Discovery:routes", "api/Discovery:schemaDiscovery", + "api/Discovery:scoping", "api/Discovery:services", "api/Discovery:version", "api/DispatcherConfig:fallback", @@ -1063,6 +1065,7 @@ "api/GetDiscoveryResponse:name", "api/GetDiscoveryResponse:routes", "api/GetDiscoveryResponse:schemaDiscovery", + "api/GetDiscoveryResponse:scoping", "api/GetDiscoveryResponse:services", "api/GetDiscoveryResponse:version", "api/GetEffectivePermissionsResponse:objects", @@ -3814,6 +3817,7 @@ "data/StateMachineValidation:type", "data/StringOperator:$contains", "data/StringOperator:$endsWith", + "data/StringOperator:$icontains", "data/StringOperator:$notContains", "data/StringOperator:$startsWith", "data/TenancyConfig:enabled", @@ -6923,6 +6927,9 @@ "ui/ActionParam:requiresFeature", "ui/ActionParam:type", "ui/ActionParam:visible", + "ui/ActionSession:organizationId", + "ui/ActionSession:roles", + "ui/ActionSession:userId", "ui/AddRecordConfig:enabled", "ui/AddRecordConfig:formView", "ui/AddRecordConfig:mode", @@ -7699,6 +7706,7 @@ "ui/RecordDetailsProps:aria", "ui/RecordDetailsProps:columns", "ui/RecordDetailsProps:fields", + "ui/RecordDetailsProps:hideFields", "ui/RecordDetailsProps:layout", "ui/RecordDetailsProps:sections", "ui/RecordHighlightsProps:aria", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 31cd8796ef..30541001af 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -3437,8 +3437,8 @@ "data/ImportFieldMapping:transform", "data/Index:fields", "data/Index:name", - "data/Index:partial", - "data/Index:type", + "data/Index:partial [RETIRED]", + "data/Index:type [RETIRED]", "data/Index:unique", "data/JSONValidation:_lock", "data/JSONValidation:_lockDocsUrl", diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index fd0aa0e577..c77b6288ce 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -78,7 +78,8 @@ "indexes": { "status": "live", "evidence": "packages/drivers/driver-sql/src/sql-driver.ts:1181", - "note": "DDL." + "verifiedAt": "2026-08-06", + "note": "DDL — but only through `name`/`fields`/`unique`. `syncDeclaredIndexes` creates every declared index via knex's `table.index()` / `table.unique()`, and the differ's `DeclaredIndexInput` carries `name`/`fields`/`unique`/`nullSafeColumns`. The other two child keys were RETIRED 2026-08-06 (#5248, #4943, ADR-0049): `indexes[].type` selected no access method (and its `.default('btree')` materialized an inert knob into every parse output) and `indexes[].partial` produced a FULL index with the predicate discarded. Both are `retiredKey()` tombstones at `IndexSchema` and are stripped from sources by the protocol-18 conversion `object-index-type-partial-removed`. The container stays `live` because the surviving keys drive real DDL. ⚠ Not to be confused with driver-sql's own `partial: boolean`, which is parsed back out of the database's own CREATE INDEX DDL for drift detection and is unrelated to the declaration surface." }, "validations": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index b9f349916a..29e8fd2015 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -3583,6 +3583,117 @@ const objectEnableTrashMruRemoved: MetadataConversion = { }, }; +/** + * `indexes[].type` / `indexes[].partial` leave the surface (protocol 18, + * #5248 + #4943, ADR-0049 enforce-or-remove). + * + * Neither key had a single DDL consumer. `SqlDriver.syncDeclaredIndexes` builds + * every declared index through knex's `table.index(fields, name)` / + * `table.unique(fields, { indexName })`, and the differ's `DeclaredIndexInput` + * declares `name` / `fields` / `unique` / `nullSafeColumns` — so an authored + * `type` selected no access method and an authored `partial` produced a FULL + * index with the predicate silently discarded. `type` additionally carried + * `.default('btree')`, so it was materialized into *every* parse output: the + * ADR-0078 shape where an inert knob reads as live configuration. + * + * The maintainer chose remove over enforce (#5248, 2026-08-06): enforcing means + * per-dialect algorithm mapping, raw-SQL `CREATE INDEX … WHERE` (MySQL has no + * partial index), and reworking how `isSyncReproducibleIndex` excludes partial + * indexes from incremental sync — design cost for a capability with no demand. + * + * ⚠️ NOT the same `partial` as the driver's: `schema-drift.ts` carries a + * `partial: boolean` parsed back out of the DATABASE's own `CREATE INDEX` DDL + * (`parseIndexDdl`), consumed by drift detection. That is untouched here, and + * the exemption it grants DB-authored partial indexes still stands. + * + * `retiredFromLoadPath`: both keys are tombstoned at `IndexSchema`, so a live + * author is taught by the parse error. This entry exists so stored ≤17 rows + * replay clean through `applyConversionsToStoredItem` (without it a + * pre-removal row would flag `metadata_spec_invalid` forever) and so + * `os migrate meta --from 17` rewrites sources mechanically. + */ +const objectIndexTypePartialRemoved: MetadataConversion = { + id: 'object-index-type-partial-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'object.indexes[].type / object.indexes[].partial', + summary: + "object index keys 'indexes[].type'/'indexes[].partial' removed (#5248, #4943 — no driver " + + 'ever read either: the index method is the dialect\'s choice and a partial index is built ' + + 'by a database-layer migration, not declared)', + apply(stack, emit) { + // `indexes` is an ARRAY one level down, so `stripKeys` (top-level only) + // cannot reach it — drill in and copy-on-write, so an object whose indexes + // carry neither key keeps its identity. Both `objects[]` and + // `objectExtensions[]` embed `IndexSchema`, so both are walked. + const stripIndexes = (owner: Dict, path: string): Dict => { + const indexes = owner.indexes; + if (!Array.isArray(indexes)) return owner; + let changed = false; + const next = indexes.map((idx, i) => { + if (!isDict(idx)) return idx; + const stripped = stripKeys(idx, ['type', 'partial'], emit, `${path}.indexes[${i}]`); + if (stripped !== idx) changed = true; + return stripped; + }); + return changed ? { ...owner, indexes: next } : owner; + }; + let out = mapCollection(stack, 'objects', stripIndexes); + out = mapCollection(out, 'objectExtensions', stripIndexes); + return out; + }, + fixture: { + before: { + objects: [ + { + name: 'crm_invoice', + label: 'Invoice', + indexes: [ + // both retired keys on one index, alongside surviving ones + { + name: 'idx_invoice_active_no', + fields: ['invoice_no'], + unique: 'global', + type: 'btree', + partial: "state = 'active'", + }, + // an index carrying neither key passes through untouched + { name: 'idx_invoice_customer', fields: ['customer'] }, + ], + }, + ], + objectExtensions: [ + { + extend: 'crm_invoice', + indexes: [{ fields: ['tags'], type: 'gin' }], + }, + ], + }, + // Three notices: two keys on the object's first index, one on the + // extension's. The surviving `name`/`fields`/`unique` prove the strip is + // surgical rather than an index-level delete. + after: { + objects: [ + { + name: 'crm_invoice', + label: 'Invoice', + indexes: [ + { name: 'idx_invoice_active_no', fields: ['invoice_no'], unique: 'global' }, + { name: 'idx_invoice_customer', fields: ['customer'] }, + ], + }, + ], + objectExtensions: [ + { + extend: 'crm_invoice', + indexes: [{ fields: ['tags'] }], + }, + ], + }, + expectedNotices: 3, + }, +}; + /** * The retry policy converges to one declaration (protocol 17, #4661 — the * #4535 C8 dual-source cluster). @@ -4322,6 +4433,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { expect(parsed.indexes[0].fields).toEqual(['name']); }); - it('the drift is real: the console offers `where`, this schema declares `partial`', () => { + it('the console drift outlived BOTH spellings: `where` strips, `partial` is now retired', () => { // objectui `metadata-admin/EmbeddedItemEditor.tsx` → FALLBACK_SCHEMAS.index - // publishes `where` for the partial-index predicate. The spec's key is - // `partial`. The editor splices its output into `object.indexes[]` and PUTs - // the whole object, and `saveMetaItem` keeps the body verbatim — so closing - // this shape 422s a control the console itself renders (the #5114 class). - accept(IndexSchema, { fields: ['name'], partial: "status = 'open'" }); + // publishes `where` for the partial-index predicate. The spec's key WAS + // `partial` — until #5248 / #4943 retired it (no driver ever emitted the + // predicate). So the editor now renders a control for a key that does not + // exist under either spelling, which is #5247's job to delete. + // + // The two halves fail DIFFERENTLY, and that difference is the point: + // - `where` was never declared here, and the shape still `.strip()`s, so + // it is discarded in silence — the held-open defect above; + // - `partial` IS declared, as a tombstone, so it is rejected loudly with + // the migration prescription rather than stripped (the whole reason a + // non-strict schema gets a tombstone instead of a plain delete). const parsedWhere = accept(IndexSchema, { fields: ['name'], where: "status = 'open'" }) as Record; expect(parsedWhere.where, 'the console spelling is silently discarded today').toBeUndefined(); + + const retired = IndexSchema.safeParse({ fields: ['name'], partial: "status = 'open'" }); + expect(retired.success, '`partial` is retired, not stripped').toBe(false); + expect(retired.error!.issues[0]!.message).toMatch(/`indexes\[\]\.partial` was removed.*Delete the key/s); }); it('its SIBLINGS in the same file are closed — this is a held site, not an unwalked file', () => { diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 0dcddb80c0..29f11fbb8b 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -229,6 +229,62 @@ describe('IndexSchema', () => { }); }); +/** + * `indexes[].type` / `indexes[].partial` retirement (#5248, #4943, ADR-0049). + * + * Both were authorable with zero DDL consumers. `IndexSchema` is not + * `.strict()`, so a plain delete would have Zod strip an authored value in + * silence — swapping one no-op for another (#3726 / #3733, the ADR-0104 + * class). The tombstone is what makes the removal audible, so these tests pin + * the PRESCRIPTION, not merely the rejection. + */ +describe('IndexSchema retired keys (#5248 / #4943)', () => { + it('REJECTS `type`, with the fix and the reason in the message', () => { + expect(() => IndexSchema.parse({ fields: ['tags'], type: 'gin' })) + .toThrow(/`indexes\[\]\.type` was removed.*no driver ever read it.*Delete the key/s); + }); + + it('REJECTS `partial`, naming the database-layer replacement', () => { + expect(() => IndexSchema.parse({ fields: ['name'], partial: "state = 'active'" })) + .toThrow(/`indexes\[\]\.partial` was removed.*Delete the key.*CREATE \[UNIQUE\] INDEX/s); + }); + + it('points at the CLI conversion rather than naming the conversion id', () => { + for (const bad of [{ fields: ['a'], type: 'btree' }, { fields: ['a'], partial: 'x' }]) { + expect(() => IndexSchema.parse(bad)).toThrow(/os migrate meta --from 17/); + } + }); + + it('the live keys are untouched — the retirement is surgical', () => { + const parsed = IndexSchema.parse({ + name: 'idx_invoice_no', + fields: ['invoice_no'], + unique: 'organization', + }); + expect(parsed).toEqual({ name: 'idx_invoice_no', fields: ['invoice_no'], unique: 'organization' }); + }); + + it('no longer materializes a phantom `btree` into every parsed index', () => { + // The louder half of the defect: `type` carried `.default('btree')`, so an + // access-method knob nothing has ever read appeared in the parse output of + // every index of every object (it was pinned in service-realtime's + // sys_presence test, on an object that never declared it). + const parsed = IndexSchema.parse({ fields: ['email'] }); + expect(parsed).not.toHaveProperty('type'); + expect(parsed).not.toHaveProperty('partial'); + expect(parsed).toEqual({ fields: ['email'], unique: false }); + }); + + it('rejects the retired keys through a whole object too, not just the sub-schema', () => { + expect(() => ObjectSchema.parse({ + name: 'crm_invoice', + label: 'Invoice', + fields: { name: { type: 'text', label: 'Name' } }, + indexes: [{ fields: ['name'], partial: "state = 'active'" }], + })).toThrow(/`indexes\[\]\.partial` was removed/s); + }); +}); + describe('ObjectSchema', () => { describe('Basic Object Properties', () => { it('should accept minimal valid object', () => { diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index cd905725f0..3818262982 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -15,6 +15,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { strictUnknownKeyError } from '../shared/suggestions.zod'; import { strictObject } from '../shared/strict-object'; import { ProtectionSchema } from '../shared/protection.zod'; +import { retiredKey } from '../shared/retired-key'; export const ApiMethod = z.enum([ 'get', 'list', // Read 'create', 'update', 'delete', // Write @@ -266,16 +267,51 @@ export const ObjectCapabilities = z.object({ /** * Schema for database indexes. - * Enhanced with additional index types and configuration options - * + * + * The declaration surface is exactly what the driver materializes: + * `name` / `fields` / `unique` (ADR-0120 scope). Nothing else — see the + * retirement note below. + * * @example * { * name: "idx_account_name", * fields: ["name"], - * type: "btree", * unique: true * } * + * ## `type` / `partial` were RETIRED at protocol 18 (#5248, #4943, ADR-0049) + * + * Both were authorable and had **zero** DDL consumers. + * `SqlDriver.syncDeclaredIndexes` builds every declared index through knex's + * `table.unique(fields, { indexName })` / `table.index(fields, name)`, and the + * differ's `DeclaredIndexInput` (`driver-sql/src/schema-drift.ts`) carries + * `name` / `fields` / `unique` / `nullSafeColumns` — neither key ever reached + * a `CREATE INDEX`. `type` was the louder of the two because it also carried + * `.default('btree')`, so it appeared in *every* parse output: a knob that had + * never influenced a single statement, rendered as live configuration. That is + * the exact shape ADR-0078 (no-silently-inert-metadata) and ADR-0049 + * (enforce-or-remove) exist to delete. + * + * The maintainer chose **remove** over **enforce** (2026-08-06, #5248): + * enforcing would mean per-dialect algorithm mapping (`gin`/`gist` Postgres-only, + * `fulltext` MySQL-only), raw-SQL `CREATE INDEX … WHERE` (MySQL has no partial + * index at all), and a redesign of how `isSyncReproducibleIndex` excludes + * partial indexes from incremental sync — real design cost for a capability + * nothing has asked for. If a genuine need appears, it comes back enforce-first. + * + * Replacements: an index **method** is the driver/dialect's choice, not a + * declaration-surface concern. A **partial** index is built at the database + * layer (a runtime migration issuing `CREATE UNIQUE INDEX … WHERE`, the way + * `metadata-protocol`'s `ensureOverlayIndex` does for `sys_metadata`); drift + * detection's exemption for DB-authored partial indexes is unaffected — + * `isSyncReproducibleIndex` reads a boolean parsed out of the database's OWN + * DDL (`parseIndexDdl`), which never had anything to do with this string. + * + * ⚠️ The tombstones sit at the BOTTOM of the shape deliberately (#5606): the + * docs renderer prints only the first `INLINE_KEY_LIMIT` keys of an inline + * shape and has no `z.never()` branch, so a tombstone high in the shape prints + * as `any` and reads as a free-form slot. + * * ## ⛔ Deliberately still `.strip()` — #4001 批 20 held this one site * * Every other inner block of this file was closed in that batch. This one was @@ -286,30 +322,18 @@ export const ObjectCapabilities = z.object({ * (`objectui` → `metadata-admin/EmbeddedItemEditor.tsx`, `FALLBACK_SCHEMAS.index`) * ships its own hand-copied JSON-Schema for this shape — the framework does not * publish one, because `index` is an embedded-only sub-type with no metadata - * type of its own. That copy has drifted: - * - * - it offers **`where`** for the partial-index predicate; this schema - * declares **`partial`**; - * - it offers **`brin`** in the algorithm enum, which this schema does not. - * - * The editor splices its form output into `object.indexes[]` and PUTs the whole - * object, and `saveMetaItem` keeps the body verbatim while validating it. So - * today an admin who fills in "Partial-index predicate" gets a clean save and a - * key that this schema silently drops on every later parse — and closing this - * shape would turn that same click into a 422 on a control the console itself - * renders (the #5114 class). - * - * Closing it is still the right end state, but it is gated on the producer - * being fixed first (contract-first: the drift is in the copy, not here), and - * on an ADR-0049 answer for `type`/`partial` (#5247 / #5248) — neither is read by any driver - * (`syncDeclaredIndexes` in `driver-sql` consumes `name`/`fields`/`unique` - * only), so pointing an author at `partial` today would be a guidance entry - * that claims more than the platform delivers (ledger finding 18). + * type of its own. That copy has drifted: it offers **`where`** for the partial + * predicate and **`brin`** in the algorithm enum. Both of its drifted controls + * now edit keys that no longer exist at all, which is #5247's job to delete + * (it is `Blocked-by` this retirement). Until that lands, an admin filling in + * "Partial-index predicate" still gets a clean save of a key this schema drops + * — closing the shape would turn that same click into a 422 on a control the + * console itself renders (the #5114 class), so the strip stays until the + * producer is fixed (contract-first: the drift is in the copy, not here). */ export const IndexSchema = lazySchema(() => z.object({ name: z.string().optional().describe('Index name (auto-generated if not provided)'), fields: z.array(z.string()).describe('Fields included in the index'), - type: z.enum(['btree', 'hash', 'gin', 'gist', 'fulltext']).optional().default('btree').describe('Index algorithm type'), // Unique scope on a DECLARED index (ADR-0120 D1, amending #3696): // // - `'global'` — the VERBATIM contract: materialized over exactly the @@ -337,7 +361,36 @@ export const IndexSchema = lazySchema(() => z.object({ // but new code says `unique: 'organization'` — the hand-written composite // is NOT NULL-safe (#5030). unique: UniqueScopeSchema.optional().default(false).describe("Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18, #5082) — state the scope. 'tenant'/'org' are rejected — the word is 'organization'"), - partial: z.string().optional().describe('Partial index condition (SQL WHERE clause for conditional indexes)'), + + // ── Tombstones (ADR-0049 / ADR-0087) ───────────────────────────────── + // Kept LAST in the shape on purpose — see the #5606 note in the block + // comment above. `IndexSchema` is not `.strict()`, so a plain delete would + // make Zod strip an authored value silently, which is the same no-op these + // keys already were (#3726 / #3733, the ADR-0104 class). The tombstone + // makes the removal audible in the two channels an upgrading author + // actually reads: `tsc` (input type `never`) and the parse itself. + // `object-index-type-partial-removed` strips both from stored/authored + // sources on the protocol-18 migration. + type: retiredKey( + '`indexes[].type` was removed in @objectstack/spec 18.0.0 (#5248, ADR-0049) — no driver ever ' + + 'read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex\'s ' + + '`table.index()` / `table.unique()`, which cannot express an access method, so the value ' + + 'changed no DDL; its `.default(\'btree\')` merely made an inert knob show up in every parse ' + + 'output. Delete the key. The index method is the driver/dialect\'s decision (Postgres ' + + 'defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a ' + + 'database-layer migration when a workload actually needs one). ' + + 'Run `os migrate meta --from 17` to rewrite it automatically.', + ), + partial: retiredKey( + '`indexes[].partial` was removed in @objectstack/spec 18.0.0 (#5248, #4943, ADR-0049) — no ' + + 'driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a ' + + 'FULL index and the predicate silently did nothing. Delete the key. Partial indexes are ' + + 'built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … ' + + 'WHERE ` from a runtime migration (this is what `metadata-protocol`\'s ' + + '`ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it ' + + 'reads partiality back from the database\'s own DDL, never from this key. ' + + 'Run `os migrate meta --from 17` to rewrite it automatically.', + ), })); /** diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 6b0c49d558..a6c575f05a 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1884,6 +1884,54 @@ const step17: MigrationStep = { ], }; +/** + * Protocol 18 step. + * + * Opens the 18 lane with the `IndexSchema` retirement (#5248, #4943). Purely + * mechanical: two authorable keys with zero DDL consumers are deleted, so + * there is no semantic residue and nothing an author has to decide. + * + * ⚠️ This step targets a major the running package has not reached yet + * (`PROTOCOL_MAJOR` is read from `packages/spec/package.json`, today 17.x). + * `composeMigrationChain` filters `m <= toMajor` and `toMajor` defaults to + * `PROTOCOL_MAJOR`, so the step is *registered but dormant*: `os migrate meta + * --from 17` composes no hop for it until the package is 18.0.0, at which + * point it activates with no further edit. That is deliberate — the retirement + * was ruled out of the v17 rc window (#5248), and registering it now is what + * keeps `spec-changes.json`, the upgrade guide and the `spec_changes` MCP tool + * carrying the break from the moment it lands in the schema. + */ +const step18: MigrationStep = { + toMajor: 18, + rationale: + 'Protocol 18 removes the two inert `IndexSchema` keys (#5248, #4943, ADR-0049 ' + + 'enforce-or-remove): `indexes[].type` and `indexes[].partial`. Neither ever had a DDL ' + + 'consumer — `SqlDriver.syncDeclaredIndexes` creates declared indexes through knex\'s ' + + '`table.index()` / `table.unique()`, and the drift differ\'s `DeclaredIndexInput` carries ' + + 'only `name`/`fields`/`unique`/`nullSafeColumns` — so an authored `type` selected no ' + + 'access method and an authored `partial` produced a FULL index with its predicate ' + + 'discarded. `partial` was the more damaging of the two because it read as a correctness ' + + 'control: the platform\'s own `sys_metadata` declared `partial: "state = \'active\'"` for ' + + 'overlay uniqueness, and what the declaration alone materialized was an unrestricted ' + + 'unique index (the active-row scoping is delivered by a runtime migration, ' + + '`metadata-protocol`\'s `ensureOverlayIndex`, not by the key). `type` was the louder: its ' + + '`.default(\'btree\')` put an inert knob into every parse output, so it read as live ' + + 'configuration — the ADR-0078 no-silently-inert shape.\n\n' + + 'Remove was chosen over enforce (maintainer ruling, 2026-08-06): enforcing would require ' + + 'per-dialect algorithm mapping (`gin`/`gist` Postgres-only, `fulltext` MySQL-only), ' + + 'raw-SQL `CREATE INDEX … WHERE` on the dialects that have partial indexes at all (MySQL ' + + 'does not), and a redesign of how `isSyncReproducibleIndex` excludes partial indexes from ' + + 'incremental sync — real design cost for a capability nothing has asked for. If a genuine ' + + 'need appears it returns enforce-first.\n\n' + + 'Both are lossless deletes: no DDL changes, because no DDL ever depended on them. The ' + + 'replacements are the mechanisms that were already doing the work — the index method is ' + + 'the driver/dialect\'s decision, and a partial index is issued by a database-layer ' + + 'migration. Drift detection is untouched: the `partial` flag it consumes is parsed back ' + + 'out of the database\'s OWN `CREATE INDEX` DDL and never came from this key.', + conversionIds: ['object-index-type-partial-removed'], + semantic: [], +}; + /** All migration steps, keyed by the major they migrate into. */ export const MIGRATIONS_BY_MAJOR: Readonly> = { 11: step11, @@ -1893,6 +1941,7 @@ export const MIGRATIONS_BY_MAJOR: Readonly> = { 15: step15, 16: step16, 17: step17, + 18: step18, }; /** The majors that have a step, ascending. */ diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index f9d3d708f8..be8e461e54 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -379,17 +379,24 @@ See [rules/validation.md](./rules/validation.md) for all types and examples. ### Index Patterns -**Omit default values:** `type` defaults to `'btree'`, `unique` defaults to `false`. +**The whole declaration surface is `fields` / `unique` / `name`.** `unique` +defaults to `false`; omit it when that is what you mean. ```typescript indexes: [ - { fields: ['status', 'created_at'] }, // btree (default) + { fields: ['status', 'created_at'] }, // composite { fields: ['email'], unique: 'organization' }, // unique per organization { fields: ['hostname'], unique: 'global' }, // unique platform-wide - { fields: ['description'], type: 'fulltext' }, // non-default type + { name: 'idx_acct_status', fields: ['status'] }, // custom name ] ``` +> **`type` and `partial` were retired at protocol 18** (#5248, #4943): no driver +> ever read either, so an authored `type` chose no access method and an authored +> `partial` produced a full index with the predicate discarded. Both are now a +> `tsc` error and a parse error; `os migrate meta --from 17` strips them. Access +> methods and partial predicates are database-layer migrations. + > **A unique index must state its scope** — `'organization'` (one holder per > organization, NULL-safe) or `'global'` (one holder across the installation). > On a declared index bare `unique: true` is the deprecated spelling of @@ -397,7 +404,8 @@ indexes: [ > warns and protocol 18 rejects it. On a FIELD, `unique: true` means > `'organization'` and stays valid. -See [rules/indexing.md](./rules/indexing.md) for composite/partial/gin/gist indexes. +See [rules/indexing.md](./rules/indexing.md) for composite indexes, unique scope, +and how to build partial / gin / gist indexes at the database layer. ### Lifecycle Hooks diff --git a/skills/objectstack-data/rules/indexing.md b/skills/objectstack-data/rules/indexing.md index 6e77dd9aed..998597261f 100644 --- a/skills/objectstack-data/rules/indexing.md +++ b/skills/objectstack-data/rules/indexing.md @@ -9,28 +9,37 @@ ObjectStack automatically creates indexes for: - Foreign keys (lookup/master_detail fields) - Unique constraints -**Only declare non-default values.** `type` defaults to `'btree'` and `unique` defaults to `false` — omit them when using defaults. +**Only declare non-default values.** `unique` defaults to `false` — omit it when using the default. -## Index Types +## The declaration surface is exactly three keys -| Type | Default? | When to Use | Performance | -|:-----|:---------|:------------|:------------| -| `btree` | ✅ Yes | Equality and range queries (`=`, `<`, `>`, `BETWEEN`) | Excellent | -| `hash` | No | Exact equality only (`=`) — rare use case | Fast for `=`, poor for ranges | -| `fulltext` | No | Text search columns (descriptions, notes) | Text search only | -| `gin` | No | Array / JSONB containment, full-text search | JSONB, arrays, tags | -| `gist` | No | Geospatial / range types | Location, geometry | +A declared index has `name`, `fields` and `unique`. That is the whole surface, +and it is the whole surface *because* it is all the driver materializes: +`syncDeclaredIndexes` creates every declared index through knex's +`table.index(fields, name)` / `table.unique(fields, { indexName })`. + +| Key | Required | Meaning | +|:----|:---------|:--------| +| `fields` | ✅ | The indexed columns, in order (left-to-right rule below) | +| `unique` | optional | Uniqueness **and its scope** — see ADR-0120 section below | +| `name` | optional | Custom index name; auto-generated when omitted | + +> **Retired at protocol 18 (#5248, #4943): `type` and `partial`.** Both were +> authorable and neither was ever read by any driver — an authored `type` +> selected no access method, and an authored `partial` produced a **full** +> index with the predicate silently discarded. Writing either is now a `tsc` +> error and a parse error carrying the migration prescription; run +> `os migrate meta --from 17` to strip them automatically. What to do instead +> is the subject of "Access methods and partial indexes" below. ## Syntax ```typescript indexes: [ - { fields: ['status', 'created_at'] }, // btree (default) - { fields: ['email'], unique: 'organization' }, // btree + unique per org - { fields: ['hostname'], unique: 'global' }, // btree + unique platform-wide - { fields: ['description'], type: 'fulltext' }, // non-default type - { fields: ['tags'], type: 'gin' }, // non-default type - { fields: ['location'], type: 'gist' }, // non-default type + { fields: ['status', 'created_at'] }, // plain composite + { fields: ['email'], unique: 'organization' }, // unique per org + { fields: ['hostname'], unique: 'global' }, // unique platform-wide + { name: 'idx_acct_status', fields: ['status'] }, // custom name ] ``` @@ -85,7 +94,8 @@ Notes an author has to know: 1. **Join columns** — Non-foreign-key join fields 2. **Frequent aggregations** — GROUP BY columns 3. **Range queries** — Date ranges, numeric ranges -4. **Partial data** — Use partial indexes for subset queries +4. **Subset queries** — a partial index can help, but it is a database-layer + migration, not a declaration (see below) ### ❌ Avoid Indexing @@ -127,82 +137,25 @@ indexes: [ ] ``` -### Partial Index - -```typescript -indexes: [ - // Only index active records - { - fields: ['created_at'], - partial: "status = 'active'", - }, - - // Only index non-deleted records - { - fields: ['email'], - unique: 'organization', - partial: "deleted_at IS NULL", - }, -] -``` - -### Full-Text Index - -```typescript -indexes: [ - { - fields: ['description', 'notes'], - type: 'fulltext', - }, -] -``` - -### GIN Index (JSONB/Array) - -```typescript -indexes: [ - // JSONB field - { - fields: ['metadata'], - type: 'gin', - }, - - // Array field - { - fields: ['tags'], - type: 'gin', - }, -] -``` - -### Geospatial Index (GIST) - -```typescript -indexes: [ - { - fields: ['location'], - type: 'gist', - }, -] -``` - ## Incorrect vs Correct -### ❌ Incorrect — Redundant Default Values +### ❌ Incorrect — Retired and Redundant Keys ```typescript indexes: [ - { fields: ['status'], type: 'btree', unique: false }, // ❌ Redundant defaults - { fields: ['email'], type: 'btree', unique: 'organization' }, // ❌ Redundant type + { fields: ['status'], type: 'btree', unique: false }, // ❌ `type` retired; `unique: false` redundant + { fields: ['description'], type: 'fulltext' }, // ❌ `type` retired (#5248) + { fields: ['created_at'], partial: "status = 'active'" }, // ❌ `partial` retired (#5248) ] ``` -### ✅ Correct — Omit Defaults +### ✅ Correct — Declare Only What the Driver Materializes ```typescript indexes: [ - { fields: ['status'] }, // ✅ btree and unique: false are defaults - { fields: ['email'], unique: 'organization' }, // ✅ btree is default; the scope is required + { fields: ['status'] }, // ✅ unique: false is the default + { fields: ['email'], unique: 'organization' }, // ✅ the scope is required + { fields: ['description', 'notes'] }, // ✅ plain index; see below for full-text ] ``` @@ -281,36 +234,47 @@ Place most **selective** (unique) fields first, then range/sort fields last. { fields: ['created_at', 'status', 'tenant_id'] } ``` -## Partial Indexes +## Access methods and partial indexes -Use partial indexes to index only a subset of rows: +Both are real database capabilities. Neither is part of the **declaration** +surface, and the keys that used to pretend otherwise (`type`, `partial`) were +retired at protocol 18 (#5248, #4943) precisely because nothing consumed them. -```typescript -// Only index active records (common query) -{ - fields: ['created_at'], - partial: "status = 'active'", -} +**Access method (`btree` / `hash` / `gin` / `gist` / `fulltext`).** The driver +and dialect decide. Postgres defaults to B-tree, which is the right choice for +the equality, range and sort patterns this guide is about. The specialised +methods are dialect-specific — `gin`/`gist` are Postgres, `fulltext` is +MySQL-family — so a portable declaration could not name one anyway. When a +workload genuinely needs one, issue it from a database-layer migration against +the dialect you are actually running. -// Only index high-value accounts -{ - fields: ['annual_revenue'], - partial: "annual_revenue > 1000000", -} +**Partial index (`CREATE INDEX … WHERE `).** Supported on Postgres +and SQLite (≥ 3.8.9), absent on MySQL. Because it cannot be expressed +portably — and because knex's index builders have no way to emit a predicate — +it is issued as raw SQL from a runtime migration. The platform does exactly +this for its own overlay uniqueness: `metadata-protocol`'s `ensureOverlayIndex` +runs -// Only index non-deleted records -{ - fields: ['email'], - unique: 'organization', - partial: "deleted_at IS NULL", -} +```sql +CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active + ON sys_metadata (type, name, organization_id, COALESCE(package_id, '')) + WHERE state = 'active'; ``` -**Benefits:** +with a plain-index fallback for dialects that reject the predicate. Follow that +shape: declare the coarse index (or none) in metadata, and build the partial +form in a migration. + +**Benefits of a partial index, when you do build one:** - Smaller index size - Faster writes (fewer rows to maintain) - Faster queries (focused data subset) +> Drift detection understands database-authored partial indexes and leaves them +> alone — it reads partiality back out of the database's own DDL, so a partial +> index you create in a migration is not reported as drift and is never +> targeted by `os migrate apply --allow-destructive`. + ## Performance Trade-offs ### Index Benefits @@ -367,8 +331,8 @@ SHOW INDEX FROM your_table; 1. **Index foreign keys** — Always (automatic in ObjectStack) 2. **Composite for common queries** — Combine frequently filtered columns 3. **Order matters** — Most selective field first -4. **Partial for subsets** — Index only relevant rows -5. **Unique for constraints** — Enforce at DB level +4. **Partial for subsets** — but build it in a migration, not a declaration +5. **Unique for constraints** — Enforce at DB level, and always state the scope 6. **Monitor usage** — Remove unused indexes 7. **Limit total indexes** — Balance read/write performance 8. **Avoid over-indexing** — More indexes ≠ better performance @@ -395,29 +359,18 @@ indexes: [ ] ``` -### Text Search - -```typescript -// Query: WHERE description ILIKE '%keyword%' -indexes: [ - { fields: ['description'], type: 'fulltext' }, -] -``` - -### Array/JSONB Containment - -```typescript -// Query: WHERE tags @> ['urgent'] -indexes: [ - { fields: ['tags'], type: 'gin' }, -] -``` +### Text Search / Containment / Geospatial -### Location-Based Queries +These three want a specialised access method (`fulltext`, `gin`, `gist`), which +is **not** declarable — see "Access methods and partial indexes" above. Declare +the plain index if the column is also filtered or sorted normally, and create +the specialised one from a database-layer migration on the dialect you run. ```typescript -// Query: WHERE ST_DWithin(location, point, distance) +// Declaration: plain, portable, and all the driver can build indexes: [ - { fields: ['location'], type: 'gist' }, + { fields: ['description'] }, // text search: add a fulltext/GIN index in a migration + { fields: ['tags'] }, // containment (tags @> [...]): GIN, in a migration + { fields: ['location'] }, // ST_DWithin(...): GIST, in a migration ] ``` From 62ec212fb3029878e2d105aeb75d02f93979ff56 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 08:40:49 +0000 Subject: [PATCH 2/2] fixup: retarget the IndexSchema retirement to protocol 17 (PM ruling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `toMajor: 18` → `17`; `step18` deleted and `object-index-type-partial-removed` wired into the existing step-17 chain (its rationale extended). Tombstone prescriptions now say "removed in @objectstack/spec 17.0.0" and point at `os migrate meta --from 16`, matching every other toMajor-17 entry. Skill, docs, liveness note and changeset retargeted; generated artifacts (spec-changes.json, upgrade guide, reference docs) regenerated. Changeset stays `major`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D --- .changeset/index-type-partial-removed.md | 4 +- content/docs/data-modeling/objects.mdx | 4 +- content/docs/references/data/object.mdx | 8 +-- docs/protocol-upgrade-guide.md | 3 + .../src/declared-index-retired-keys.test.ts | 2 +- packages/spec/liveness/object.json | 2 +- packages/spec/spec-changes.json | 12 ++++ packages/spec/src/conversions/registry.ts | 10 +-- packages/spec/src/data/object.test.ts | 2 +- packages/spec/src/data/object.zod.ts | 12 ++-- packages/spec/src/migrations/registry.ts | 72 ++++++------------- skills/objectstack-data/SKILL.md | 4 +- skills/objectstack-data/rules/indexing.md | 6 +- 13 files changed, 64 insertions(+), 77 deletions(-) diff --git a/.changeset/index-type-partial-removed.md b/.changeset/index-type-partial-removed.md index df490c081b..54196cbdfa 100644 --- a/.changeset/index-type-partial-removed.md +++ b/.changeset/index-type-partial-removed.md @@ -60,8 +60,8 @@ sync, unchanged. either key is now a `tsc` error and a parse error carrying the prescription. They sit at the bottom of the shape per the #5606 renderer note. - **ADR-0087 D2 conversion + D3 chain step** (`object-index-type-partial-removed`, - `toMajor: 18`, opening the protocol-18 step): strips both keys from `objects[]` - and `objectExtensions[]`; `os migrate meta --from 17` rewrites sources + `toMajor: 17`, wired into the existing step-17 chain): strips both keys from + `objects[]` and `objectExtensions[]`; `os migrate meta --from 16` rewrites sources mechanically. A pure lossless delete — there was no effect to lose. - **Producers flipped:** `sys_metadata` (`idx_sys_metadata_overlay_active`, the case #4943 named) and `sys_view_definition` (`idx_sys_view_def_active`), both diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index 31a42f88f1..232b5eaf59 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -222,12 +222,12 @@ indexes: [ | `name` | `string` | optional | Index name (auto-generated if omitted) | - **`type` and `partial` were retired in protocol 18** (#5248, #4943). Neither + **`type` and `partial` were retired in protocol 17** (#5248, #4943). Neither had a driver consumer: declared indexes are created through knex's `table.index()` / `table.unique()`, so an authored `type` selected no access method and an authored `partial` produced a **full** index with the predicate silently discarded. Writing either now fails `tsc` and the parse with a - migration prescription — run `os migrate meta --from 17` to strip them. + migration prescription — run `os migrate meta --from 16` to strip them. Both capabilities remain available where they are actually implementable: the index method is the driver/dialect's choice, and a partial index is issued as diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 178cc6c957..cc913a50b0 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -67,9 +67,9 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | -| **type** | `Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>` | ✅ | Index algorithm type | | **unique** | `boolean \| 'global' \| 'organization'` | ✅ | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18, #5082) — state the scope. 'tenant'/'org' are rejected — the word is 'organization' | -| **partial** | `string` | optional | Partial index condition (SQL WHERE clause for conditional indexes) | +| **type** | `any` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (#5248, ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to rewrite it automatically. | +| **partial** | `any` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (#5248, #4943, ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to rewrite it automatically. | --- @@ -122,7 +122,7 @@ const result = ApiMethod.parse(data); | **datasource** | `string` | optional | Target Datasource ID. "default" is the primary DB. | | **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record; … }` | optional | Remote table binding for federated (external) objects. | | **fields** | `Record; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. | -| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global' \| 'organization'; … }[]` | optional | Database performance indexes | +| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization'; type?: any; … }[]` | optional | Database performance indexes | | **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. | | **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications | | **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). | @@ -196,7 +196,7 @@ const result = ApiMethod.parse(data); | **pluralLabel** | `string` | optional | Override plural label for the extended object | | **description** | `string` | optional | Override description for the extended object | | **validations** | `any[]` | optional | Additional validation rules to merge into the target object | -| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global' \| 'organization'; … }[]` | optional | Additional indexes to merge into the target object | +| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization'; type?: any; … }[]` | optional | Additional indexes to merge into the target object | | **priority** | `integer` | optional | Merge priority (higher = applied later) | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 29bef973dd..4bf2939c0e 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -208,6 +208,8 @@ The same is true of the protocol-17 retirement that closes this list, and the pa The last enforce-or-remove entry of this step is on the RUNTIME context rather than on anything authorable: `HookContext.session.roles` (#5050). It was declared in `data/hook.zod.ts`, read by exactly two consumers — the approvals record lock and the delegation write guard, each opening with `session.roles?.includes('admin')` — and produced by nobody on the hook path: ObjectQL's `buildSession()` writes the session field by field (`userId`, `organizationId`, `accessToken`, `isSystem`, `actor`, the skip flags) and has no `roles` write, here or in `cloud`, whose hook consumers read `hookContext?.session?.userId` and nothing else (an ACTION body's `ctx.session` is a different untyped object that does carry one, tracked apart). So both branches were dead on every real engine path: an authorization decision in shape only, and — worse for a reader — a SECOND admin dialect competing with the one ADR-0095 D3 sanctions. #4839 (PR #5049) deleted the two readers on the maintainer's ruling; this step removes the declaration that outlived them, which is what ADR-0049 asks for once a key has neither end. Nothing observable changes: a key nobody wrote and nothing read cannot alter a single decision. It is tombstoned rather than deleted because `HookContextSchema` is deliberately NOT `.strict()` (strictness there would make an engine-internal enrichment a breaking change for anyone parsing a context they were handed, as `provenance` was in #3712), so a plain delete would strip the key in silence — the #3733 / ADR-0104 failure this whole pass exists to end. There is NO conversion and no source rewrite: a HookContext is built per operation by the engine and never stored, so no `sys_metadata` row, example or template can carry the key — the `openApi31` / `activationEvents` shape, one semantic TODO for hook authors. The live vocabulary is untouched and deliberately elsewhere: gate on `session.userId` / `session.isSystem` in the hook, and judge PRIVILEGE through the security service, which reads capability grants (`permissions`), placements (`positions`) and the derived posture off the execution context. +Finally it retires the two inert `IndexSchema` keys, `indexes[].type` and `indexes[].partial` (#5248, #4943). Neither ever had a DDL consumer: `SqlDriver.syncDeclaredIndexes` creates declared indexes through knex's `table.index()` / `table.unique()`, and the drift differ's `DeclaredIndexInput` carries only `name`/`fields`/`unique`/`nullSafeColumns` — so an authored `type` selected no access method and an authored `partial` produced a FULL index with its predicate discarded. `partial` was the more damaging of the two because it read as a correctness control: the platform's own `sys_metadata` declared it for overlay uniqueness, and what the declaration alone materialized was an unrestricted unique index (the active-row scoping is delivered by a runtime migration, `metadata-protocol`'s `ensureOverlayIndex`, not by the key). `type` was the louder: its `.default('btree')` put an inert knob into every parse output, so it read as live configuration — the ADR-0078 no-silently-inert shape. Remove was chosen over enforce (maintainer ruling, 2026-08-06): enforcing needs per-dialect algorithm mapping (`gin`/`gist` Postgres-only, `fulltext` MySQL-family), raw-SQL `CREATE INDEX … WHERE` on the dialects that have partial indexes at all (MySQL does not), and a redesign of how `isSyncReproducibleIndex` excludes partial indexes from incremental sync — design cost for a capability nothing has asked for. Both are lossless deletes: no DDL changes, because no DDL ever depended on them. Drift detection is untouched — the `partial` flag it consumes is parsed back out of the database's OWN `CREATE INDEX` DDL and never came from this key. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -255,6 +257,7 @@ The last enforce-or-remove entry of this step is on the RUNTIME context rather t | `connector-rate-limit-config-removed` | `connector.rateLimitConfig` | connector key 'rateLimitConfig' removed (#4911 — no outbound rate-limiting engine exists; the runtime's only token bucket limits INBOUND requests, so every knob here was inert while reading like a configured cap. The whole ConnectorRateLimitConfig shape went with it) | retired — `migrate meta` only | | `theme-inert-token-scales-removed` | `theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight / theme.typography.letterSpacing / theme.typography.fontFamily.heading / theme.typography.fontFamily.mono / theme.animation / theme.zIndex` | theme keys 'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing', 'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed (#5021, ADR-0049 — the engine emitted --font-size-*, --font-weight-*, --line-height-*, --letter-spacing-*, --duration-*, --timing-*, --z-*, --font-heading and --font-mono faithfully, and no first-party component or stylesheet has ever read one. Re-declare any variable you actually consume under customVars, which emits it verbatim) | retired — `migrate meta` only | | `page-header-subtitle-alias` | `page.component.page-header.description` | page-header component prop 'description' → 'subtitle' (objectui#3226 — the `subtitle ?? description` fallback retires) | live — protocol 17 loader accepts the old shape | +| `object-index-type-partial-removed` | `object.indexes[].type / object.indexes[].partial` | object index keys 'indexes[].type'/'indexes[].partial' removed (#5248, #4943 — no driver ever read either: the index method is the dialect's choice and a partial index is built by a database-layer migration, not declared) | retired — `migrate meta` only | ### Semantic (delegated to you, with acceptance criteria) diff --git a/packages/drivers/driver-sql/src/declared-index-retired-keys.test.ts b/packages/drivers/driver-sql/src/declared-index-retired-keys.test.ts index 081aeb8370..a704d17ff0 100644 --- a/packages/drivers/driver-sql/src/declared-index-retired-keys.test.ts +++ b/packages/drivers/driver-sql/src/declared-index-retired-keys.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { SqlDriver, normalizeDeclaredIndex } from '../src/index.js'; /** - * `indexes[].type` / `indexes[].partial` were retired at protocol 18 (#5248, + * `indexes[].type` / `indexes[].partial` were retired at protocol 17 (#5248, * #4943, ADR-0049 enforce-or-remove). The retirement rests on ONE factual * claim, and this file is that claim's proof rather than its restatement: * diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index c77b6288ce..0d7b6b7e8d 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -79,7 +79,7 @@ "status": "live", "evidence": "packages/drivers/driver-sql/src/sql-driver.ts:1181", "verifiedAt": "2026-08-06", - "note": "DDL — but only through `name`/`fields`/`unique`. `syncDeclaredIndexes` creates every declared index via knex's `table.index()` / `table.unique()`, and the differ's `DeclaredIndexInput` carries `name`/`fields`/`unique`/`nullSafeColumns`. The other two child keys were RETIRED 2026-08-06 (#5248, #4943, ADR-0049): `indexes[].type` selected no access method (and its `.default('btree')` materialized an inert knob into every parse output) and `indexes[].partial` produced a FULL index with the predicate discarded. Both are `retiredKey()` tombstones at `IndexSchema` and are stripped from sources by the protocol-18 conversion `object-index-type-partial-removed`. The container stays `live` because the surviving keys drive real DDL. ⚠ Not to be confused with driver-sql's own `partial: boolean`, which is parsed back out of the database's own CREATE INDEX DDL for drift detection and is unrelated to the declaration surface." + "note": "DDL — but only through `name`/`fields`/`unique`. `syncDeclaredIndexes` creates every declared index via knex's `table.index()` / `table.unique()`, and the differ's `DeclaredIndexInput` carries `name`/`fields`/`unique`/`nullSafeColumns`. The other two child keys were RETIRED 2026-08-06 (#5248, #4943, ADR-0049): `indexes[].type` selected no access method (and its `.default('btree')` materialized an inert knob into every parse output) and `indexes[].partial` produced a FULL index with the predicate discarded. Both are `retiredKey()` tombstones at `IndexSchema` and are stripped from sources by the protocol-17 conversion `object-index-type-partial-removed`. The container stays `live` because the surviving keys drive real DDL. ⚠ Not to be confused with driver-sql's own `partial: boolean`, which is parsed back out of the database's own CREATE INDEX DDL for drift detection and is unrelated to the declaration surface." }, "validations": { "status": "live", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index d147a8658a..c15f6c5e6e 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -325,6 +325,12 @@ "to": "page-header component prop 'description' → 'subtitle' (objectui#3226 — the `subtitle ?? description` fallback retires)", "conversionId": "page-header-subtitle-alias", "toMajor": 17 + }, + { + "surface": "object.indexes[].type / object.indexes[].partial", + "to": "object index keys 'indexes[].type'/'indexes[].partial' removed (#5248, #4943 — no driver ever read either: the index method is the dialect's choice and a partial index is built by a database-layer migration, not declared)", + "conversionId": "object-index-type-partial-removed", + "toMajor": 17 } ], "migrated": [ @@ -1033,6 +1039,12 @@ "to": "page-header component prop 'description' → 'subtitle' (objectui#3226 — the `subtitle ?? description` fallback retires)", "conversionId": "page-header-subtitle-alias", "toMajor": 17 + }, + { + "surface": "object.indexes[].type / object.indexes[].partial", + "to": "object index keys 'indexes[].type'/'indexes[].partial' removed (#5248, #4943 — no driver ever read either: the index method is the dialect's choice and a partial index is built by a database-layer migration, not declared)", + "conversionId": "object-index-type-partial-removed", + "toMajor": 17 } ], "migrated": [ diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 29e8fd2015..4ab6cc9303 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -3584,7 +3584,7 @@ const objectEnableTrashMruRemoved: MetadataConversion = { }; /** - * `indexes[].type` / `indexes[].partial` leave the surface (protocol 18, + * `indexes[].type` / `indexes[].partial` leave the surface (protocol 17, * #5248 + #4943, ADR-0049 enforce-or-remove). * * Neither key had a single DDL consumer. `SqlDriver.syncDeclaredIndexes` builds @@ -3607,14 +3607,14 @@ const objectEnableTrashMruRemoved: MetadataConversion = { * the exemption it grants DB-authored partial indexes still stands. * * `retiredFromLoadPath`: both keys are tombstoned at `IndexSchema`, so a live - * author is taught by the parse error. This entry exists so stored ≤17 rows + * author is taught by the parse error. This entry exists so stored ≤16 rows * replay clean through `applyConversionsToStoredItem` (without it a * pre-removal row would flag `metadata_spec_invalid` forever) and so - * `os migrate meta --from 17` rewrites sources mechanically. + * `os migrate meta --from 16` rewrites sources mechanically. */ const objectIndexTypePartialRemoved: MetadataConversion = { id: 'object-index-type-partial-removed', - toMajor: 18, + toMajor: 17, retiredFromLoadPath: true, surface: 'object.indexes[].type / object.indexes[].partial', summary: @@ -4432,8 +4432,8 @@ export const CONVERSIONS_BY_MAJOR: Readonly { it('points at the CLI conversion rather than naming the conversion id', () => { for (const bad of [{ fields: ['a'], type: 'btree' }, { fields: ['a'], partial: 'x' }]) { - expect(() => IndexSchema.parse(bad)).toThrow(/os migrate meta --from 17/); + expect(() => IndexSchema.parse(bad)).toThrow(/os migrate meta --from 16/); } }); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 3818262982..7ac54be5b6 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -279,7 +279,7 @@ export const ObjectCapabilities = z.object({ * unique: true * } * - * ## `type` / `partial` were RETIRED at protocol 18 (#5248, #4943, ADR-0049) + * ## `type` / `partial` were RETIRED at protocol 17 (#5248, #4943, ADR-0049) * * Both were authorable and had **zero** DDL consumers. * `SqlDriver.syncDeclaredIndexes` builds every declared index through knex's @@ -370,26 +370,26 @@ export const IndexSchema = lazySchema(() => z.object({ // makes the removal audible in the two channels an upgrading author // actually reads: `tsc` (input type `never`) and the parse itself. // `object-index-type-partial-removed` strips both from stored/authored - // sources on the protocol-18 migration. + // sources on the protocol-17 migration. type: retiredKey( - '`indexes[].type` was removed in @objectstack/spec 18.0.0 (#5248, ADR-0049) — no driver ever ' + + '`indexes[].type` was removed in @objectstack/spec 17.0.0 (#5248, ADR-0049) — no driver ever ' + 'read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex\'s ' + '`table.index()` / `table.unique()`, which cannot express an access method, so the value ' + 'changed no DDL; its `.default(\'btree\')` merely made an inert knob show up in every parse ' + 'output. Delete the key. The index method is the driver/dialect\'s decision (Postgres ' + 'defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a ' + 'database-layer migration when a workload actually needs one). ' + - 'Run `os migrate meta --from 17` to rewrite it automatically.', + 'Run `os migrate meta --from 16` to rewrite it automatically.', ), partial: retiredKey( - '`indexes[].partial` was removed in @objectstack/spec 18.0.0 (#5248, #4943, ADR-0049) — no ' + + '`indexes[].partial` was removed in @objectstack/spec 17.0.0 (#5248, #4943, ADR-0049) — no ' + 'driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a ' + 'FULL index and the predicate silently did nothing. Delete the key. Partial indexes are ' + 'built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … ' + 'WHERE ` from a runtime migration (this is what `metadata-protocol`\'s ' + '`ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it ' + 'reads partiality back from the database\'s own DDL, never from this key. ' + - 'Run `os migrate meta --from 17` to rewrite it automatically.', + 'Run `os migrate meta --from 16` to rewrite it automatically.', ), })); diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a6c575f05a..fd44d56ce6 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -991,7 +991,27 @@ const step17: MigrationStep = { + 'live vocabulary is untouched and deliberately elsewhere: gate on `session.userId` / ' + '`session.isSystem` in the hook, and judge PRIVILEGE through the security service, which ' + 'reads capability grants (`permissions`), placements (`positions`) and the derived posture ' - + 'off the execution context.', + + 'off the execution context.\n\n' + + 'Finally it retires the two inert `IndexSchema` keys, `indexes[].type` and ' + + '`indexes[].partial` (#5248, #4943). Neither ever had a DDL consumer: ' + + '`SqlDriver.syncDeclaredIndexes` creates declared indexes through knex\'s `table.index()` / ' + + '`table.unique()`, and the drift differ\'s `DeclaredIndexInput` carries only ' + + '`name`/`fields`/`unique`/`nullSafeColumns` — so an authored `type` selected no access ' + + 'method and an authored `partial` produced a FULL index with its predicate discarded. ' + + '`partial` was the more damaging of the two because it read as a correctness control: the ' + + 'platform\'s own `sys_metadata` declared it for overlay uniqueness, and what the ' + + 'declaration alone materialized was an unrestricted unique index (the active-row scoping ' + + 'is delivered by a runtime migration, `metadata-protocol`\'s `ensureOverlayIndex`, not by ' + + 'the key). `type` was the louder: its `.default(\'btree\')` put an inert knob into every ' + + 'parse output, so it read as live configuration — the ADR-0078 no-silently-inert shape. ' + + 'Remove was chosen over enforce (maintainer ruling, 2026-08-06): enforcing needs ' + + 'per-dialect algorithm mapping (`gin`/`gist` Postgres-only, `fulltext` MySQL-family), ' + + 'raw-SQL `CREATE INDEX … WHERE` on the dialects that have partial indexes at all (MySQL ' + + 'does not), and a redesign of how `isSyncReproducibleIndex` excludes partial indexes from ' + + 'incremental sync — design cost for a capability nothing has asked for. Both are lossless ' + + 'deletes: no DDL changes, because no DDL ever depended on them. Drift detection is ' + + 'untouched — the `partial` flag it consumes is parsed back out of the database\'s OWN ' + + '`CREATE INDEX` DDL and never came from this key.', conversionIds: [ 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', @@ -1036,6 +1056,7 @@ const step17: MigrationStep = { 'dashboard-widget-compareto-converged', 'theme-inert-token-scales-removed', 'page-header-subtitle-alias', + 'object-index-type-partial-removed', ], semantic: [ { @@ -1884,54 +1905,6 @@ const step17: MigrationStep = { ], }; -/** - * Protocol 18 step. - * - * Opens the 18 lane with the `IndexSchema` retirement (#5248, #4943). Purely - * mechanical: two authorable keys with zero DDL consumers are deleted, so - * there is no semantic residue and nothing an author has to decide. - * - * ⚠️ This step targets a major the running package has not reached yet - * (`PROTOCOL_MAJOR` is read from `packages/spec/package.json`, today 17.x). - * `composeMigrationChain` filters `m <= toMajor` and `toMajor` defaults to - * `PROTOCOL_MAJOR`, so the step is *registered but dormant*: `os migrate meta - * --from 17` composes no hop for it until the package is 18.0.0, at which - * point it activates with no further edit. That is deliberate — the retirement - * was ruled out of the v17 rc window (#5248), and registering it now is what - * keeps `spec-changes.json`, the upgrade guide and the `spec_changes` MCP tool - * carrying the break from the moment it lands in the schema. - */ -const step18: MigrationStep = { - toMajor: 18, - rationale: - 'Protocol 18 removes the two inert `IndexSchema` keys (#5248, #4943, ADR-0049 ' - + 'enforce-or-remove): `indexes[].type` and `indexes[].partial`. Neither ever had a DDL ' - + 'consumer — `SqlDriver.syncDeclaredIndexes` creates declared indexes through knex\'s ' - + '`table.index()` / `table.unique()`, and the drift differ\'s `DeclaredIndexInput` carries ' - + 'only `name`/`fields`/`unique`/`nullSafeColumns` — so an authored `type` selected no ' - + 'access method and an authored `partial` produced a FULL index with its predicate ' - + 'discarded. `partial` was the more damaging of the two because it read as a correctness ' - + 'control: the platform\'s own `sys_metadata` declared `partial: "state = \'active\'"` for ' - + 'overlay uniqueness, and what the declaration alone materialized was an unrestricted ' - + 'unique index (the active-row scoping is delivered by a runtime migration, ' - + '`metadata-protocol`\'s `ensureOverlayIndex`, not by the key). `type` was the louder: its ' - + '`.default(\'btree\')` put an inert knob into every parse output, so it read as live ' - + 'configuration — the ADR-0078 no-silently-inert shape.\n\n' - + 'Remove was chosen over enforce (maintainer ruling, 2026-08-06): enforcing would require ' - + 'per-dialect algorithm mapping (`gin`/`gist` Postgres-only, `fulltext` MySQL-only), ' - + 'raw-SQL `CREATE INDEX … WHERE` on the dialects that have partial indexes at all (MySQL ' - + 'does not), and a redesign of how `isSyncReproducibleIndex` excludes partial indexes from ' - + 'incremental sync — real design cost for a capability nothing has asked for. If a genuine ' - + 'need appears it returns enforce-first.\n\n' - + 'Both are lossless deletes: no DDL changes, because no DDL ever depended on them. The ' - + 'replacements are the mechanisms that were already doing the work — the index method is ' - + 'the driver/dialect\'s decision, and a partial index is issued by a database-layer ' - + 'migration. Drift detection is untouched: the `partial` flag it consumes is parsed back ' - + 'out of the database\'s OWN `CREATE INDEX` DDL and never came from this key.', - conversionIds: ['object-index-type-partial-removed'], - semantic: [], -}; - /** All migration steps, keyed by the major they migrate into. */ export const MIGRATIONS_BY_MAJOR: Readonly> = { 11: step11, @@ -1941,7 +1914,6 @@ export const MIGRATIONS_BY_MAJOR: Readonly> = { 15: step15, 16: step16, 17: step17, - 18: step18, }; /** The majors that have a step, ascending. */ diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index be8e461e54..c8ef3bf05b 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -391,10 +391,10 @@ indexes: [ ] ``` -> **`type` and `partial` were retired at protocol 18** (#5248, #4943): no driver +> **`type` and `partial` were retired at protocol 17** (#5248, #4943): no driver > ever read either, so an authored `type` chose no access method and an authored > `partial` produced a full index with the predicate discarded. Both are now a -> `tsc` error and a parse error; `os migrate meta --from 17` strips them. Access +> `tsc` error and a parse error; `os migrate meta --from 16` strips them. Access > methods and partial predicates are database-layer migrations. > **A unique index must state its scope** — `'organization'` (one holder per diff --git a/skills/objectstack-data/rules/indexing.md b/skills/objectstack-data/rules/indexing.md index 998597261f..2937d8afee 100644 --- a/skills/objectstack-data/rules/indexing.md +++ b/skills/objectstack-data/rules/indexing.md @@ -24,12 +24,12 @@ and it is the whole surface *because* it is all the driver materializes: | `unique` | optional | Uniqueness **and its scope** — see ADR-0120 section below | | `name` | optional | Custom index name; auto-generated when omitted | -> **Retired at protocol 18 (#5248, #4943): `type` and `partial`.** Both were +> **Retired at protocol 17 (#5248, #4943): `type` and `partial`.** Both were > authorable and neither was ever read by any driver — an authored `type` > selected no access method, and an authored `partial` produced a **full** > index with the predicate silently discarded. Writing either is now a `tsc` > error and a parse error carrying the migration prescription; run -> `os migrate meta --from 17` to strip them automatically. What to do instead +> `os migrate meta --from 16` to strip them automatically. What to do instead > is the subject of "Access methods and partial indexes" below. ## Syntax @@ -238,7 +238,7 @@ Place most **selective** (unique) fields first, then range/sort fields last. Both are real database capabilities. Neither is part of the **declaration** surface, and the keys that used to pretend otherwise (`type`, `partial`) were -retired at protocol 18 (#5248, #4943) precisely because nothing consumed them. +retired at protocol 17 (#5248, #4943) precisely because nothing consumed them. **Access method (`btree` / `hash` / `gin` / `gist` / `fulltext`).** The driver and dialect decide. Postgres defaults to B-tree, which is the right choice for