You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit d977ee6
Browse filesBrowse the repository at this point in the historyBrowse files
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.
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.
{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'},
101
106
{name: "file: + syncUrl under a forced mode: 'local'",config: {url: FILE,mode: 'local',syncUrl: REMOTE,sync: {onConnect: false}},ctor: 'accept'},
102
107
{name: "libsql:// under a forced mode: 'remote'",config: {url: REMOTE,mode: 'remote'},ctor: 'accept'},
103
108
{name: "file: under a forced mode: 'remote'",config: {url: FILE,mode: 'remote'},ctor: 'accept'},
@@ -147,13 +152,24 @@ const ROWS: Row[] = [
147
152
{name: 'sync with no syncUrl',config: {url: FILE,sync: {intervalSeconds: 60}},ctor: 'refuse',refusedOn: 'sync'},
148
153
{name: 'sync with no syncUrl on a remote url',config: {url: REMOTE,sync: {intervalSeconds: 60}},ctor: 'refuse',refusedOn: 'sync'},
149
154
{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},
0 commit comments