Skip to content

Commit 6befe19

Browse files
fix(metadata-protocol): put and delete accept the version a checksum-less sys_metadata row is served as (#21990)
Fixes #21978 Clause-②: no ## What this changes `SysMetadataRepository` (`packages/metadata-protocol/src/sys-metadata-repository.ts`) served a `sys_metadata` row that has no `checksum` as the hash of its stored body (`rowToItem`), but `put` and `delete` judged the caller's parent against the raw column (`existing.checksum ?? null`). So a row like that could never be written or removed through the metadata door. Every `saveMetaItem` / `deleteMetaItem` answered `409 METADATA_CONFLICT` ("Expected parent hmac-sha256:… but current is null"), whether the parent was the version the door served or no `If-Match` was sent at all, because the door takes the parent from the same read. Publish, rollback and commit revert over such a row hit the same lock, and the post-promotion drain of a checksum-less draft was refused and silenced as a benign race. Per triage's direction (6014717866), with nothing narrowed and no backfill: - **One helper**, `servedVersion(ref, row)`: the stored `checksum`, else `hashSpec(body, type)`. `rowToItem` now reads it, so every read hands out this one value. - **One lock**, `lockAccepts(ref, row, parent)`, used by `put` and `delete`. It accepts the row's stored stamp, which is the old compare unchanged: a row with a `checksum` is judged exactly as before, and a `null` parent still matches a checksum-less row. For a checksum-less row it also accepts the served version. - **The conflict's head** (`lockHead`) is the served version, so a 409 on such a row names the version a read hands out (before this, `null`). A checksum-less row whose bytes do not parse keeps `null` there, so a lock refusal never becomes a parse error. - The lineage fields (`previous_checksum`, the event's `parentHash`) and the no-op check keep reading the raw stamp. So the first write over a checksum-less row, even with an identical body, stamps the row as usual. Nothing is rewritten at rest, and the header's "no backfill" non-goal stands, now with one line on how such a row is served. **File surface:** as dispatched. The producer that wrote such rows (the datasource admin door) already stamps a checksum since PR #21977, which is on `main`, so the remaining work is the stored rows, and that lands in this repository class. Two test files in the same package: the pins, plus one fixture comment in `protocol-publish-drafts-package-scope.test.ts` that this change made false. Changeset: `@objectstack/metadata-protocol` patch. ## Pins (`protocol.served-content-hash.test.ts`, the existing conflict-test double) Through the protocol's real `saveMetaItem` / `deleteMetaItem` / `publishMetaItem`, on a row seeded with no `checksum`: - (a) saved and deleted with the version its read serves, in the keyed form a door hands out: the repository's own `get` read, keyed; - (a) unpinned (last-write-wins) save and delete succeed: the dogfood shape; - (b) a stale keyed token and the raw served hash are still refused with `METADATA_CONFLICT` / `409` on both doors; `actualHead` is the served token, the row is untouched, and retrying with that `actualHead` succeeds; - (c) a `null` parent still succeeds: `storedParentVersion: row.checksum ?? null`, the stored-row migration's in-process spelling; - (d) after each write the row carries `hashSpec(newBody, 'view')`; an identical re-save stamps it too; - publish over a checksum-less active row; the drain removes a checksum-less draft row; - repository level: a row WITH a checksum whose stamp differs from its body's hash refuses the body's hash and `null` (both name the stamp as head) and accepts its stamp; a checksum-less row accepts `null` and its served version, and refuses anything else with the served version as head. ## Reverse verification (committed HEAD `5c4815a6ab`) The mutation went through `scripts/ablation-replace.mjs` with an EXIT/INT/TERM restore trap and absolute paths. It restored the raw compare in both `put` and `delete` (anchor hit x2 → x0, replacement x0 → x2, blob `dc58518587` → `494fa3f0ee`; on disk, raw-compare 0 → 2 and `lockAccepts` call 2 → 0). - Predicted beforehand: 7 of the 9 new pins red, and green for the `null`-parent pin and the stamped-row pin, which guard against widening and against narrowing rather than this mutation. - **Observed: `Tests 7 failed | 16 passed (23)`**, the 7 predicted. The save door reproduced the card's text verbatim: "view/case_grid has been modified since you loaded it. Expected parent hmac-sha256:e532d121… but current is null." The drain pin read the draft row still present, and the repository pin read `actualHead` `null`. - Restore was proven by observation: blob after restore `dc58518587` equals the HEAD blob, `git diff HEAD` is empty, and `git status --porcelain` is empty. - An earlier invocation was a no-op: the tool refused with exit 2 before writing, because it located the repository from the shared checkout's cwd. On-disk counts were unchanged, and it was rerun from the worktree root. The subject is imported by relative `src` path (`./protocol.js`, `./sys-metadata-repository.js`), so no `dist/` sits on the ablation's resolution path. ## Clause-② (measured against the built entry declarations) `packages/metadata-protocol/dist/index.d.ts` was built at HEAD, and again with BASE `8a399b2b15`'s repository source swapped in behind a trap. The swap was restored and proven by blob equality, and HEAD was rebuilt, giving a byte-identical `index.d.ts`. The diff's non-comment lines are `private servedVersion;`, `private lockHead;` and `private lockAccepts;`, with 0 removed; everything else is doc text. `index.d.cts` has the identical diff. No exported type or signature moves. Behaviourally, `put` / `delete` accept for a checksum-less row the version the same repository already serves for it, which is the declared version token, not a new class of input. ## Tests and gates: all on HEAD `81606021e2` (after merging `origin/main` twice, the second bringing PR #21979's `protocol.ts` change) - `pnpm --filter @objectstack/metadata-protocol test`: `Test Files 218 passed | 3 skipped (221)`, `Tests 28028 passed | 19 skipped (28047)`. `typecheck`: `tsc --noEmit` clean, and the test file is in the program (`--listFiles` count 1). Lock VERDICT command-exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived the 63 commands, and all ran at exit 0. `check:type-check-debt` ran under the verify lock ("1 ledger entr(ies) re-measured … 26 raw tsc error(s) total, none above its recorded number"). `check:dual-build-cjs-loads` and `check:lean-entry-closure` ran after a full `turbo run build` (72 tasks, 71 cached). Reconciliation, `--ran` with per-command exit codes: "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN". - The artifact-roster block (55 families, outside the total): 52 at exit 0. `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths` answered NOT WIRED (exit 2, no PR context); they are rerun against this PR and reported in the `os-dev-report` comment. - The four symbol-anchor sweeps (`check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`, `check:adr-anchors`): exit 0. - NOT MEASURED locally, owned by CI: the five path-scheduled CI jobs (Test Core shards, Temporal Conformance, Dogfood Regression Gate, Dogfood Verify CLI, Build Core) and the workspace type-check lanes. `packages/qa/dogfood/test/datasource-meta-door-reaches-admin-door.dogfood.test.ts` was not run locally. ## Census: writers of `sys_metadata` that can store a row with no `checksum` | Writer | Where | `checksum` | Still producing such rows | |---|---|---|---| | `SysMetadataRepository.put` (insert / update) | `metadata-protocol/src/sys-metadata-repository.ts` | always `hashSpec(body, type)` | no | | `SysMetadataRepository.delete` | same file | removes the row. Its tombstone goes to `sys_metadata_history` with `checksum: null` by design | n/a (history table) | | datasource admin door `writeDatasourceRow` | `service-datasource/src/datasource-admin-plugin.ts` | `hashSpec(record, 'datasource')` since PR #21977; none before | no. Its pre-#21977 rows are the stored population this PR makes writable | | datasource admin door delete fallback | same file | `update { state: 'inactive' }`, which keeps the column | no | | `DatabaseLoader` save / create / `registerRollback` | `metadata/src/loaders/database-loader.ts` | `contentHash` stamp | no | | protocol orphan adoption (`package_id` rebind) | `metadata-protocol/src/protocol.ts` | partial update, which keeps the column | no | | protocol legacy delete, permission-set overlay discard | `protocol.ts`, `plugin-security/src/permission-set-overlay-discard.ts` | delete only | no | | `env_id` → `project_id` migration | `metadata/src/migrations/migrate-env-id-to-project-id.ts` | column rename DDL | no | | stored-row migration, flow credential move | `protocol.ts` `migrateStoredMetadata`, `service-automation/src/flow-credential-migration.ts` | through `saveMetaItem` → `put` (stamps) | no. Both were refused on such rows before this PR and succeed now | | generic data door, MCP data bridge, flow write nodes, hook bodies | — | refused: `sys_metadata` declares `apiMethods: ['get', 'list']`, plus the stored-metadata family refusals | no | A tombstone reads back as a `delete` event with `hash: null` (`history()` / `rowToEvent`). `getByHash` never matches it, and `restoreVersion` refuses it with `VERSION_NOT_RESTORABLE`. **No writer is still live after this change, so no follow-up card.** ## Acceptance notes - `packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts` (about `:190`) explains its explicit `parentVersion: null` by saying a raw-seeded row's derived parent "would 409". After this change it would not; the `null` it passes stays valid. Comment drift in another package, left as is. Owner: none. - The first write over a checksum-less row records `previous_checksum: null` / `parentHash: null`, the raw stamp. That is deliberate: no history row carries the served hash, so naming it would be a parent link to nothing. - A conflict-audit note on such a row now reads "current is (withheld)" where it read "current is null", because the head is no longer null. - `DraftDrainFailure.draftHash` is documented as "the row's `checksum`". It is the served version, the same value for a stamped row. This is a doc imprecision predating this PR. - Rollback (`restoreVersion`) and commit revert over a checksum-less active row take the served parent and pass the same lock. This was read in code; only publish is pinned as the representative internal caller. - No door read serves a version token for a stored row that has no history; the tokens come from receipts, history events and a 409's `actualHead`. So for a legacy row, the 409 is the first place a client sees its token. The stale-version pin covers that retry. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aa09db5 commit 6befe19

4 files changed

Lines changed: 343 additions & 12 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
A metadata row stored with no `checksum` can be edited and removed through the metadata door (#21978)
6+
7+
Clause-②: no
8+
9+
- `SysMetadataRepository` serves a `sys_metadata` row that carries no `checksum` as the hash of its stored body, but its `put` and `delete` compared the caller's parent with the raw column (`null`). So `PUT` and `DELETE /api/v1/meta/:type/:name` answered `409 METADATA_CONFLICT` ("Expected parent … but current is null") for every such row, with `If-Match` set to the version the door served and with no `If-Match` (last-write-wins) alike. A publish over such a row was refused the same way, as were the rollback and commit-revert doors, which take their parent from the same read. The datasource admin door stored such rows before it stamped them.
10+
- `put` and `delete` now accept the version such a row is served as. A `null` parent still matches it, and a row with a `checksum` is judged exactly as before. A stale version is still refused with `409 METADATA_CONFLICT`, and the refusal now names the row's served version as the current one instead of `null`.
11+
- The next write stamps the row's `checksum`, as every write does. Stored rows are not rewritten.
12+
- Publishing a draft row stored with no `checksum` now also removes that draft row. Before, the post-promotion cleanup was refused by the same lock and the draft stayed pending, with nothing reported.

‎packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -621,10 +621,10 @@ describe('publishMetaItem — the scope probes ask the promote\'s question (#110
621621
// must not come from `saveMetaItem(mode:'draft')` while PR #11139 is
622622
// changing that path's binding resolution. The shape mirrors what the
623623
// repository's `put` writes for a package-less org draft — `checksum`
624-
// included: the post-promotion drain is an optimistic-lock delete
625-
// keyed on it, and a checksum-less row makes the drain read as the
626-
// benign "newer draft saved" race and survive (measured on this
627-
// fixture's first run).
624+
// included. (On this fixture's first run a checksum-less row made the
625+
// post-promotion drain read as the benign "newer draft saved" race and
626+
// survive; since #21978 the drain's lock accepts such a row's served
627+
// version, and the stamp stays only so the row is the one `put` writes.)
628628
const noPackageBody = objectBody('shared_ticket', 'NO_PACKAGE');
629629
await engine.insert('sys_metadata', {
630630
type: 'object',

‎packages/metadata-protocol/src/protocol.served-content-hash.test.ts‎

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@
3030
* own read and write shapes (the `protocol.lifecycle-audit-rows.test.ts` double,
3131
* plus the engine's `getKeyedDigest` accessor), so the stored values compared
3232
* against are the ones the real repository writes.
33+
*
34+
* [#21978] The last two blocks pin the same doors on a stored row with NO
35+
* `checksum`: it is saved, deleted and published over through the version its
36+
* read serves, a stale version is still refused, a `null` parent still matches
37+
* it, the write stamps it, and a row WITH a `checksum` is judged as before.
3338
*/
3439

3540
import { createHmac } from 'node:crypto';
@@ -38,9 +43,11 @@ import {
3843
assertEngineDeleteDispatch,
3944
assertEngineFindOnePredicate,
4045
assertEngineUpdateDispatch,
46+
ConflictError,
4147
hashSpec,
4248
} from '@objectstack/metadata-core';
4349
import { ObjectStackProtocolImplementation } from './protocol.js';
50+
import { SysMetadataRepository } from './sys-metadata-repository.js';
4451

4552
const TEST_KEY = 'served-content-hash-test-key';
4653
/** A keyed digest with the provider contract's output shape, under a key this file holds. */
@@ -462,3 +469,227 @@ describe('[#21207] a change note that quotes a stored hash', () => {
462469
}
463470
});
464471
});
472+
473+
// ---------------------------------------------------------------------------
474+
// [#21978] A stored row with no `checksum`
475+
// ---------------------------------------------------------------------------
476+
//
477+
// The datasource admin door stored its rows with no `checksum` before it
478+
// stamped them, and such rows stay at rest (no backfill). The repository serves
479+
// a row like that as the hash of its stored body; its `put` / `delete` used to
480+
// judge the caller's parent against the raw column (`null`) instead, so every
481+
// save and delete of such a row through the metadata door answered 409 — the
482+
// unpinned (last-write-wins) ones included, since the door takes the parent
483+
// from the same read. The lock is type-agnostic, so this file's `view` row
484+
// stands in for the datasource one.
485+
486+
const VIEW_REF = { type: 'view', name: 'case_grid', org: ORG } as const;
487+
488+
/** Store the active row the way a writer that stamps no `checksum` did. */
489+
async function seedUnstamped(h: ReturnType<typeof makeEngine>, label = 'legacy', state = 'active'): Promise<void> {
490+
await h.engine.insert('sys_metadata', {
491+
type: 'view',
492+
name: 'case_grid',
493+
organization_id: ORG,
494+
package_id: null,
495+
state,
496+
metadata: JSON.stringify(viewBody(label)),
497+
version: 1,
498+
});
499+
}
500+
501+
function caseGridRow(h: ReturnType<typeof makeEngine>, state = 'active'): Row | undefined {
502+
return [...h.rows.values()].find((r) => r.name === 'case_grid' && r.state === state);
503+
}
504+
505+
function repoFor(h: ReturnType<typeof makeEngine>): SysMetadataRepository {
506+
return new SysMetadataRepository({ engine: h.engine, organizationId: ORG, orgLabel: ORG });
507+
}
508+
509+
/** The version the repository's own read serves for the row, keyed as a door hands it out. */
510+
async function servedToken(h: ReturnType<typeof makeEngine>): Promise<string> {
511+
const item = await repoFor(h).get(VIEW_REF as any);
512+
expect(item).not.toBeNull();
513+
return keyedDigest(item!.hash);
514+
}
515+
516+
describe('[#21978] a stored row with no checksum is written through the version its read serves', () => {
517+
it('save door: the served version is accepted as the parent, and the write stamps the row', async () => {
518+
const h = makeEngine();
519+
const p = new ObjectStackProtocolImplementation(h.engine);
520+
await seedUnstamped(h);
521+
expect(caseGridRow(h)?.checksum).toBeUndefined();
522+
523+
const token = await servedToken(h);
524+
// The read serves the hash of the stored body, as `put` would stamp it.
525+
expect(token).toBe(await keyedDigest(hashSpec(viewBody('legacy'), 'view')));
526+
527+
const saved: any = await p.saveMetaItem({ ...ref, item: viewBody('edited'), parentVersion: token } as any);
528+
expect(saved.success).toBe(true);
529+
expect(JSON.parse(caseGridRow(h)!.metadata).label).toBe('edited');
530+
// The write stamped the row: it now carries the checksum of its new body.
531+
expect(caseGridRow(h)!.checksum).toBe(hashSpec(viewBody('edited'), 'view'));
532+
expect(saved.version).toBe(await keyedDigest(caseGridRow(h)!.checksum!));
533+
});
534+
535+
it('reset door: the served version is accepted as the parent, and the row is removed', async () => {
536+
const h = makeEngine();
537+
const p = new ObjectStackProtocolImplementation(h.engine);
538+
await seedUnstamped(h);
539+
540+
const reset: any = await p.deleteMetaItem({ ...ref, parentVersion: await servedToken(h) } as any);
541+
expect(reset.success).toBe(true);
542+
expect(caseGridRow(h)).toBeUndefined();
543+
});
544+
545+
it('unpinned save and delete (last-write-wins) succeed; an identical re-save stamps the row too', async () => {
546+
const saveSide = makeEngine();
547+
const p = new ObjectStackProtocolImplementation(saveSide.engine);
548+
await seedUnstamped(saveSide);
549+
const saved: any = await p.saveMetaItem({ ...ref, item: viewBody('legacy') } as any);
550+
expect(saved.success).toBe(true);
551+
expect(caseGridRow(saveSide)!.checksum).toBe(hashSpec(viewBody('legacy'), 'view'));
552+
553+
const deleteSide = makeEngine();
554+
const q = new ObjectStackProtocolImplementation(deleteSide.engine);
555+
await seedUnstamped(deleteSide);
556+
const reset: any = await q.deleteMetaItem({ ...ref } as any);
557+
expect(reset.success).toBe(true);
558+
expect(caseGridRow(deleteSide)).toBeUndefined();
559+
});
560+
561+
it('a stale version is still refused (METADATA_CONFLICT / 409), the refusal names the served version, and that version is then accepted', async () => {
562+
const h = makeEngine();
563+
const p = new ObjectStackProtocolImplementation(h.engine);
564+
await seedUnstamped(h);
565+
const served = await servedToken(h);
566+
567+
const staleTokens = [
568+
await keyedDigest(hashSpec(viewBody('someone else'), 'view')),
569+
// The served hash in stored (unkeyed) form stays refused at the door.
570+
hashSpec(viewBody('legacy'), 'view'),
571+
];
572+
let saveRefusal: any;
573+
for (const token of staleTokens) {
574+
for (const [door, run] of [
575+
['save', () => p.saveMetaItem({ ...ref, item: viewBody('lost'), parentVersion: token } as any)],
576+
['delete', () => p.deleteMetaItem({ ...ref, parentVersion: token } as any)],
577+
] as const) {
578+
const refused = await rejection(run);
579+
expect(refused.code, `${door} with ${token}`).toBe('METADATA_CONFLICT');
580+
expect(refused.status, `${door} with ${token}`).toBe(409);
581+
expect(refused.actualHead, `${door} with ${token}`).toBe(served);
582+
if (door === 'save') saveRefusal = refused;
583+
}
584+
}
585+
// Nothing was written: the row is the one stored, still unstamped.
586+
expect(JSON.parse(caseGridRow(h)!.metadata).label).toBe('legacy');
587+
expect(caseGridRow(h)!.checksum).toBeUndefined();
588+
589+
const after: any = await p.saveMetaItem({ ...ref, item: viewBody('edited'), parentVersion: saveRefusal.actualHead } as any);
590+
expect(after.success).toBe(true);
591+
expect(caseGridRow(h)!.checksum).toBe(hashSpec(viewBody('edited'), 'view'));
592+
});
593+
594+
it('a writer passing null for such a row still succeeds (the stored-row migration hands the raw column on)', async () => {
595+
const h = makeEngine();
596+
const p = new ObjectStackProtocolImplementation(h.engine);
597+
await seedUnstamped(h);
598+
599+
// `migrateStoredMetadata`'s in-process spelling: `row.checksum ?? null`.
600+
const saved: any = await p.saveMetaItem({
601+
...ref,
602+
item: viewBody('migrated'),
603+
storedParentVersion: caseGridRow(h)!.checksum ?? null,
604+
} as any);
605+
expect(saved.success).toBe(true);
606+
expect(caseGridRow(h)!.checksum).toBe(hashSpec(viewBody('migrated'), 'view'));
607+
});
608+
609+
it('publish over such a row: the promotion takes the active row\'s served version as its parent', async () => {
610+
const h = makeEngine();
611+
const p = new ObjectStackProtocolImplementation(h.engine);
612+
await seedUnstamped(h);
613+
await p.saveMetaItem({ ...ref, item: viewBody('staged'), mode: 'draft' } as any);
614+
615+
const published: any = await p.publishMetaItem({ ...ref } as any);
616+
expect(published.version).toBe(await keyedDigest(hashSpec(viewBody('staged'), 'view')));
617+
expect(JSON.parse(caseGridRow(h)!.metadata).label).toBe('staged');
618+
expect(caseGridRow(h)!.checksum).toBe(hashSpec(viewBody('staged'), 'view'));
619+
expect(caseGridRow(h, 'draft')).toBeUndefined();
620+
});
621+
622+
it('the post-promotion drain removes a draft row stored with no checksum', async () => {
623+
const h = makeEngine();
624+
const p = new ObjectStackProtocolImplementation(h.engine);
625+
await seedUnstamped(h, 'staged', 'draft');
626+
627+
await p.publishMetaItem({ ...ref } as any);
628+
expect(JSON.parse(caseGridRow(h)!.metadata).label).toBe('staged');
629+
// The drain deletes by the draft's served version; judged against the raw
630+
// column it read as the benign "newer draft saved" race and survived.
631+
expect(caseGridRow(h, 'draft')).toBeUndefined();
632+
});
633+
});
634+
635+
describe('[#21978] the repository lock: a row with a checksum is judged exactly as before', () => {
636+
it('its stamp is its head: the hash of its body and a null parent are refused when the stamp differs', async () => {
637+
const h = makeEngine();
638+
const repo = repoFor(h);
639+
// A stamp that is not the hash of the bytes beside it (a row stamped
640+
// before its type's canonical form changed): the stamp, not the body,
641+
// is the version that row is served as.
642+
const stamp = hashSpec(viewBody('stamped earlier'), 'view');
643+
await h.engine.insert('sys_metadata', {
644+
type: 'view',
645+
name: 'case_grid',
646+
organization_id: ORG,
647+
package_id: null,
648+
state: 'active',
649+
metadata: JSON.stringify(viewBody('stamped')),
650+
checksum: stamp,
651+
});
652+
expect((await repo.get(VIEW_REF as any))!.hash).toBe(stamp);
653+
654+
for (const parent of [hashSpec(viewBody('stamped'), 'view'), null]) {
655+
const refused = await rejection(() =>
656+
repo.put(VIEW_REF as any, viewBody('next'), { parentVersion: parent, actor: null }));
657+
expect(refused).toBeInstanceOf(ConflictError);
658+
expect(refused.code).toBe('METADATA_CONFLICT');
659+
expect(refused.actualHead).toBe(stamp);
660+
}
661+
const refusedDelete = await rejection(() =>
662+
repo.delete(VIEW_REF as any, { parentVersion: hashSpec(viewBody('stamped'), 'view'), actor: null }));
663+
expect(refusedDelete).toBeInstanceOf(ConflictError);
664+
expect(refusedDelete.actualHead).toBe(stamp);
665+
expect(caseGridRow(h)!.checksum).toBe(stamp);
666+
667+
const written = await repo.put(VIEW_REF as any, viewBody('next'), { parentVersion: stamp, actor: null });
668+
expect(written.version).toBe(hashSpec(viewBody('next'), 'view'));
669+
});
670+
671+
it('a row with no checksum: null and its served version are accepted, anything else is refused with the served version as head', async () => {
672+
const h = makeEngine();
673+
const repo = repoFor(h);
674+
await seedUnstamped(h);
675+
const served = hashSpec(viewBody('legacy'), 'view');
676+
677+
const refused = await rejection(() =>
678+
repo.put(VIEW_REF as any, viewBody('next'), { parentVersion: hashSpec(viewBody('other'), 'view'), actor: null }));
679+
expect(refused).toBeInstanceOf(ConflictError);
680+
expect(refused.code).toBe('METADATA_CONFLICT');
681+
expect(refused.actualHead).toBe(served);
682+
683+
const viaNull = await repo.put(VIEW_REF as any, viewBody('legacy'), { parentVersion: null, actor: null });
684+
// An identical body still writes: the row had no stamp, and now has one.
685+
expect(viaNull.version).toBe(served);
686+
expect(caseGridRow(h)!.checksum).toBe(served);
687+
expect(h.historyRows).toHaveLength(1);
688+
689+
const again = makeEngine();
690+
await seedUnstamped(again);
691+
const removed = await repoFor(again).delete(VIEW_REF as any, { parentVersion: served, actor: null });
692+
expect(removed).toBeDefined();
693+
expect(caseGridRow(again)).toBeUndefined();
694+
});
695+
});

0 commit comments

Comments
 (0)