Repository navigation
Commit 3566c08
fix(metadata-protocol): seed replay compares time, datetime and date fields in their stored form (#24617)
Fixes #24562
Clause-②: no
## What changed
`SeedLoaderService.isNoOpReplay`
(`packages/metadata-protocol/src/seed-loader.ts`) now compares a field
the object declares `time`, `datetime` or `date` in its **stored form**.
Both sides go through `temporalStorageForm` (`@objectstack/core`), the
rule `SqlDriver` and `InMemoryDriver` already apply on write (ADR-0053).
Every other field keeps the comparison it had.
- The kinds come from the object definition `loadDataset` already
resolves (`temporalFieldKinds`, next to `seedValuesInStoredForm`). They
are passed explicitly to both write paths that call `isNoOpReplay`: the
batched `decideWriteAction` (all three call sites) and the sequential
`writeRecord` used by self-referencing objects. The new parameter is
required, so no call site can leave it out.
- "Any doubt reads as changed" still holds. `temporalStorageForm`
returns a value it cannot read unchanged, so an unparseable value equals
only its own identical spelling. A non-text result (an Invalid Date, a
structure) never compares equal.
- Nothing a seed may declare changes, and nothing any write stores
changes. This is a comparison inside the replay only (the triage
ruling). No consumer-side tolerance was added anywhere.
## Measured: mechanism before and after, on a real driver
This used a scratch harness (not committed): a real `ObjectQL` and
`SqlDriver` (`better-sqlite3`) on one SQLite **file** DB. Each boot is a
new driver, engine and loader. Results:
```
scratch object (time / datetime / date), seed spelled short
stored after boot 1: '09:15' -> '09:15:00'; '2026-06-17T14:30:00Z' -> '2026-06-17T14:30:00.000Z';
date written '2026-06-17T00:00:00Z' -> '2026-06-17'
base 2667e24 boot 2 (same seed): updated=3 skipped=1 (short-time, short-datetime, iso-date rewritten)
fix 09b324c boot 2 (same seed): updated=0 skipped=4
fix 09b324c boot 3 (time+instant changed): updated=2 skipped=2 (exactly the two changed rows)
showcase: every examples/app-showcase object definition + every ShowcaseSeedData dataset on a showcase-owned object (127 rows)
base 2667e24 boot 2: engine.update on 5 rows -- Specimen — Full, INV-1001 · Consulting hours, INV-1004 · Consulting hours,
and their two parent invoices (summary recompute)
per-key causes: lines -> service_start '09:15' vs '09:15:00' (and '13:40');
Specimen — Full -> f_datetime, f_time AND f_password
fix 09b324c boot 2: engine.update on 1 row -- Specimen — Full, f_password only
```
The `date` kind has the same spelling gap (an instant written to a
`date` field), so it is covered too.
**What this PR does not fix:** Specimen — Full still gets a new
`updated_at` on every boot. The cause is a second, independent
mechanism. The loader reads existing rows through `engine.find`, which
masks a `password` field to `••••••••` in every context, so the seed's
plaintext `hunter2` never equals the stored value. It is not a
temporal-spelling issue. Fixing it means deciding how a replay compares
a credential it cannot read, so it is reported to the seat as a separate
finding and left out of this PR. So of the card's 5 churning showcase
rows, 4 stop churning here; the fifth keeps churning until that finding
has its own fix.
The full `objectstack dev` two-boot on the stock showcase is **NOT
MEASURED**: the CLI's build closure is 63 packages, which is too much
for this container's shared verify lock. The harness above loads the
showcase's own object definitions and seed data through the same
`SeedLoaderService`, `ObjectQL` and `SqlDriver`, and on base it
reproduces the card's exact 5 rows.
## Tests
- New pins in
`packages/metadata-protocol/src/seed-loader-replay.test.ts`, reusing the
file's pinned faithful engine double. Its `insert` and `update` are
overridden to write temporal fields in the driver's stored form and to
stamp `updated_at`. Each case asserts which write path ran (a
`writeRecord` spy):
- replaying the unchanged short-spelled seed (`'09:15'`,
`'…T14:30:00Z'`, zone-naive `'2026-06-17 14:30'`, an instant on a `date`
field, plus a stored-spelling control) issues **no update** and leaves
the store, `updated_at` included, deep-equal. 4 cases: batched and
sequential paths, modes `upsert` and `update`;
- positive control: a changed wall clock, instant and day are each
rewritten, **only** their rows, and stored in the new value's form (2
cases);
- an unparseable time and datetime still read as changed (2 cases).
- `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2 src/seed-loader-replay.test.ts`: 13 passed (5 existing +
8 new).
- `pnpm --filter @objectstack/metadata-protocol typecheck && pnpm
--filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`
at `7cad49c76` (the final head, after merging `origin/main` `5a871e4e4`
and rebuilding the package closure): typecheck exit 0; 248 files passed,
3 skipped; 20731 tests passed, 19 skipped. The package `tsconfig`
includes `src/**/*`; `--listFiles` counts the test file.
- **Ablation** (one-shot, committed fix at `09b324c5b`, through
`scripts/ablation-replace.mjs`; tests import `./seed-loader.js` from
source, so no `dist/` is involved). The `if (kind)` stored-form block
was deleted: anchor 1 to 0, blob `ee3eb38f87de` to `206fd67db529`.
Predicted direction: all 8 new cases red, 5 existing green. Observed:
**8 failed, 5 passed**. The replay negatives failed with "expected not
to be called, called 4 times" (the 4 short-spelled rows); the control
and unparseable cases failed by rewriting extra rows. Restored: blob
equals HEAD `ee3eb38f87de`, `git diff HEAD` empty.
## Gates
Locally, the derived set from `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack`, run at the final head
`7cad49c76`. `--ran` reconciliation: **64 derived, 64 run, 0
NOT-MEASURED, 0 UNRUN**, every one exit 0.
- That includes `check:engine-double-contract` (OK, 1031 pinned),
`check:durability-log-level`, `check:nul-bytes`,
`check:type-check-debt`, `check:dual-build-cjs-loads` (111 require entry
points load), `check:test-source-alias`,
`check:cross-package-test-inputs`, `check:published-files` and
`check-adr-0087-registration` (1 non-breaking changeset).
- `check-plugin-teardown-shape --self-test` needs a positive-control
commit that lay outside this shallow clone. That commit was fetched with
`--depth=1`, and the self-test then passed with 48 cases.
- `test:repo` and the whole-repo `lint`: not run locally, declared to
CI.
## Acceptance notes
- Changeset: `.changeset/24562-seed-replay-time-equality.md`,
`@objectstack/metadata-protocol` `patch`.
- `seedValuesInStoredForm` (a `Date` resolved by a CEL envelope, put in
its stored form before the write) is untouched. The new helper reads the
definition's field types the same way.
- `@objectstack/metadata-protocol` has no dependency on a driver
package, so the pin uses the existing double with the drivers' own
storage rule, not a real driver. The real-driver reading is the
measurement above. It was a one-shot and is not a permanent test, by the
dispatch file surface.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent c395405 commit 3566c08
3 files changed
Lines changed: 245 additions & 15 deletions
File tree
- .changeset
- packages/metadata-protocol/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| 7 | + | |
7 | 8 | | |
8 | 9 | | |
9 | 10 | | |
| |||
322 | 323 | | |
323 | 324 | | |
324 | 325 | | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
0 commit comments