Skip to content

Commit 8ad4a9d

Browse files
committed
Merge origin/main into claude/issue-20489-rows-path-compensated-sum
Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
2 parents db9d272 + 03b19d9 commit 8ad4a9d

68 files changed

Lines changed: 2336 additions & 1575 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/19332-g2a-fieldgroups-indexes-form-rows.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Two live structured object keys are authorable in the metadata form: `fieldGroup
1010
- `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.
1111
- `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.
1212

13-
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.
13+
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.
1414

1515
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.
1616

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
'@objectstack/spec': patch
4+
'@objectstack/platform-objects': patch
5+
'@objectstack/lint': patch
6+
---
7+
8+
fix(driver-sql): a declared index that can never be built is logged at `error` and reported in drift
9+
10+
**Clause-②: yes (widening)**: the exported `DriftOp` union gains one member, `unbuildable_index`.
11+
No accept set changes. Nothing an author could write before is refused now.
12+
13+
A declared index names a column that no declaration will ever create when:
14+
15+
- the name is not a field of the object, for example a misspelling that the Studio save door
16+
admits (`os validate` / `os build` already refuse it); or
17+
- the name is a virtual `formula` field, which is computed on read and has no column. The same
18+
applies to a field-level `unique` on a formula field.
19+
20+
The SQL driver skips such an index at every sync. It used to say so at `warn`, and the drift
21+
report dropped the index from the expected set, so `os migrate plan` showed nothing. For a
22+
`unique` index, the declared constraint was not enforced and duplicate rows were accepted,
23+
while everything looked normal.
24+
25+
- **The sync logs the skip at `error`**, on the same durability channel as the duplicate-row
26+
refusals in the same loop. One line per skipped index per sync names the object, the index,
27+
each missing column with its reason (not a field of the object, or a formula field), and
28+
whether the index is `UNIQUE`. The structured meta carries `index`, `missing` and `unique`.
29+
- **Drift reports it** as a report-only entry: `kind: 'index_mismatch'`, `actual: '(absent)'`,
30+
`category: 'needs_confirm'`, `severity: 'error'` for a unique index and `'warning'` otherwise.
31+
Its op is the new member:
32+
33+
```ts
34+
{ type: 'unbuildable_index'; table: string; column?: string; indexName: string;
35+
unique: boolean; missingColumns: string[] }
36+
```
37+
38+
`missingColumns` lists only the columns that will never materialize. A declared column that
39+
is merely not added yet is pending additive work, not this finding.
40+
41+
**What a consumer that reads `op.type` now sees.** A new value, `'unbuildable_index'`. It has
42+
no reconciler arm, and none can exist, because there is no column to build over. The remedy is
43+
a metadata edit. It is in `INDEX_DRIFT_OPS`, so `isIndexDriftOp` answers `true` and it never
44+
triggers a SQLite table rebuild. `applyMigrationEntries` reports it `skipped` on every dialect.
45+
`os migrate plan` lists it under "Needs confirmation", addressed by its index name. `os migrate
46+
apply` counts it like any `needs_confirm` entry (so it asks for `--yes`), and then reports it
47+
skipped. The artifact-pinned boot warns about it and still starts, because
48+
only `destructive` entries refuse a boot. A `switch` over `op.type` that treats unknown values
49+
as "not applied" needs no change. An exhaustive `switch` with a `never` check gets one more case
50+
to handle.
51+
52+
**The object form's help text follows.** The `indexes` → Fields help in the Studio object form
53+
said the skip left "a warning in the server log". It now says an error, in English and in the
54+
zh-CN, ja-JP and es-ES translations. Nothing else in the text changes.
55+
56+
**The lint message follows too.** `object-field-ref-unknown`, on a misspelt `indexes[].fields`
57+
name, said the SQL driver skips the index "with only a warning, and drift drops it too". It now
58+
says the skip is logged at error and `os migrate plan` reports the index as unbuildable. The rule,
59+
its severity and its prescription are unchanged.
60+
61+
**Upgrade note:** on a database that already carries such an index, `os migrate plan` now
62+
reports one entry per index, and so does the boot's drift warning. That entry clears only when
63+
the metadata names stored fields or drops the index.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix(platform-objects): the ja-JP and es-ES platform-object bundles no longer ship English labels, options and help as copies of the source (#20493)
6+
7+
Clause-②: no
8+
9+
A ja-JP or es-ES console showed English on Setup surfaces: the Invite user
10+
dialog listed the membership roles with **Delegated Admin** among the
11+
translated ones. The keys were present in `ja-JP.objects.generated.ts` and
12+
`es-ES.objects.generated.ts`, but their values were byte copies of the English
13+
source that `os i18n extract --fill=default` seeds.
14+
15+
- ja-JP: 340 of the 383 string leaves that equalled their `en` source are now
16+
translated: labels, plural labels, select options, help, descriptions and
17+
empty states. `delegated_admin` reads 委任管理者 on both `sys_member` and
18+
`sys_invitation`, the word the console already uses for that role.
19+
- es-ES: 338 of the 392 are now translated the same way. `delegated_admin`
20+
reads Administrador delegado on both objects, again the console's word.
21+
- The rest stay as written by design, each recorded with its reason in a
22+
per-locale ledger (`objects-ja-jp-echo-decisions.test.ts`,
23+
`objects-es-es-echo-decisions.test.ts`). ja-JP keeps 43: sign-in provider
24+
brands, the bare `ID` initialism, protocol names (JWKS, IdP SSO URL,
25+
Message-ID, UI / API), the `Web` client type as the bundle already renders it,
26+
the Cc / Bcc header abbreviations, and placeholders that show a value the
27+
admin types. es-ES keeps 54: the same brands, `ID`, JWKS, Message-ID,
28+
UI / API, `Web` and placeholders, plus words Spanish spells as English (Error,
29+
Actor, Global, Variables), the loanwords the bundle already uses (Token,
30+
Checksum, Slug) and Cc.
31+
32+
`ja-JP.source-hashes.generated.ts` and `es-ES.source-hashes.generated.ts` were
33+
regenerated by `pnpm i18n:extract`: each records a leaf only while the leaf is
34+
a copy of its source, so they now list exactly those 43 and 54. zh-CN is
35+
unchanged.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Provenance comments in `data/` were re-anchored
6+
7+
Comment and docblock lines under `src/data` (all but the files other open work
8+
holds) that cited tracker numbers which no longer resolve on GitHub now cite
9+
the commit in this repository's history that decided the matter, and say in
10+
their own words what was decided. Comments only: no type, schema, export or
11+
runtime behaviour changes.

‎content/docs/references/data/feed.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ enums here configure the record activity component (`RecordActivityProps` in
1515
`FeedItemType` is not backend-free: it has no backend *import*, yet it is the
1616
TARGET of the map UI consumers apply to the `sys_activity.type` column — a
1717
backend *coupling* — and that column's vocabulary is OPEN and
18-
author-extensible (maintainer ruling 2026-08-24, #11507). `FeedItemType` is
18+
author-extensible (maintainer ruling 2026-08-24, commit 88b9d749a). `FeedItemType` is
1919
therefore the built-in guidance half of that map, never the value domain of
2020
an authoring surface: `RecordActivityProps.types` accepts contributed kinds
21-
beyond it (#11658), and consumers must map unknown `sys_activity.type` values
21+
beyond it (commit 1a6a19c31), and consumers must map unknown `sys_activity.type` values
2222
to a fallback rather than drop them. `SYS_ACTIVITY_BUILTIN_TYPES` below is
2323
the published built-in vocabulary of the `sys_activity.type` column,
2424
co-located with `FeedItemType` because UI consumers map one onto the other.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20432] What the CLI does with the driver's new report-only drift op,
5+
* `unbuildable_index`: a declared index that can never be built, because a key
6+
* column is not a field of the object or is a virtual `formula` field.
7+
*
8+
* ## Why this lives in `packages/cli`
9+
*
10+
* The driver owns the entry. The CLI owns the three places it lands:
11+
*
12+
* - `os migrate plan` renders it through `renderPlan` / `driftTarget`, which
13+
* read `category`, `op.indexName`, `op.type` and `message` generically.
14+
* This file proves that the new op needs no CLI source change to be shown.
15+
* - `os migrate apply` hands it to `applyMigrationEntries`, which reports it
16+
* `skipped`: there is no reconciler arm, and none can exist.
17+
* - The artifact-pinned boot gate applies every non-destructive entry and
18+
* refuses the boot only on `category === 'destructive'`. A report-only
19+
* entry must WARN and let the boot continue. Refusing would take down
20+
* every deployment carrying a misspelt index column at `kernel:ready`,
21+
* over a declaration that no DDL can repair. That is the same asymmetry
22+
* `artifact-boot-migration.report-only-drift.test.ts` pins for
23+
* `manual_column_type_change`.
24+
*
25+
* Every entry here comes from the REAL driver (an in-memory SQLite
26+
* `SqlDriver`) and goes through the REAL gate and renderer. A hand-stamped
27+
* entry would stay green on the day the driver starts emitting the op as
28+
* `destructive`, because nothing would connect the two.
29+
*
30+
* ⚠️ `@objectstack/driver-sql` resolves through its `exports` to its **dist**
31+
* (no `resolve.alias` for it in this package, deliberately), so this file
32+
* reads the BUILT driver. A stale `dist/` makes it a verdict about build
33+
* state. The value import also puts it in the `integration` tier
34+
* (`vitest-tiers.ts`, KERNEL).
35+
*/
36+
37+
import { describe, it, expect, afterEach, vi } from 'vitest';
38+
import { SqlDriver, buildIndexName } from '@objectstack/driver-sql';
39+
import { runArtifactBootMigrationGate } from './artifact-boot-migration.js';
40+
import { driftTarget, groupByCategory, renderPlan, type SqlDriverLike } from './schema-migrate.js';
41+
42+
const T = 'os20432_boot';
43+
const INDEX = buildIndexName(T, ['statsu'], true);
44+
45+
/** A table whose only real column is `status`, declaring a UNIQUE over the misspelling `statsu`. */
46+
const OBJECT = {
47+
name: T,
48+
tenancy: { enabled: false },
49+
fields: { status: { type: 'text', maxLength: 64 } },
50+
indexes: [{ fields: ['statsu'], unique: true as const }],
51+
};
52+
53+
async function syncedDriver(): Promise<SqlDriver> {
54+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
55+
// Quiet: the driver's own error line is pinned in driver-sql. This file is about the CLI's handling.
56+
(driver as any).logger = { debug() {}, info() {}, warn() {}, error() {} };
57+
await driver.initObjects([OBJECT]);
58+
return driver;
59+
}
60+
61+
describe('the unbuildable_index drift op through the CLI (#20432)', () => {
62+
let driver: SqlDriver | undefined;
63+
afterEach(async () => {
64+
vi.restoreAllMocks();
65+
await driver?.disconnect().catch(() => {});
66+
driver = undefined;
67+
});
68+
69+
it('the fixture is real: the driver reports exactly one unbuildable_index entry, needs_confirm', async () => {
70+
driver = await syncedDriver();
71+
const drift = await driver.detectManagedDrift();
72+
expect(drift.map((d) => d.op.type)).toEqual(['unbuildable_index']);
73+
expect(drift[0]).toMatchObject({ category: 'needs_confirm', op: { indexName: INDEX, unique: true } });
74+
});
75+
76+
it('the artifact boot gate warns about it and does NOT refuse the boot', async () => {
77+
driver = await syncedDriver();
78+
const info: string[] = [];
79+
const warn: string[] = [];
80+
81+
// The gate's two members, delegated to the REAL driver. `bootSchemaStack`
82+
// reaches the driver by duck type, and `SqlDriver` keeps `config`
83+
// protected, so the class itself is not assignable to `SqlDriverLike`.
84+
const real = driver;
85+
const gateDriver: SqlDriverLike = {
86+
detectManagedDrift: () => real.detectManagedDrift(),
87+
applyMigrationEntries: (entries, opts) => real.applyMigrationEntries(entries, opts),
88+
};
89+
const verdict = await runArtifactBootMigrationGate({
90+
driver: gateDriver,
91+
artifactDisplay: 'https://artifacts.example.com/app.json',
92+
info: (m) => info.push(m),
93+
warn: (m) => warn.push(m),
94+
});
95+
96+
expect(verdict.ok).toBe(true);
97+
expect(verdict.refusal).toBeUndefined();
98+
expect(verdict.destructive).toEqual([]);
99+
// Handed to the driver, which declined it: skipped, never "migrated".
100+
expect(verdict.applied).toEqual([]);
101+
expect(verdict.skipped.map((d) => d.op.type)).toEqual(['unbuildable_index']);
102+
expect(info).toEqual([]);
103+
// …and the skip is not silent: one warn, carrying the driver's message.
104+
expect(warn).toHaveLength(1);
105+
expect(warn[0]).toContain(T);
106+
expect(warn[0]).toContain(INDEX);
107+
});
108+
109+
it('os migrate plan renders it with no CLI change: grouped needs_confirm, targeted by index name, tagged by op', async () => {
110+
driver = await syncedDriver();
111+
const drift = await driver.detectManagedDrift();
112+
113+
expect(groupByCategory(drift).needs_confirm).toEqual(drift);
114+
expect(driftTarget(drift[0]!)).toBe(`${T} [${INDEX}]`);
115+
116+
const lines: string[] = [];
117+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
118+
lines.push(args.map(String).join(' '));
119+
});
120+
renderPlan(drift);
121+
const out = lines.join('\n');
122+
expect(out).toContain(`${T} [${INDEX}]`);
123+
expect(out).toContain('[unbuildable_index]');
124+
expect(out).toContain(drift[0]!.message);
125+
});
126+
});

0 commit comments

Comments
 (0)