Skip to content

Commit 6ee42b8

Browse files
os-zhuangCopilot
andcommitted
fix(objectql): align SysMetadataRepository with existing sys_metadata schema (ADR-0008 PR-10d.2)
Discovery from the PR-10d.2 audit: the production sys_metadata table (packages/platform-objects/src/metadata/sys-metadata.object.ts) already has a `checksum: text(64)` column — exactly the right shape for sha256 hex — and a `version: number` column for monotonic increments. The draft SysMetadataRepository was writing a non-existent `_hash` column, which would silently drop or error against real drivers. Changes: - SysMetadataRepository now reads/writes `checksum` instead of `_hash` - documented schema mapping in the file header: body → metadata · hash → checksum · version int → version - updated the in-memory test fakes (both sys-metadata-repository.test.ts and layered-overlay-integration.test.ts) to mirror the new column name - extended the dry-run probe (PR-10d.1) with two new checks: * checksum_missing — warning, eligible for lazy backfill * checksum_drift — error, stored checksum disagrees with hashSpec(body) - 3 new probe tests + report fields (needsBackfill, checksumDrift) Result: zero DDL needed for PR-10d.3 cutover. Legacy rows with NULL checksum will be backfilled on first put(). Objectql suite: 325/325 green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1e625b8 commit 6ee42b8

6 files changed

Lines changed: 138 additions & 21 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): SysMetadataRepository reuses the existing `checksum` column
6+
instead of writing a non-existent `_hash` column (ADR-0008 PR-10d.2). The
7+
production `sys_metadata` schema (`packages/platform-objects`) already
8+
ships with `checksum: text(64)` — perfect for sha256 hex — and `version:
9+
number` for the monotonic counter. No DDL migration is required for
10+
PR-10d.3 cutover; legacy rows with NULL checksum will be lazily
11+
backfilled on first put().
12+
13+
Also extends the PR-10d.1 dry-run probe with two new checks
14+
(`checksum_missing` warning, `checksum_drift` error) and three additional
15+
tests, taking objectql to 325/325 green.

packages/objectql/scripts/dry-run-hash-compat.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export interface LegacyMetadataRow {
3838
metadata?: string | null;
3939
state?: string;
4040
version?: number | null;
41+
/**
42+
* Legacy `checksum` column (sha256 hex). PR-10d.2 reuses this column
43+
* as the optimistic-locking token; pre-PR-10d rows have `null`.
44+
*/
45+
checksum?: string | null;
4146
[k: string]: unknown;
4247
}
4348

@@ -49,7 +54,9 @@ export interface RowFinding {
4954
| 'non_object_body'
5055
| 'unstable_hash'
5156
| 'missing_metadata'
52-
| 'duplicate_overlay_key';
57+
| 'duplicate_overlay_key'
58+
| 'checksum_drift'
59+
| 'checksum_missing';
5360
detail: string;
5461
}
5562

@@ -59,6 +66,10 @@ export interface DryRunReport {
5966
findings: RowFinding[];
6067
typeDistribution: Record<string, number>;
6168
duplicateKeys: string[];
69+
/** Rows where `checksum` is NULL — eligible for lazy backfill on first write. */
70+
needsBackfill: number;
71+
/** Rows where stored `checksum` disagrees with recomputed `hashSpec(body)`. */
72+
checksumDrift: number;
6273
compatible: boolean;
6374
}
6475

@@ -71,6 +82,8 @@ export function runDryRun(rows: LegacyMetadataRow[]): DryRunReport {
7182
const seen = new Map<string, LegacyMetadataRow>();
7283
const duplicateKeys = new Set<string>();
7384
let okRows = 0;
85+
let needsBackfill = 0;
86+
let checksumDrift = 0;
7487

7588
for (const row of rows) {
7689
const tag = {
@@ -146,7 +159,31 @@ export function runDryRun(rows: LegacyMetadataRow[]): DryRunReport {
146159
continue;
147160
}
148161

149-
// 5. Duplicate (type, name, organization_id) — would break the unique
162+
// 5. Checksum reconciliation (PR-10d.2). The repository uses the
163+
// `checksum` column as the optimistic-lock token. Pre-PR-10d
164+
// rows have `null`. Eligible for lazy backfill on next write.
165+
// If non-null, it MUST equal hashSpec(body) — otherwise legacy
166+
// code wrote a checksum we can't reproduce.
167+
if (row.checksum == null) {
168+
needsBackfill += 1;
169+
findings.push({
170+
row: tag,
171+
severity: 'warning',
172+
code: 'checksum_missing',
173+
detail: 'legacy row with NULL checksum — will be backfilled on first put()',
174+
});
175+
} else if (row.checksum !== h1) {
176+
checksumDrift += 1;
177+
findings.push({
178+
row: tag,
179+
severity: 'error',
180+
code: 'checksum_drift',
181+
detail: `stored checksum ${row.checksum} != recomputed ${h1}`,
182+
});
183+
continue;
184+
}
185+
186+
// 6. Duplicate (type, name, organization_id) — would break the unique
150187
// overlay invariant. Only count active rows.
151188
if (row.state === 'active' && row.type && row.name) {
152189
const key = `${row.type}|${row.name}|${row.organization_id ?? '__env__'}`;
@@ -164,7 +201,7 @@ export function runDryRun(rows: LegacyMetadataRow[]): DryRunReport {
164201
seen.set(key, row);
165202
}
166203

167-
// 6. Distribution.
204+
// 7. Distribution.
168205
if (row.type) {
169206
typeDistribution[row.type] = (typeDistribution[row.type] ?? 0) + 1;
170207
}
@@ -177,6 +214,8 @@ export function runDryRun(rows: LegacyMetadataRow[]): DryRunReport {
177214
findings,
178215
typeDistribution,
179216
duplicateKeys: Array.from(duplicateKeys),
217+
needsBackfill,
218+
checksumDrift,
180219
compatible: findings.every((f) => f.severity !== 'error'),
181220
};
182221
}
@@ -191,6 +230,8 @@ export function formatReport(report: DryRunReport): string {
191230
lines.push(`Total rows: ${report.totalRows}`);
192231
lines.push(`OK rows: ${report.okRows}`);
193232
lines.push(`Findings: ${report.findings.length}`);
233+
lines.push(`Needs backfill: ${report.needsBackfill} (NULL checksum)`);
234+
lines.push(`Checksum drift: ${report.checksumDrift}`);
194235
lines.push(`Compatible: ${report.compatible ? 'YES ✅' : 'NO ❌'}`);
195236
lines.push('');
196237
lines.push('## Type distribution');

packages/objectql/src/dry-run-hash-compat.test.ts

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, expect, it } from 'vitest';
4+
import { hashSpec } from '@objectstack/metadata-core';
45
import { runDryRun, type LegacyMetadataRow } from '../scripts/dry-run-hash-compat';
56

67
/**
@@ -13,13 +14,8 @@ import { runDryRun, type LegacyMetadataRow } from '../scripts/dry-run-hash-compa
1314
* or dashboard body. Shapes vary in nesting, key order, locale strings, etc.
1415
*/
1516

16-
const validView = (overrides: Record<string, unknown> = {}) => ({
17-
id: 'r1',
18-
type: 'view',
19-
name: 'case_grid',
20-
organization_id: 'org_alpha',
21-
state: 'active',
22-
metadata: JSON.stringify({
17+
const validView = (overrides: Record<string, unknown> = {}): LegacyMetadataRow => {
18+
const body = {
2319
name: 'case_grid',
2420
type: 'grid',
2521
label: 'Cases',
@@ -28,8 +24,17 @@ const validView = (overrides: Record<string, unknown> = {}) => ({
2824
{ field: 'title', width: 240 },
2925
],
3026
...overrides,
31-
}),
32-
});
27+
};
28+
return {
29+
id: 'r1',
30+
type: 'view',
31+
name: 'case_grid',
32+
organization_id: 'org_alpha',
33+
state: 'active',
34+
metadata: JSON.stringify(body),
35+
checksum: hashSpec(body),
36+
};
37+
};
3338

3439
describe('runDryRun — happy path', () => {
3540
it('returns compatible:true for a clean snapshot', () => {
@@ -57,21 +62,25 @@ describe('runDryRun — happy path', () => {
5762
// Production code does `JSON.stringify(item)` which preserves insertion
5863
// order. Different writers produce different orderings. canonicalize()
5964
// must absorb this.
65+
const bodyA = { z: 1, a: 2, m: { y: 9, b: 8 } };
66+
const bodyB = { a: 2, m: { b: 8, y: 9 }, z: 1 };
6067
const a: LegacyMetadataRow = {
6168
id: 'r1',
6269
type: 'view',
6370
name: 'case_grid',
6471
organization_id: 'org_alpha',
6572
state: 'active',
66-
metadata: JSON.stringify({ z: 1, a: 2, m: { y: 9, b: 8 } }),
73+
metadata: JSON.stringify(bodyA),
74+
checksum: hashSpec(bodyA),
6775
};
6876
const b: LegacyMetadataRow = {
6977
id: 'r2',
7078
type: 'view',
7179
name: 'case_kanban',
7280
organization_id: 'org_alpha',
7381
state: 'active',
74-
metadata: JSON.stringify({ a: 2, m: { b: 8, y: 9 }, z: 1 }),
82+
metadata: JSON.stringify(bodyB),
83+
checksum: hashSpec(bodyB),
7584
};
7685
const report = runDryRun([a, b]);
7786
expect(report.compatible).toBe(true);
@@ -189,12 +198,55 @@ describe('runDryRun — error classification', () => {
189198
});
190199
});
191200

201+
describe('runDryRun — checksum reconciliation (PR-10d.2)', () => {
202+
it('warns (does not fail) when legacy row has NULL checksum', () => {
203+
const report = runDryRun([
204+
{ ...validView(), checksum: null },
205+
]);
206+
expect(report.compatible).toBe(true); // warning, not error
207+
expect(report.needsBackfill).toBe(1);
208+
expect(report.findings).toHaveLength(1);
209+
expect(report.findings[0].code).toBe('checksum_missing');
210+
expect(report.findings[0].severity).toBe('warning');
211+
});
212+
213+
it('accepts row whose stored checksum matches recomputed hashSpec(body)', () => {
214+
const body = { name: 'case_grid', type: 'grid', label: 'Cases' };
215+
const report = runDryRun([
216+
{
217+
id: 'r1',
218+
type: 'view',
219+
name: 'case_grid',
220+
organization_id: 'org_alpha',
221+
state: 'active',
222+
metadata: JSON.stringify(body),
223+
checksum: hashSpec(body),
224+
},
225+
]);
226+
expect(report.compatible).toBe(true);
227+
expect(report.needsBackfill).toBe(0);
228+
expect(report.checksumDrift).toBe(0);
229+
expect(report.findings).toHaveLength(0);
230+
});
231+
232+
it('flags drift when stored checksum disagrees with recomputed hash', () => {
233+
const report = runDryRun([
234+
{ ...validView(), checksum: 'sha256:deadbeef' },
235+
]);
236+
expect(report.compatible).toBe(false);
237+
expect(report.checksumDrift).toBe(1);
238+
expect(report.findings[0].code).toBe('checksum_drift');
239+
});
240+
});
241+
192242
describe('runDryRun — boundary conditions', () => {
193243
it('handles empty snapshot', () => {
194244
const report = runDryRun([]);
195245
expect(report.compatible).toBe(true);
196246
expect(report.totalRows).toBe(0);
197247
expect(report.okRows).toBe(0);
248+
expect(report.needsBackfill).toBe(0);
249+
expect(report.checksumDrift).toBe(0);
198250
});
199251

200252
it('handles row with deeply nested body', () => {

packages/objectql/src/layered-overlay-integration.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ interface Row {
2626
name: string;
2727
organization_id: string | null;
2828
metadata: string;
29-
_hash: string;
29+
checksum: string;
3030
state: string;
3131
version: number;
3232
}

packages/objectql/src/sys-metadata-repository.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ interface Row {
1818
name: string;
1919
organization_id: string | null;
2020
metadata: string;
21-
_hash: string;
21+
checksum: string;
2222
state: string;
2323
version: number;
2424
created_at: string;

packages/objectql/src/sys-metadata-repository.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,16 @@
2323
* table yet; LISTEN/NOTIFY plumbing comes with PostgresRepository)
2424
* - history() — emits empty AsyncIterable
2525
* - branch ops (fork/merge)
26-
* - hashSpec backfill for legacy rows missing `_hash`
26+
* - hashSpec backfill for legacy rows missing `checksum`
27+
*
28+
* Schema mapping (ADR-0008 PR-10d.2):
29+
* Repository concept sys_metadata column
30+
* ─────────────────────── ───────────────────
31+
* body → metadata (JSON string)
32+
* hash (sha256) → checksum (text(64))
33+
* monotonic version int → version (number)
34+
* org isolation → organization_id (lookup)
35+
* actor → updated_by (lookup, optional)
2736
*
2837
* Composition: PR-10c will compose
2938
* `LayeredRepository([FileSystemRepository, SysMetadataRepository])`
@@ -150,7 +159,7 @@ export class SysMetadataRepository implements MetadataRepository {
150159
const existing = await this.engine.findOne('sys_metadata', {
151160
where: this.whereFor(ref),
152161
});
153-
const existingHash: string | null = existing?._hash ?? null;
162+
const existingHash: string | null = existing?.checksum ?? null;
154163
if (opts.parentVersion !== existingHash) {
155164
throw new ConflictError(this.fullRef(ref), opts.parentVersion, existingHash);
156165
}
@@ -170,7 +179,7 @@ export class SysMetadataRepository implements MetadataRepository {
170179
name: ref.name,
171180
organization_id: this.organizationId,
172181
metadata: JSON.stringify(body),
173-
_hash: version,
182+
checksum: version,
174183
state: 'active',
175184
version: (existing?.version ?? 0) + 1,
176185
updated_at: now,
@@ -223,7 +232,7 @@ export class SysMetadataRepository implements MetadataRepository {
223232
// (actual HEAD is null but caller supplied a non-null parentVersion).
224233
throw new ConflictError(this.fullRef(ref), opts.parentVersion, null);
225234
}
226-
const existingHash: string | null = existing._hash ?? null;
235+
const existingHash: string | null = existing.checksum ?? null;
227236
if (opts.parentVersion !== existingHash) {
228237
throw new ConflictError(this.fullRef(ref), opts.parentVersion, existingHash);
229238
}
@@ -396,7 +405,7 @@ export class SysMetadataRepository implements MetadataRepository {
396405
private rowToItem(ref: Pick<MetaRef, 'type' | 'name'>, row: any): MetadataItem {
397406
const body: Record<string, unknown> =
398407
typeof row.metadata === 'string' ? JSON.parse(row.metadata) : (row.metadata ?? {});
399-
const hash: string = row._hash ?? hashSpec(body);
408+
const hash: string = row.checksum ?? hashSpec(body);
400409
return {
401410
ref: this.fullRef(ref),
402411
body,

0 commit comments

Comments
 (0)