Skip to content

Commit d977ee6

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20300-cube-member-inner-name-retired
# Conflicts: # packages/spec/src/migrations/registry.ts
2 parents f694896 + c876a74 commit d977ee6

48 files changed

Lines changed: 2143 additions & 837 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/driver-turso': minor
4+
---
5+
6+
fix(spec,driver-turso)!: a turso config that forces `mode: 'replica'` with no `syncUrl` is refused where it is written and when the driver is built, instead of running as a plain local database that never syncs
7+
8+
Clause-②: yes (narrowing) — the accept set of the `turso` `datasource.config` contract narrows by one combination. No key is added, removed or renamed, and no exported symbol moves.
9+
10+
An embedded replica is a local file kept in sync with the remote named in `syncUrl`. A config that forced `mode: 'replica'` on a `file:` url with no `syncUrl` (or an empty one) was accepted by `@objectstack/spec`'s `TursoConfigSchema`, by the published mirror in `@objectstack/driver-turso`, and by `new TursoDriver()`. Measured on the built driver before this change, with and without `sync`: it constructed with `transportMode` `'replica'`, `isSyncEnabled()` answered `false`, no sync interval started, the sync call did nothing, and every read and write went to the local file. A datasource declared as a replica ran as a plain local database that never replicated, with no error and no warning.
11+
12+
**BREAKING** accept-set narrowing on a published schema and a published constructor, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). Refused now, at both doors together, with one message whose prescription names both ways out:
13+
14+
- **at authoring**, as one `custom` issue on `mode` (`config.mode` on a datasource): `DatasourceSchema`, `validateDriverConfig`, `defineStack` / `os validate`, and a save or test connection through the datasource admin service;
15+
- **at construction**, `VALIDATION_ERROR` / 400 from `new TursoDriver()` (and `createTursoDriver()`), before any client or database is opened.
16+
17+
The message is the same text at both doors, and a test holds the constructor's copy equal to the schema's issue byte for byte. The sibling refusals keep their order. A forced replica on a remote url, an in-memory url or a bare path still meets its `url` refusal first. One with `sync` and no `syncUrl` still meets the `sync` refusal first; the schema now reports the `mode` issue beside it. The driver mirror declares no `mode` key and strips an authored one, so it cannot see a forced mode: this refusal reaches it only as byte-identical text, and the spec contract and the constructor are the two doors that judge it.
18+
19+
### Migration: FROM → TO
20+
21+
| You wrote | Write instead |
22+
| --- | --- |
23+
| `url: 'file:./data/replica.db', mode: 'replica'` (no `syncUrl`, or `syncUrl: ''`) | an embedded replica: keep the `file:` url and name the remote, `syncUrl: 'libsql://my-db.turso.io'` |
24+
| the same | a plain local database: drop `mode` (`url: 'file:./data/app.db'` alone) |
25+
26+
A datasource row stored in this shape is not re-parsed when it loads, so it now fails when the driver is built. `factory.create` throws the refusal. The connection service records the datasource as `failed-degraded` with the message, and a test connection answers `ok: false` ("Failed to build driver: …"). Under ADR-0062 D5, the boot fails fast when objects bind to that datasource or are routed to it, or when it is boot-critical, unless `OS_ALLOW_DRIVER_CONNECT_FAILURE` is set. Otherwise it is left unconnected with a warning. Before this change the same row booted and ran as a local database. The way out is the table above.
27+
28+
Blast radius, measured on this tree: no example, template, published skill or hand-written doc authors the shape, and no host default or environment variable sets `mode` (a turso `mode` reaches the driver only from an authored `datasource.config`). Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero.
29+
30+
<!-- adr-0087: registered turso-config-forced-replica-without-sync-url-refused -->
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/objectql": patch
4+
---
5+
6+
The number-comparand refusal now says "a numeric aggregated column" at `having`, and names PostgreSQL's server error only where a driver actually binds the comparand
7+
8+
Clause-②: no
9+
10+
**Two false phrases, at two positions.** At `having`, filtering a `count` /
11+
`sum` / `avg` result (or a groupBy column) against a non-numeric comparand
12+
answered `filter on 'total' compares a declared number field …` — `total` is
13+
the aggregated row's own column, not a declared field of the object; the
14+
verdict is handed the numeric class the column belongs to, which has no
15+
`FieldType` of its own. And at `having` and the per-aggregation `filter`, the
16+
`not-a-number`, `boolean` and `date` clauses each named "(PostgreSQL with a
17+
server error)", a fact about `where`: the engine evaluates both of those
18+
clauses itself, on every driver, before any row is read, so a comparand there
19+
never reaches a driver bind and PostgreSQL never answers it.
20+
21+
**Measured, unchanged: the per-aggregation `filter`'s column IS a declared
22+
field.** That position narrows the object's RAW rows before any aggregation
23+
runs, against the object's real field map — so its refusal keeps "a declared …
24+
field", exactly as `where`'s does. Only the PostgreSQL clause moves there,
25+
because the engine evaluates that position itself too.
26+
27+
**FROM** `filter on 'total' compares a declared number field against "abc" at
28+
having.total.$gt, which is not a number: it has no numeric reading, and
29+
backends answer it differently (PostgreSQL with a server error). …`
30+
31+
**TO** `filter on 'total' compares a numeric aggregated column against "abc"
32+
at having.total.$gt, which is not a number: it has no numeric reading. …`
33+
34+
The `where` message is unchanged, byte for byte, and so is the accept set: no
35+
comparand that was refused before is now accepted, and none that passed is now
36+
refused. This is a wording fix.
37+
38+
**What moved to carry it.** `NumberComparandRefusalSite` (`@objectstack/spec`)
39+
gains two optional fields the engine door already knew and now passes along:
40+
`aggregated` (the column is an aggregated-row column, not a declared field —
41+
`having` sets it; `where` and the per-aggregation `filter` do not) and
42+
`boundByDriver` (this position reaches a live driver bind — `where` alone sets
43+
it true; unset defaults to `true`, so a site built before this change, or any
44+
caller who never sets these fields, renders exactly as it always has).
45+
`@objectstack/objectql`'s door passes both explicitly at each of its three
46+
call sites; no second rule and no driver-level change.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
refactor(spec): protocol 18's migration step keeps its `rationale` as key-sorted fragments and derives its `conversionIds` — no value changes (#20535)
6+
7+
Nothing a consumer reads changes. `MIGRATIONS_BY_MAJOR[18].rationale` (48,953
8+
characters), `MIGRATIONS_BY_MAJOR[18].conversionIds` (45 ids, same order) and the
9+
whole `MIGRATIONS_BY_MAJOR` value are byte-identical to the previous release, and so
10+
is the rationale `migrate meta` prints for the 17 → 18 hop.
11+
12+
What changed is how the step is written, so two major-18 retirements can be in
13+
flight at once without conflicting in `packages/spec/src/migrations/registry.ts`:
14+
15+
- The rationale is `STEP18_RATIONALE`, one `{ id, order, text }` fragment per
16+
retirement, kept sorted by `id` and rendered by `order`, joined with one space.
17+
A retirement adds ONE fragment where its `id` (its D3 semantic entry id) sorts —
18+
never at the end — with `order` one more than the highest present.
19+
- `conversionIds` is read off `CONVERSIONS_BY_MAJOR[18]`, which it copied value
20+
for value. A retirement adds its conversion there only.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Provenance comments in `ui/` were re-anchored
6+
7+
Comment and docblock lines under `src/ui/`, and in
8+
`src/data/filter-subtree-provenance.ts` and
9+
`src/meta-spelling/manifest-collection-spelling.ts`, that cited tracker numbers
10+
which no longer resolve on GitHub now cite the commit in this repository's
11+
history that decided the matter, or the ADR amendment they quote, and say in
12+
their own words what was decided. Two references to objectui numbers now name
13+
objectui on each number. Comments only: no type, schema, export or runtime
14+
behaviour changes.

‎content/docs/references/ui/expression-bindable-text-keys.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ above, its omission is not merely unmeasured: `value` there is a fallback
7474
spelling for the same slot `content` already evaluates (see `content` NOT a
7575
member, above), not a second read point, so a `text: ['value']` row would
7676
legitimize the fallback spelling and give one slot two declared evaluation
77-
paths. The objectstack#13670 ruling settled `text`'s intended evaluation
77+
paths. The ruling that commit 8c6a7fc0b records settled `text`'s intended evaluation
7878
channel as `content` alone and declared `text.value` OUT on those grounds —
7979
this omission is deliberate, not pending measurement. Adding a row is
8080
additive and spec-first; do it here, never as a renderer-side inference.
@@ -91,7 +91,7 @@ rule would in the same motion grant rows to `element:button` and
9191
`properties` bag and never read these keys at the node's top level.
9292

9393
`action:button` is therefore deliberately OUT, and `ui:button` with it
94-
(objectstack#13672); the `button` row above covers the bare `button`
94+
(commit e854a531a); the `button` row above covers the bare `button`
9595
spelling alone, which is why its citation names `form/button.tsx` only. Two
9696
measured grounds, the same two kinds every other row runs on — zero pull
9797
(the objectui corpus census of 736 JSON documents / 2747 typed nodes finds 5

‎packages/drivers/driver-turso/README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,18 @@ no embedded replica for a remote url anyway. For a remote database, drop
223223
no local engine, so its url is not judged here: `@libsql/client` refuses a
224224
url it cannot open when the driver connects.
225225

226+
The constructor also refuses (`VALIDATION_ERROR` / 400) three sync settings
227+
that nothing would honour, each with the message `@objectstack/spec`'s
228+
`TursoConfigSchema` gives at authoring:
229+
230+
- `syncUrl` under a forced `mode: 'remote'`, where the remote client never
231+
receives it. For a remote database, drop `syncUrl` (and `sync`);
232+
- `sync` with no `syncUrl` (or an empty one), in any mode, where nothing reads
233+
it. Set `syncUrl`, or remove `sync`;
234+
- a forced `mode: 'replica'` with no `syncUrl` (or an empty one), which would
235+
never sync and would run as a plain local database. Name the remote in
236+
`syncUrl` beside the `file:` url, or drop `mode` for a local database.
237+
226238
You can also force a specific mode:
227239

228240
```typescript

‎packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
* - [#20200] where the constructor refuses on `syncUrl` or `sync`, its message
2929
* is the spec contract's issue message, byte for byte: those two texts are
3030
* copies in `../turso-driver.ts`, and this is the pin that holds them equal.
31+
* [#20437] The same holds for a forced `mode: 'replica'` with no `syncUrl`,
32+
* refused on `mode` — the third copy. That row used to be accepted
33+
* everywhere, as a replica that never synced.
3134
*
3235
* ⚠️ The mirror declares no `mode`, so zod strips an authored one before its
3336
* refinement runs: rows that FORCE a mode are judged by the constructor and the
@@ -74,7 +77,9 @@ interface Row {
7477
/** The constructor's verdict on this config. */
7578
ctor: 'accept' | 'refuse';
7679
/** The key both schemas refuse on, or `undefined` when they accept. */
77-
refusedOn?: 'url' | 'syncUrl' | 'timeoutMs' | 'sync';
80+
refusedOn?: 'url' | 'syncUrl' | 'timeoutMs' | 'sync' | 'mode';
81+
/** How many issues the spec contract raises on a refused row; 1 unless stated. */
82+
issues?: number;
7883
/** The constructor accepts it and ignores a key: refused at authoring only. None today (#20200). */
7984
inert?: true;
8085
}
@@ -97,7 +102,7 @@ const ROWS: Row[] = [
97102
{ name: 'a url behind whitespace (the loaders trim it)', config: { url: ` ${FILE}` }, ctor: 'accept' },
98103
{ name: 'a remote url behind whitespace', config: { url: ` ${REMOTE}` }, ctor: 'accept' },
99104
{ name: 'an empty syncUrl (unset)', config: { url: REMOTE, syncUrl: '' }, ctor: 'accept' },
100-
{ name: "file: under a forced mode: 'replica'", config: { url: FILE, mode: 'replica' }, ctor: 'accept' },
105+
{ name: "file: + syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'accept' },
101106
{ name: "file: + syncUrl under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'accept' },
102107
{ name: "libsql:// under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote' }, ctor: 'accept' },
103108
{ name: "file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote' }, ctor: 'accept' },
@@ -147,13 +152,24 @@ const ROWS: Row[] = [
147152
{ name: 'sync with no syncUrl', config: { url: FILE, sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
148153
{ name: 'sync with no syncUrl on a remote url', config: { url: REMOTE, sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
149154
{ name: "sync with no syncUrl under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote', sync: { onConnect: true } }, ctor: 'refuse', refusedOn: 'sync' },
150-
{ name: "sync with no syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
155+
// The spec contract raises BOTH issues here (`sync`, then `mode`); the
156+
// constructor throws one, the `sync` refusal, which is the spec's first.
157+
{ name: "sync with no syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync', issues: 2 },
151158
{ name: 'sync beside an empty syncUrl (unset)', config: { url: FILE, syncUrl: '', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
159+
160+
// ── a forced replica with no remote to replicate from: refused on `mode` (#20437) ──
161+
{ name: "file: under a forced mode: 'replica'", config: { url: FILE, mode: 'replica' }, ctor: 'refuse', refusedOn: 'mode' },
162+
{ name: "an uppercase FILE: url under a forced mode: 'replica'", config: { url: `FILE:${DIR}/upper-replica.db`, mode: 'replica' }, ctor: 'refuse', refusedOn: 'mode' },
163+
{ name: "file: + an empty syncUrl (unset) under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: '' }, ctor: 'refuse', refusedOn: 'mode' },
164+
{ name: "file: + timeoutMs under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', timeoutMs: 5000 }, ctor: 'refuse', refusedOn: 'mode' },
152165
];
153166

154-
/** The rows the constructor refuses on a sync key: its message is a copy of the spec's (#20200). */
167+
/**
168+
* The rows the constructor refuses on a sync key, or on a forced replica with
169+
* no `syncUrl`: its message is a copy of the spec's (#20200, #20437).
170+
*/
155171
const SYNC_KEY_REFUSALS = ROWS.filter(
156-
(r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync'),
172+
(r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync' || r.refusedOn === 'mode'),
157173
);
158174

159175
/**
@@ -209,7 +225,8 @@ describe('turso config: the constructor, the spec contract and this mirror agree
209225
expect(ROWS.filter((r) => r.refusedOn === 'timeoutMs').length).toBeGreaterThanOrEqual(3);
210226
expect(ROWS.filter((r) => r.refusedOn === 'syncUrl').length).toBeGreaterThanOrEqual(3);
211227
expect(ROWS.filter((r) => r.refusedOn === 'sync').length).toBeGreaterThanOrEqual(5);
212-
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(8);
228+
expect(ROWS.filter((r) => r.refusedOn === 'mode').length).toBeGreaterThanOrEqual(4);
229+
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(12);
213230
// [#20200] Exactly zero, not a floor: every key the constructor used to
214231
// build and ignore is refused at construction now (see the header).
215232
expect(ROWS.filter((r) => r.inert).length).toBe(0);
@@ -229,7 +246,7 @@ describe('turso config: the constructor, the spec contract and this mirror agree
229246
const verdict = schemaVerdict(SpecTursoConfigSchema, row.config);
230247
expect(verdict.refusedOn, verdict.message).toBe(row.refusedOn);
231248
if (row.refusedOn) {
232-
expect(verdict.count, verdict.message).toBe(1);
249+
expect(verdict.count, verdict.message).toBe(row.issues ?? 1);
233250
expect(verdict.code).toBe('custom');
234251
}
235252
});
@@ -241,11 +258,12 @@ describe('turso config: the constructor, the spec contract and this mirror agree
241258
});
242259
});
243260

244-
// [#20200] The two sync refusals are copies of the spec contract's texts in
245-
// `../turso-driver.ts` (the spec keeps them module-local); this is the pin
246-
// that holds each copy equal to the schema's issue, byte for byte.
261+
// [#20200] The two sync refusals, and [#20437] the forced-replica refusal,
262+
// are copies of the spec contract's texts in `../turso-driver.ts` (the spec
263+
// keeps them module-local); this is the pin that holds each copy equal to the
264+
// schema's issue, byte for byte.
247265
describe.each(SYNC_KEY_REFUSALS)('$name', (row) => {
248-
it("the constructor's message is the spec contract's, byte for byte (#20200)", () => {
266+
it("the constructor's message is the spec contract's, byte for byte (#20200, #20437)", () => {
249267
const spec = schemaVerdict(SpecTursoConfigSchema, row.config);
250268
expect(spec.refusedOn).toBe(row.refusedOn);
251269
expect(spec.message).toBeTypeOf('string');

‎packages/drivers/driver-turso/src/spec/turso.zod.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ interface TursoTransportKeys {
155155

156156
/** One refusal: the key it sits on and its message. */
157157
interface TursoTransportIssue {
158-
path: 'url' | 'syncUrl' | 'timeoutMs';
158+
path: 'url' | 'syncUrl' | 'timeoutMs' | 'mode';
159159
message: string;
160160
}
161161

@@ -239,6 +239,24 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] {
239239
+ `${drop} for a plain in-memory local database.`,
240240
}];
241241
}
242+
if (mode === 'replica' && !hasSyncUrl) {
243+
// #20437. Only a FORCED replica reaches here: with no `mode`, a replica is
244+
// selected by `syncUrl` alone. The url is a `file:` url (every other one
245+
// met a refusal above), so the url is fine and the MODE is what cannot be
246+
// honoured — the issue sits on `mode`, as the `sync` refusal sits on `sync`.
247+
// Unreachable through this mirror, which strips `mode` (see above); kept
248+
// byte-identical to the spec contract's arm.
249+
return [{
250+
path: 'mode',
251+
message:
252+
"`mode: 'replica'` makes this datasource an embedded replica, a local file kept in sync with "
253+
+ 'the remote named in `syncUrl`, but no `syncUrl` is set: nothing would ever sync, so it would '
254+
+ 'run as a plain local database that never replicates — the turso driver refuses this '
255+
+ 'configuration when it starts. For an embedded replica, name the remote in `syncUrl` beside '
256+
+ "the local file: `url: 'file:./data/replica.db'` with `syncUrl` set to the `libsql://` or "
257+
+ "`https://` Turso endpoint. For a plain local database, drop `mode: 'replica'`.",
258+
}];
259+
}
242260
return [];
243261
}
244262

0 commit comments

Comments
 (0)