spec.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. /**
  2. * The projection-cache domain declaration: one `sessions` table keyed by
  3. * {@link SessionId}, each record the full projection checkpoint for one
  4. * session (`key → {ver, seq, val}` rows). The spec object is the single
  5. * source of the domain's identity, version, layout, and record schema; the
  6. * storage-domain routing decides the medium (the shipped composition's json
  7. * backend stores the domain `per-record`: one document per session under
  8. * `<root>/session_projcache/sessions/`, so a checkpoint write rewrites one
  9. * session's document instead of the whole unit).
  10. * @module @deepseek-ai/dsh-session-projection-cache/src/spec
  11. */
  12. import { z } from 'zod'
  13. import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
  14. import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session'
  15. import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
  16. /**
  17. * One persisted checkpoint row (the RFC's `(sessionId, key, ver, seq, val)`
  18. * minus the two record keys). `val` is the unit's internal state — plain
  19. * JSON by the unit contract; `z.json()` enforces that at the durable
  20. * boundary. A row is never wrong, only possibly stale: `seq` says exactly
  21. * how stale, and a `ver` mismatch against the live unit's `stateVersion`
  22. * discards it at read time (never a migration).
  23. */
  24. export const checkpointRow = z.object({
  25. ver: z.number().int().nonnegative(),
  26. seq: z.number().int().gte(-1).transform((value): SessionSeqCursor =>
  27. value === -1 ? -1 : SessionSeq(value)),
  28. val: z.json(),
  29. })
  30. /**
  31. * The stored-log identity a record is bound to: the immutable header fields
  32. * that distinguish one session lifecycle from another under the same id. A
  33. * session id names a slot, not a lifecycle — a deleted-then-recreated id, or
  34. * a persistence root swapped under a surviving cache, would otherwise let an
  35. * old record pass every watermark check and seed state folded from an
  36. * unrelated log. Reads validate this against the live header (listing) or
  37. * the stored header (cold read) before accepting any record.
  38. *
  39. * The format and lineage fields are optional because records admitted through
  40. * `compatibleVersions` predate them. The reader (`identityMatches`) refuses an
  41. * absent format generation because no current Session log can prove that
  42. * record's fold semantics. It interprets absent lineage as unseeded only after
  43. * the format generation matches. Current-version writes always store all three
  44. * fields.
  45. */
  46. export const checkpointIdentity = z.object({
  47. formatVersion: z.number().int().nonnegative().optional(),
  48. createdAt: z.number().int().nonnegative(),
  49. cwd: z.string().optional(),
  50. isSeeded: z.boolean().optional(),
  51. inheritedEventCount: z.number().int().nonnegative().transform(SessionLogOffset).optional(),
  52. })
  53. /** The identity fields a record is bound to, inferred from {@link checkpointIdentity}. */
  54. export type CheckpointIdentity = z.infer<typeof checkpointIdentity>
  55. /**
  56. * One session's stored record: the log identity it was folded from plus its
  57. * checkpoint rows keyed by projection key. The whole record is replaced on
  58. * every write (whole-value discipline — the registry checkpoint is always
  59. * the complete per-session cut).
  60. */
  61. export const checkpointRecord = z.object({
  62. identity: checkpointIdentity,
  63. rows: z.record(z.string(), checkpointRow),
  64. })
  65. /** One stored per-session checkpoint record, inferred from {@link checkpointRecord}. */
  66. export type CheckpointRecord = z.infer<typeof checkpointRecord>
  67. /**
  68. * The session-projcache domain spec. The `per-record` layout scopes version
  69. * bumps per session: after a bump, a stale session document is discarded on
  70. * open (cache semantics — a stale or unreadable cache costs a longer tail
  71. * replay, never a wrong value) while the rest of the domain stays usable,
  72. * instead of rejecting the whole medium. The `compatibleVersions` entries
  73. * keep structurally valid predecessor records available for a later current
  74. * checkpoint rewrite. Records without `formatVersion` remain unusable as fold
  75. * shortcuts because they cannot prove which Session event semantics produced
  76. * their rows; the per-record version map and disposition live in the read-compat Agent Note
  77. * (.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md).
  78. * The per-row `ver` guard and the identity match still discard anything the
  79. * current fold semantics cannot vouch for.
  80. *
  81. * A lifecycle-matching predecessor may still expose its version-compatible
  82. * title through the cache service's listing-only hint; this never relaxes the
  83. * format requirement for hydration or another fold shortcut.
  84. *
  85. * `invalidRecords: 'backup-and-skip'`: a stored record that fails the schema
  86. * anyway is disposable derived data, so it must never cost the boot — the
  87. * domain layer moves the document aside as `<key>.json.bak.<stamp>`, logs
  88. * the concrete validation failure, and serves the session as uncached (a
  89. * cold read rebuilds and rewrites it).
  90. */
  91. export const projectionCacheDomainSpec = defineDomain({
  92. name: 'session_projcache',
  93. version: 7,
  94. compatibleVersions: [3, 4, 5, 6],
  95. invalidRecords: 'backup-and-skip',
  96. layout: 'per-record',
  97. tables: { sessions: domainTable<SessionId, CheckpointRecord>(checkpointRecord) },
  98. })