Skip to content

Commit 606d577

Browse files
os-zhuangclaude
andauthored
feat(spec): close memory driver's persistence sub-shapes against unknown keys (#4001 batch B) (#7985)
Convert the 5 remaining non-strict `z.object` sites in `data/driver/memory.zod.ts` to `strictObject` — `PersistenceAdapterSchema`, `FilePersistenceConfigSchema`, `LocalStoragePersistenceConfigSchema`, `CustomPersistenceConfigSchema`, `AutoPersistenceConfigSchema` — the persistence-adapter union nested under `datasource.config.persistence`. `.strict()` does not recurse, so these stayed open after #4410 closed the top-level `MemoryConfigSchema`. - Added unrecognised-key rejection tests for all 5 sites (reverse-verified: reverting the schema change turns them red). - Regenerated the strictness-ledger counts file and updated its prose to drop `driver/memory.zod.ts`'s now-empty remaining-strip row. - Removed `memory.test.ts`'s graduated entry from test-typecheck-debt.json (regenerated via `gen:test-typecheck-debt`, not hand-edited). Part of #4001. Claude-Session: https://claude.ai/code/session_01ECjShwqVRAhLy15mgatGjV Co-authored-by: Claude <noreply@anthropic.com>
1 parent e3c8ed0 commit 606d577

6 files changed

Lines changed: 221 additions & 48 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
Close the memory driver's `persistence` sub-shapes against unknown keys (#4001 batch B)
6+
7+
zod's default is `.strip`: a key a schema does not declare is silently
8+
discarded and the parse still succeeds. `datasource.config` for a `memory`
9+
driver has been parsed since #4410, but `.strict()` does not recurse — the
10+
top-level `MemoryConfigSchema` was closed then, while the five variant shapes
11+
nested under its `persistence` union stayed open, so a typo written *inside*
12+
`persistence` (e.g. `{ type: 'file', filepath: '/data.json' }`) parsed clean
13+
and the driver came up on its persistence defaults with no signal at all.
14+
15+
`PersistenceAdapterSchema`, `FilePersistenceConfigSchema`,
16+
`LocalStoragePersistenceConfigSchema`, `CustomPersistenceConfigSchema` and
17+
`AutoPersistenceConfigSchema` now raise a named, fixable error — the surface,
18+
the offending key, and (where the schema declares one) an edit-distance "did
19+
you mean" suggestion — instead of dropping the key.
20+
21+
No field was added or removed; every existing valid payload still parses the
22+
same. Only a config that was already writing an unrecognised key under
23+
`persistence` sees a new, loud rejection in place of the old silent no-op.

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ regenerate.
2222
|---|---|
2323
| Triaged directories | 5 |
2424
| Object sites in them | 438 |
25-
| Still-open (strip) sites | 180 |
26-
| Files carrying at least one | 27 |
25+
| Still-open (strip) sites | 175 |
26+
| Files carrying at least one | 26 |
2727

2828
Remaining strip sites by class:
2929

3030
| Bucket | Sites |
3131
|---|---|
32-
| authorable — the ruling's forced scope | 40 |
32+
| authorable — the ruling's forced scope | 35 |
3333
| unresolved — needs a per-schema verdict | 34 |
3434
| wire / open — out of forced scope | 104 |
3535
| no door — no carrier, ADR-0049 territory | 1 |
@@ -45,11 +45,11 @@ The `strict` column is the one the campaign schedules against; it counts both th
4545
| Dir | Sites | strict | passthrough | catchall | strip |
4646
|---|---|---|---|---|---|
4747
| `ui/` | 161 | 119 | 5 | 0 | 37 |
48-
| `data/` | 165 | 57 | 1 | 0 | 107 |
48+
| `data/` | 165 | 62 | 1 | 0 | 102 |
4949
| `automation/` | 65 | 42 | 0 | 0 | 23 |
5050
| `security/` | 20 | 7 | 0 | 0 | 13 |
5151
| `studio/` | 27 | 27 | 0 | 0 | 0 |
52-
| **total** | **438** | **252** | **6** | **0** | **180** |
52+
| **total** | **438** | **257** | **6** | **0** | **175** |
5353

5454
## File-level triage — site counts
5555

@@ -179,7 +179,7 @@ over it is here.
179179

180180
### `data/` — open
181181

182-
**107 strip of 165**, in 16 file(s).
182+
**102 strip of 165**, in 15 file(s).
183183

184184
| File | Strip | Sites |
185185
|---|---|---|
@@ -189,7 +189,6 @@ over it is here.
189189
| `driver-nosql.zod.ts` | 10 | 10 |
190190
| `driver-sql.zod.ts` | 2 | 2 |
191191
| `driver.zod.ts` | 9 | 9 |
192-
| `driver/memory.zod.ts` | 5 | 6 |
193192
| `external-catalog.zod.ts` | 4 | 4 |
194193
| `external-lookup.zod.ts` | 12 | 12 |
195194
| `field-value.zod.ts` | 2 | 3 |
@@ -199,11 +198,11 @@ over it is here.
199198
| `object.zod.ts` | 1 | 20 |
200199
| `query.zod.ts` | 4 | 5 |
201200
| `seed-loader.zod.ts` | 12 | 12 |
202-
| **total** | **107** | **165** |
201+
| **total** | **102** | **165** |
203202

204203
| Bucket | Sites |
205204
|---|---|
206-
| authorable — the ruling's forced scope | 8 |
205+
| authorable — the ruling's forced scope | 3 |
207206
| unresolved — needs a per-schema verdict | 34 |
208207
| wire / open — out of forced scope | 65 |
209208
| no door — no carrier, ADR-0049 territory | 0 |

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1182,7 +1182,6 @@ triage row record which one was taken.
11821182
| `driver.zod.ts` | wire | **out of scope** — driver capability contract |
11831183
| `analytics.zod.ts` | mixed (p) | `Metric` / `Dimension` / `Cube` / `AnalyticsQuery` — cube definitions are authored; needs a per-schema read |
11841184
| `document.zod.ts` | wire (p) | `DocumentTemplate` / `ESignatureConfig` read authorable on their face — the `(p)` is unresolved, verify before scheduling either way |
1185-
| `driver/memory.zod.ts` | authorable | The persistence-adapter union under `datasource.config`; `datasource.config` HAS been parsed against these since #4410, so strictness here now binds |
11861185
| `query.zod.ts` | open | ~~⚠️ classification conflict — see #4721~~ **RESOLVED (11:41Z ruling, closed by #4721).** The conflict was real and the answer was that per-FILE classification was the imprecise instrument: `SortNodeSchema` was carved out as `authorable` and closed (`strictObject` + `aliases: { direction: 'order' }`), the other 4 sites keep `open`. Those 4 are the dialect proper — `BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm — and `BaseQuerySchema`'s own top-level strictness is #4001's to schedule, deliberately **not** taken by #4721 |
11871186
| `external-catalog.zod.ts` | wire (p) | **out of scope** |
11881187
| `hook.zod.ts` | wire | **out of scope**`HookContextSchema` + `.session`/`.provenance`/`.user` are the runtime shape handed to a handler; verified in the data step |
@@ -1192,13 +1191,18 @@ triage row record which one was taken.
11921191

11931192
**Authorable strip in `data/`:**
11941193
[the counts file](./2026-07-unknown-key-strictness-ledger.counts.md#data--open) splits this
1195-
directory three ways, and the middle bucket is the one to read: `object`, `driver/memory`
1196-
and `field` are **firm** authorable; `external-lookup`, `seed-loader`, `analytics` and
1197-
`field-value` are **unresolved** — they still carry `mixed (p)`, so the ledger is saying
1198-
"nobody has done the per-schema read" rather than "these are ready". The rest is wire/open
1199-
and out of the ruling's forced scope; that count fell by one when #4721 closed
1200-
`query.zod.ts`'s `SortNodeSchema`, the one row in this directory where the per-schema read
1201-
moved a site OUT of `open` rather than confirming it.
1194+
directory three ways, and the middle bucket is the one to read: `object` and `field` are
1195+
**firm** authorable; `external-lookup`, `seed-loader`, `analytics` and `field-value` are
1196+
**unresolved** — they still carry `mixed (p)`, so the ledger is saying "nobody has done the
1197+
per-schema read" rather than "these are ready". The rest is wire/open and out of the
1198+
ruling's forced scope; that count fell by one when #4721 closed `query.zod.ts`'s
1199+
`SortNodeSchema`, the one row in this directory where the per-schema read moved a site OUT
1200+
of `open` rather than confirming it, and by one more when **#4001 batch B** closed
1201+
`driver/memory.zod.ts`'s remaining 5 sites (the persistence-adapter union under
1202+
`datasource.config``PersistenceAdapterSchema`, `FilePersistenceConfigSchema`,
1203+
`LocalStoragePersistenceConfigSchema`, `CustomPersistenceConfigSchema`,
1204+
`AutoPersistenceConfigSchema`), dropping its row from the remaining-strip map entirely: the
1205+
file's 6th site, `MemoryConfigSchema`, was already `strictObject` since #4410.
12021206

12031207
**批 20 closed 13 of `object.zod.ts`'s 14 and parked the row at 1**, which makes it
12041208
the fourth row in this ledger to shrink without disappearing — after `flow` (批 11),

packages/spec/src/data/driver/memory.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,23 @@ describe('FilePersistenceConfigSchema', () => {
295295
expect(config.type).toBe('file');
296296
expect(config.path).toBeUndefined();
297297
});
298+
299+
// #4001 batch B: this shape was a bare `z.object` — an unrecognised key was
300+
// silently stripped and the file adapter came up on its defaults with no
301+
// signal at all. `.strict()` makes that loud.
302+
it('rejects an unrecognised key instead of silently stripping it', () => {
303+
const result = FilePersistenceConfigSchema.safeParse({
304+
type: 'file',
305+
path: '/data/store.json',
306+
filepath: '/data/other.json', // typo'd key, not a real field
307+
});
308+
309+
expect(result.success).toBe(false);
310+
expect(result.error!.issues[0]!.message).toContain(
311+
"this memory datasource's file persistence config",
312+
);
313+
expect(result.error!.issues[0]!.message).toContain('filepath');
314+
});
298315
});
299316

300317
describe('LocalStoragePersistenceConfigSchema', () => {
@@ -316,6 +333,20 @@ describe('LocalStoragePersistenceConfigSchema', () => {
316333
expect(config.type).toBe('local');
317334
expect(config.key).toBeUndefined();
318335
});
336+
337+
// #4001 batch B — see the FilePersistenceConfigSchema case above.
338+
it('rejects an unrecognised key instead of silently stripping it', () => {
339+
const result = LocalStoragePersistenceConfigSchema.safeParse({
340+
type: 'local',
341+
storageKey: 'myapp:db', // typo'd key, not a real field
342+
});
343+
344+
expect(result.success).toBe(false);
345+
expect(result.error!.issues[0]!.message).toContain(
346+
"this memory datasource's localStorage persistence config",
347+
);
348+
expect(result.error!.issues[0]!.message).toContain('storageKey');
349+
});
319350
});
320351

321352
describe('CustomPersistenceConfigSchema', () => {
@@ -332,6 +363,58 @@ describe('CustomPersistenceConfigSchema', () => {
332363
expect(typeof config.adapter.save).toBe('function');
333364
expect(typeof config.adapter.flush).toBe('function');
334365
});
366+
367+
// #4001 batch B — see the FilePersistenceConfigSchema case above.
368+
it('rejects an unrecognised key instead of silently stripping it', () => {
369+
const result = CustomPersistenceConfigSchema.safeParse({
370+
adapter: {
371+
load: async () => null,
372+
save: async () => {},
373+
flush: async () => {},
374+
},
375+
options: { retries: 3 }, // not a real field on this shape
376+
});
377+
378+
expect(result.success).toBe(false);
379+
expect(result.error!.issues[0]!.message).toContain(
380+
"this memory datasource's custom-adapter persistence config",
381+
);
382+
expect(result.error!.issues[0]!.message).toContain('options');
383+
});
384+
});
385+
386+
describe('PersistenceAdapterSchema', () => {
387+
it('should accept a valid adapter (load/save/flush)', () => {
388+
const config = PersistenceAdapterSchema.parse({
389+
load: async () => null,
390+
save: async () => {},
391+
flush: async () => {},
392+
});
393+
394+
expect(typeof config.load).toBe('function');
395+
expect(typeof config.save).toBe('function');
396+
expect(typeof config.flush).toBe('function');
397+
});
398+
399+
// #4001 batch B: this shape was a bare `z.object` — an unrecognised key
400+
// (e.g. a typo'd lifecycle method) was silently stripped instead of being
401+
// reported, so a custom adapter missing `flush` because the author wrote
402+
// `close` instead got a clean parse and a driver that never persisted on
403+
// shutdown.
404+
it('rejects an unrecognised key instead of silently stripping it', () => {
405+
const result = PersistenceAdapterSchema.safeParse({
406+
load: async () => null,
407+
save: async () => {},
408+
flush: async () => {},
409+
close: async () => {}, // not a real field on this shape
410+
});
411+
412+
expect(result.success).toBe(false);
413+
expect(result.error!.issues[0]!.message).toContain(
414+
"this memory datasource's custom persistence adapter",
415+
);
416+
expect(result.error!.issues[0]!.message).toContain('close');
417+
});
335418
});
336419

337420
describe('AutoPersistenceConfigSchema', () => {
@@ -366,6 +449,20 @@ describe('AutoPersistenceConfigSchema', () => {
366449
autoSaveInterval: 50, // Below minimum of 100
367450
})).toThrow();
368451
});
452+
453+
// #4001 batch B — see the FilePersistenceConfigSchema case above.
454+
it('rejects an unrecognised key instead of silently stripping it', () => {
455+
const result = AutoPersistenceConfigSchema.safeParse({
456+
type: 'auto',
457+
interval: 5000, // meant `autoSaveInterval`, not a real field
458+
});
459+
460+
expect(result.success).toBe(false);
461+
expect(result.error!.issues[0]!.message).toContain(
462+
"this memory datasource's auto-detect persistence config",
463+
);
464+
expect(result.error!.issues[0]!.message).toContain('interval');
465+
});
369466
});
370467

371468
describe('MemoryPersistenceConfigSchema', () => {

0 commit comments

Comments
 (0)