Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/19332-g2a-fieldgroups-indexes-form-rows.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
"@objectstack/spec": minor
"@objectstack/platform-objects": patch
Expand All @@ -10,7 +10,7 @@
- `fieldGroups` (Basics, beside `highlightFields`) — six sub-rows, one per canonical group key: `key` and `label` (required text), `icon` (text), `description` (textarea), `collapse` (a `none` / `expanded` / `collapsed` select) and `visibleWhen` (`type: 'code'`, `language: 'expression'`, the `fields` grid's predicate rows). The three `[DEPRECATED → collapse]` aliases (`defaultExpanded`, `collapsible`, `collapsed`) are **not** offered; the metadata-form reconciliation ledger records a nested `omit` row for each. The parse still accepts them and derives `collapse` from one only when `collapse` is absent, so a stored entry keeps its meaning, and a `collapse` set in the form outranks any alias it carries.
- `indexes` (Advanced, beside `datasource`) — three sub-rows over the keys the SQL driver reads: `name` (text), `fields` (`widget: 'string-tags'`, required) and `unique`, a select offering **only** `global` and `organization`. The deprecated bare `unique: true` is never offered: a schema-derived control would take the union's first arm and render a switch that writes it. An edit merges into the stored entry, so an index that already carries `true` or `false` keeps it until the author picks a scope, and the select can write only the two values the parse accepts. `type` and `partial` are tombstones and have no row.

The help text states what the runtime does with each value. `indexes[].fields` is free text, and no authoring door judges its names: not the schema parse, not the publish door, not `os validate`. A name that is not a stored column makes the SQL driver skip the whole index at sync with a warning in the server log, and the help text says exactly that. A field group has no field-name list: a field joins a group through its own `group` key.
The help text states what the runtime does with each value. `indexes[].fields` is free text, and the schema parse, so a draft save, does not judge its names; `os validate`, `os build`, `os lint` and the publish door refuse a name that is not a field of the object (`object-field-ref-unknown`, #20479, in the same release). A name that is not a stored column, a `formula` field say, makes the SQL driver skip the whole index at sync with an error in the server log, and the help text says exactly that; `os migrate plan` reports the skipped index too (#20432, in the same release). A field group has no field-name list: a field joins a group through its own `group` key.

The two row schemas also carry a JSON Schema `title` on every property, as every repeater row schema must: `IndexSchema` on `name`, `fields` and `unique`, and `ObjectFieldGroupSchema` on its nine keys, the three deprecated aliases included. A property panel that reads the served schema's titles therefore shows a named column instead of a raw key. Each title is a `.meta({ title })` call and nothing more.

Expand Down
63 changes: 63 additions & 0 deletions .changeset/20432-skipped-index-durability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
'@objectstack/driver-sql': minor
'@objectstack/spec': patch
'@objectstack/platform-objects': patch
'@objectstack/lint': patch
---

fix(driver-sql): a declared index that can never be built is logged at `error` and reported in drift

**Clause-②: yes (widening)**: the exported `DriftOp` union gains one member, `unbuildable_index`.
No accept set changes. Nothing an author could write before is refused now.

A declared index names a column that no declaration will ever create when:

- the name is not a field of the object, for example a misspelling that the Studio save door
admits (`os validate` / `os build` already refuse it); or
- the name is a virtual `formula` field, which is computed on read and has no column. The same
applies to a field-level `unique` on a formula field.

The SQL driver skips such an index at every sync. It used to say so at `warn`, and the drift
report dropped the index from the expected set, so `os migrate plan` showed nothing. For a
`unique` index, the declared constraint was not enforced and duplicate rows were accepted,
while everything looked normal.

- **The sync logs the skip at `error`**, on the same durability channel as the duplicate-row
refusals in the same loop. One line per skipped index per sync names the object, the index,
each missing column with its reason (not a field of the object, or a formula field), and
whether the index is `UNIQUE`. The structured meta carries `index`, `missing` and `unique`.
- **Drift reports it** as a report-only entry: `kind: 'index_mismatch'`, `actual: '(absent)'`,
`category: 'needs_confirm'`, `severity: 'error'` for a unique index and `'warning'` otherwise.
Its op is the new member:

```ts
{ type: 'unbuildable_index'; table: string; column?: string; indexName: string;
unique: boolean; missingColumns: string[] }
```

`missingColumns` lists only the columns that will never materialize. A declared column that
is merely not added yet is pending additive work, not this finding.

**What a consumer that reads `op.type` now sees.** A new value, `'unbuildable_index'`. It has
no reconciler arm, and none can exist, because there is no column to build over. The remedy is
a metadata edit. It is in `INDEX_DRIFT_OPS`, so `isIndexDriftOp` answers `true` and it never
triggers a SQLite table rebuild. `applyMigrationEntries` reports it `skipped` on every dialect.
`os migrate plan` lists it under "Needs confirmation", addressed by its index name. `os migrate
apply` counts it like any `needs_confirm` entry (so it asks for `--yes`), and then reports it
skipped. The artifact-pinned boot warns about it and still starts, because
only `destructive` entries refuse a boot. A `switch` over `op.type` that treats unknown values
as "not applied" needs no change. An exhaustive `switch` with a `never` check gets one more case
to handle.

**The object form's help text follows.** The `indexes` → Fields help in the Studio object form
said the skip left "a warning in the server log". It now says an error, in English and in the
zh-CN, ja-JP and es-ES translations. Nothing else in the text changes.

**The lint message follows too.** `object-field-ref-unknown`, on a misspelt `indexes[].fields`
name, said the SQL driver skips the index "with only a warning, and drift drops it too". It now
says the skip is logged at error and `os migrate plan` reports the index as unbuildable. The rule,
its severity and its prescription are unchanged.

**Upgrade note:** on a database that already carries such an index, `os migrate plan` now
reports one entry per index, and so does the boot's drift warning. That entry clears only when
the metadata names stored fields or drops the index.
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#20432] What the CLI does with the driver's new report-only drift op,
* `unbuildable_index`: a declared index that can never be built, because a key
* column is not a field of the object or is a virtual `formula` field.
*
* ## Why this lives in `packages/cli`
*
* The driver owns the entry. The CLI owns the three places it lands:
*
* - `os migrate plan` renders it through `renderPlan` / `driftTarget`, which
* read `category`, `op.indexName`, `op.type` and `message` generically.
* This file proves that the new op needs no CLI source change to be shown.
* - `os migrate apply` hands it to `applyMigrationEntries`, which reports it
* `skipped`: there is no reconciler arm, and none can exist.
* - The artifact-pinned boot gate applies every non-destructive entry and
* refuses the boot only on `category === 'destructive'`. A report-only
* entry must WARN and let the boot continue. Refusing would take down
* every deployment carrying a misspelt index column at `kernel:ready`,
* over a declaration that no DDL can repair. That is the same asymmetry
* `artifact-boot-migration.report-only-drift.test.ts` pins for
* `manual_column_type_change`.
*
* Every entry here comes from the REAL driver (an in-memory SQLite
* `SqlDriver`) and goes through the REAL gate and renderer. A hand-stamped
* entry would stay green on the day the driver starts emitting the op as
* `destructive`, because nothing would connect the two.
*
* ⚠️ `@objectstack/driver-sql` resolves through its `exports` to its **dist**
* (no `resolve.alias` for it in this package, deliberately), so this file
* reads the BUILT driver. A stale `dist/` makes it a verdict about build
* state. The value import also puts it in the `integration` tier
* (`vitest-tiers.ts`, KERNEL).
*/

import { describe, it, expect, afterEach, vi } from 'vitest';
import { SqlDriver, buildIndexName } from '@objectstack/driver-sql';
import { runArtifactBootMigrationGate } from './artifact-boot-migration.js';
import { driftTarget, groupByCategory, renderPlan, type SqlDriverLike } from './schema-migrate.js';

const T = 'os20432_boot';
const INDEX = buildIndexName(T, ['statsu'], true);

/** A table whose only real column is `status`, declaring a UNIQUE over the misspelling `statsu`. */
const OBJECT = {
name: T,
tenancy: { enabled: false },
fields: { status: { type: 'text', maxLength: 64 } },
indexes: [{ fields: ['statsu'], unique: true as const }],
};

async function syncedDriver(): Promise<SqlDriver> {
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
// Quiet: the driver's own error line is pinned in driver-sql. This file is about the CLI's handling.
(driver as any).logger = { debug() {}, info() {}, warn() {}, error() {} };
await driver.initObjects([OBJECT]);
return driver;
}

describe('the unbuildable_index drift op through the CLI (#20432)', () => {
let driver: SqlDriver | undefined;
afterEach(async () => {
vi.restoreAllMocks();
await driver?.disconnect().catch(() => {});
driver = undefined;
});

it('the fixture is real: the driver reports exactly one unbuildable_index entry, needs_confirm', async () => {
driver = await syncedDriver();
const drift = await driver.detectManagedDrift();
expect(drift.map((d) => d.op.type)).toEqual(['unbuildable_index']);
expect(drift[0]).toMatchObject({ category: 'needs_confirm', op: { indexName: INDEX, unique: true } });
});

it('the artifact boot gate warns about it and does NOT refuse the boot', async () => {
driver = await syncedDriver();
const info: string[] = [];
const warn: string[] = [];

// The gate's two members, delegated to the REAL driver. `bootSchemaStack`
// reaches the driver by duck type, and `SqlDriver` keeps `config`
// protected, so the class itself is not assignable to `SqlDriverLike`.
const real = driver;
const gateDriver: SqlDriverLike = {
detectManagedDrift: () => real.detectManagedDrift(),
applyMigrationEntries: (entries, opts) => real.applyMigrationEntries(entries, opts),
};
const verdict = await runArtifactBootMigrationGate({
driver: gateDriver,
artifactDisplay: 'https://artifacts.example.com/app.json',
info: (m) => info.push(m),
warn: (m) => warn.push(m),
});

expect(verdict.ok).toBe(true);
expect(verdict.refusal).toBeUndefined();
expect(verdict.destructive).toEqual([]);
// Handed to the driver, which declined it: skipped, never "migrated".
expect(verdict.applied).toEqual([]);
expect(verdict.skipped.map((d) => d.op.type)).toEqual(['unbuildable_index']);
expect(info).toEqual([]);
// …and the skip is not silent: one warn, carrying the driver's message.
expect(warn).toHaveLength(1);
expect(warn[0]).toContain(T);
expect(warn[0]).toContain(INDEX);
});

it('os migrate plan renders it with no CLI change: grouped needs_confirm, targeted by index name, tagged by op', async () => {
driver = await syncedDriver();
const drift = await driver.detectManagedDrift();

expect(groupByCategory(drift).needs_confirm).toEqual(drift);
expect(driftTarget(drift[0]!)).toBe(`${T} [${INDEX}]`);

const lines: string[] = [];
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
lines.push(args.map(String).join(' '));
});
renderPlan(drift);
const out = lines.join('\n');
expect(out).toContain(`${T} [${INDEX}]`);
expect(out).toContain('[unbuildable_index]');
expect(out).toContain(drift[0]!.message);
});
});
Loading
Loading