@lmzhen/dsh-evolution-state-storage 0.3.63 → 0.3.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -33,6 +33,99 @@ const REVIEW_STATE_TABLE = "review_state";
33
33
  const CURATOR_STATE_TABLE = "curator_state";
34
34
  const PENDING_TABLE = "pending";
35
35
  //#endregion
36
+ //#region lib/types/record-contract.js
37
+ /**
38
+ * Record contract of the three seam tables (P2-12/14/15/16/18, v19).
39
+ *
40
+ * The contract belongs to the SEAM, not to each provider: before this module
41
+ * the json provider gated writes with plain field predicates while the domain
42
+ * provider relied on its zod schemas, so the same consumer code behaved
43
+ * differently per medium — `transactCuratorState` skipped validation entirely
44
+ * on domain, unknown fields were stripped by zod but preserved by json,
45
+ * `args` was shared by reference on domain, and a non-cloneable payload
46
+ * poisoned every later read. Both providers now call these functions.
47
+ *
48
+ * UNKNOWN_FIELD_POLICY is `preserve`: a record written by a newer version must
49
+ * survive a round-trip through an older provider, so unknown fields are kept
50
+ * (json does this naturally; the domain schemas are `.loose()`).
51
+ *
52
+ * @module @lmzhen/dsh-evolution-state-storage/src/record-contract
53
+ */
54
+ /** Unknown fields survive a provider round-trip (json preserves by
55
+ * construction; the domain schemas are `.loose()`). */
56
+ const UNKNOWN_FIELD_POLICY = "preserve";
57
+ const isNonNegInt = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
58
+ const optionalString = (value) => value === void 0 || typeof value === "string";
59
+ const PENDING_KINDS = new Set([
60
+ "memory",
61
+ "skill",
62
+ "capability"
63
+ ]);
64
+ const PENDING_STATUSES = new Set([
65
+ "pending",
66
+ "executing",
67
+ "approved",
68
+ "rejected"
69
+ ]);
70
+ /**
71
+ * The write gate for one record. Both providers call this before persisting,
72
+ * so a record the other provider would refuse can never land.
73
+ * @param table - the seam table the record belongs to.
74
+ * @param record - the candidate record.
75
+ * @returns a human-readable issue, or null when the record is well-formed.
76
+ */
77
+ function recordIssue(table, record) {
78
+ if (record === null || typeof record !== "object" || Array.isArray(record)) return "record must be a plain object";
79
+ const value = record;
80
+ if (table === "review_state") {
81
+ if (!isNonNegInt(value.turnsSinceMemory)) return "turnsSinceMemory must be a non-negative integer";
82
+ if (!isNonNegInt(value.turnsSinceSkill)) return "turnsSinceSkill must be a non-negative integer";
83
+ if (!isNonNegInt(value.lastTurn)) return "lastTurn must be a non-negative integer";
84
+ return null;
85
+ }
86
+ if (table === "curator_state") {
87
+ if (value.schemaVersion !== void 0 && !isNonNegInt(value.schemaVersion)) return "schemaVersion must be a non-negative integer when present";
88
+ if (typeof value.lastRunAt !== "number" || !Number.isFinite(value.lastRunAt) || value.lastRunAt < 0) return "lastRunAt must be a finite non-negative number";
89
+ if (!isNonNegInt(value.runCount)) return "runCount must be a non-negative integer";
90
+ if (typeof value.lastSummary !== "string") return "lastSummary must be a string";
91
+ if (typeof value.paused !== "boolean") return "paused must be a boolean";
92
+ return null;
93
+ }
94
+ if (typeof value.id !== "string") return "id must be a string";
95
+ if (typeof value.kind !== "string" || !PENDING_KINDS.has(value.kind)) return "kind must be memory|skill|capability";
96
+ if (typeof value.summary !== "string") return "summary must be a string";
97
+ if (!Object.prototype.hasOwnProperty.call(value, "args")) return "args key is required (may be any cloneable value)";
98
+ if (typeof value.createdAt !== "string") return "createdAt must be a string";
99
+ if (typeof value.status !== "string" || !PENDING_STATUSES.has(value.status)) return "status must be pending|executing|approved|rejected";
100
+ for (const field of [
101
+ "resolvedAt",
102
+ "claimedBy",
103
+ "claimedAt",
104
+ "origin",
105
+ "sessionId"
106
+ ]) if (!optionalString(value[field])) return `${field} must be a string when present`;
107
+ return null;
108
+ }
109
+ /**
110
+ * P2-15 (v19): the value must survive the seam's copy discipline. A payload
111
+ * that cannot be structured-cloned (functions, symbols, class instances with
112
+ * private state) would make every later read throw far away from the write.
113
+ * @param record - the candidate record.
114
+ * @returns a human-readable issue, or null when the value is cloneable.
115
+ */
116
+ function assertCloneable(record) {
117
+ try {
118
+ structuredClone(record);
119
+ return null;
120
+ } catch (error) {
121
+ return `record is not structured-cloneable (${error instanceof Error ? error.message : String(error)})`;
122
+ }
123
+ }
124
+ /** Deep copy for every boundary crossing (read AND write). @param record - the value to copy. @returns an independent deep copy. */
125
+ function cloneRecord(record) {
126
+ return structuredClone(record);
127
+ }
128
+ //#endregion
36
129
  //#region lib/types/index.js
37
130
  /**
38
131
  * Provider seam for durable evolution state.
@@ -50,24 +143,46 @@ const canResolvePending = (status) => status === "pending" || status === "execut
50
143
  /** Releasing a claim on an executing record rolls it back to pending (a
51
144
  * runner FAILURE is retryable); other statuses pass through unchanged. */
52
145
  const releasedStatus = (status) => status === "executing" ? "pending" : status;
53
- /** P2-4 (v15): the live pending map/table is BOUNDED both providers keep at
54
- * most this many resolved (approved/rejected) records, dropping the oldest by
55
- * `resolvedAt`. Single source (the v15 audit found the bound was json-only,
56
- * so domain deployments grew the table without bound). The audit ARCHIVE
57
- * sidecar that json maintains beyond the cap stays json-specific (domain has
58
- * no sidecar facility) — declared in both READMEs. */
146
+ /** P2-4 (v15): the live pending map/table is BOUNDED on the RESOLVE path
147
+ * `tryResolvePending` drops the oldest resolved (approved/rejected) records by
148
+ * `resolvedAt` once more than this many exist. Single source (the v15 audit
149
+ * found the bound was json-only, so domain deployments grew the table without
150
+ * bound).
151
+ *
152
+ * C-6 (v18) contract precision: a direct `savePending` of an already-resolved
153
+ * record does NOT trigger eviction (the cap is maintained by the resolve
154
+ * operation, not by the writer), and pending/executing records are never
155
+ * trimmed. Callers that write resolved audit records themselves own that
156
+ * growth; the seam's resolve path is what keeps the table bounded.
157
+ * The audit ARCHIVE sidecar that json maintains beyond the cap stays
158
+ * json-specific (domain has no sidecar facility) — declared in both READMEs. */
59
159
  const PENDING_RESOLVED_CAP = 200;
60
160
  var EvolutionStateStorageRegistry = class extends Service {
61
161
  providers = /* @__PURE__ */ new Map();
162
+ /** C-7 (v18): per-name dispose, mirroring the evolution-io registry. */
163
+ disposals = /* @__PURE__ */ new Map();
62
164
  constructor(ctx) {
63
165
  super(ctx, "evolutionStateStorage");
64
166
  }
167
+ /** C-7 (v18): re-registering the IDENTICAL provider object is idempotent and
168
+ * returns the original dispose (HMR / re-mounted row); a DIFFERENT object
169
+ * under a registered name still fails loud. The dispose carries a generation
170
+ * guard so a stale handle cannot remove a newer registration. */
65
171
  registerProvider(provider) {
66
- if (this.providers.has(provider.name)) throw new Error(`evolution state storage provider "${provider.name}" already registered`);
67
- this.providers.set(provider.name, provider);
68
- return () => {
69
- if (this.providers.get(provider.name) === provider) this.providers.delete(provider.name);
172
+ const idempotent = this.providers.get(provider.name) === provider;
173
+ if (!idempotent && this.providers.has(provider.name)) throw new Error(`evolution state storage provider "${provider.name}" already registered`);
174
+ if (idempotent) {
175
+ const existing = this.disposals.get(provider.name);
176
+ if (existing !== void 0) return existing;
177
+ }
178
+ const dispose = () => {
179
+ if (this.disposals.get(provider.name) !== dispose) return;
180
+ this.providers.delete(provider.name);
181
+ this.disposals.delete(provider.name);
70
182
  };
183
+ this.providers.set(provider.name, provider);
184
+ this.disposals.set(provider.name, dispose);
185
+ return dispose;
71
186
  }
72
187
  /** S-07: whether ANY provider is registered. Lets the state
73
188
  * consumer precheck a pinned `provider` config at mount time (a typo fails
@@ -88,4 +203,4 @@ var EvolutionStateStorageRegistry = class extends Service {
88
203
  }
89
204
  };
90
205
  //#endregion
91
- export { CURATOR_STATE_FILE, CURATOR_STATE_KEY, CURATOR_STATE_TABLE, EvolutionStateStorageRegistry, EvolutionStateStorageRegistry as default, PENDING_ARCHIVE_BAK_FILE, PENDING_ARCHIVE_FILE, PENDING_LEGACY_FILE, PENDING_RESOLVED_CAP, PENDING_STATE_FILE, PENDING_TABLE, PROVIDER_DOMAIN, PROVIDER_JSON, REVIEW_STATE_FILE, REVIEW_STATE_TABLE, canClaimPending, canResolvePending, releasedStatus };
206
+ export { CURATOR_STATE_FILE, CURATOR_STATE_KEY, CURATOR_STATE_TABLE, EvolutionStateStorageRegistry, EvolutionStateStorageRegistry as default, PENDING_ARCHIVE_BAK_FILE, PENDING_ARCHIVE_FILE, PENDING_LEGACY_FILE, PENDING_RESOLVED_CAP, PENDING_STATE_FILE, PENDING_TABLE, PROVIDER_DOMAIN, PROVIDER_JSON, REVIEW_STATE_FILE, REVIEW_STATE_TABLE, UNKNOWN_FIELD_POLICY, assertCloneable, canClaimPending, canResolvePending, cloneRecord, recordIssue, releasedStatus };
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { Context, Service } from '@deepseek-ai/cordis';
10
10
  export * from './constants.ts';
11
+ export * from './record-contract.ts';
11
12
  /** 0.3.17 (S3.5, D-4): 'skill_batch' removed — nothing ever created one
12
13
  * (dead enum member); the historic value, if it ever reached disk, is read as
13
14
  * an unknown kind by consumers rather than minted here. */
@@ -24,12 +25,19 @@ export declare const canResolvePending: (status: PendingStatus) => boolean;
24
25
  /** Releasing a claim on an executing record rolls it back to pending (a
25
26
  * runner FAILURE is retryable); other statuses pass through unchanged. */
26
27
  export declare const releasedStatus: (status: PendingStatus) => PendingStatus;
27
- /** P2-4 (v15): the live pending map/table is BOUNDED both providers keep at
28
- * most this many resolved (approved/rejected) records, dropping the oldest by
29
- * `resolvedAt`. Single source (the v15 audit found the bound was json-only,
30
- * so domain deployments grew the table without bound). The audit ARCHIVE
31
- * sidecar that json maintains beyond the cap stays json-specific (domain has
32
- * no sidecar facility) — declared in both READMEs. */
28
+ /** P2-4 (v15): the live pending map/table is BOUNDED on the RESOLVE path
29
+ * `tryResolvePending` drops the oldest resolved (approved/rejected) records by
30
+ * `resolvedAt` once more than this many exist. Single source (the v15 audit
31
+ * found the bound was json-only, so domain deployments grew the table without
32
+ * bound).
33
+ *
34
+ * C-6 (v18) contract precision: a direct `savePending` of an already-resolved
35
+ * record does NOT trigger eviction (the cap is maintained by the resolve
36
+ * operation, not by the writer), and pending/executing records are never
37
+ * trimmed. Callers that write resolved audit records themselves own that
38
+ * growth; the seam's resolve path is what keeps the table bounded.
39
+ * The audit ARCHIVE sidecar that json maintains beyond the cap stays
40
+ * json-specific (domain has no sidecar facility) — declared in both READMEs. */
33
41
  export declare const PENDING_RESOLVED_CAP = 200;
34
42
  /**
35
43
  * Claim lifecycle (S3.3): pending →(claim)→ executing →(resolve)→ approved/rejected.
@@ -108,7 +116,13 @@ declare module '@deepseek-ai/cordis' {
108
116
  }
109
117
  export declare class EvolutionStateStorageRegistry extends Service {
110
118
  private readonly providers;
119
+ /** C-7 (v18): per-name dispose, mirroring the evolution-io registry. */
120
+ private readonly disposals;
111
121
  constructor(ctx: Context);
122
+ /** C-7 (v18): re-registering the IDENTICAL provider object is idempotent and
123
+ * returns the original dispose (HMR / re-mounted row); a DIFFERENT object
124
+ * under a registered name still fails loud. The dispose carries a generation
125
+ * guard so a stale handle cannot remove a newer registration. */
112
126
  registerProvider(provider: EvolutionStateStorage): () => void;
113
127
  /** S-07: whether ANY provider is registered. Lets the state
114
128
  * consumer precheck a pinned `provider` config at mount time (a typo fails
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Record contract of the three seam tables (P2-12/14/15/16/18, v19).
3
+ *
4
+ * The contract belongs to the SEAM, not to each provider: before this module
5
+ * the json provider gated writes with plain field predicates while the domain
6
+ * provider relied on its zod schemas, so the same consumer code behaved
7
+ * differently per medium — `transactCuratorState` skipped validation entirely
8
+ * on domain, unknown fields were stripped by zod but preserved by json,
9
+ * `args` was shared by reference on domain, and a non-cloneable payload
10
+ * poisoned every later read. Both providers now call these functions.
11
+ *
12
+ * UNKNOWN_FIELD_POLICY is `preserve`: a record written by a newer version must
13
+ * survive a round-trip through an older provider, so unknown fields are kept
14
+ * (json does this naturally; the domain schemas are `.loose()`).
15
+ *
16
+ * @module @lmzhen/dsh-evolution-state-storage/src/record-contract
17
+ */
18
+ import { CURATOR_STATE_TABLE, PENDING_TABLE, REVIEW_STATE_TABLE } from './constants.ts';
19
+ /** The three seam tables a record can belong to. */
20
+ export type SeamRecordTable = typeof REVIEW_STATE_TABLE | typeof CURATOR_STATE_TABLE | typeof PENDING_TABLE;
21
+ /** Unknown fields survive a provider round-trip (json preserves by
22
+ * construction; the domain schemas are `.loose()`). */
23
+ export declare const UNKNOWN_FIELD_POLICY: "preserve";
24
+ /**
25
+ * The write gate for one record. Both providers call this before persisting,
26
+ * so a record the other provider would refuse can never land.
27
+ * @param table - the seam table the record belongs to.
28
+ * @param record - the candidate record.
29
+ * @returns a human-readable issue, or null when the record is well-formed.
30
+ */
31
+ export declare function recordIssue(table: SeamRecordTable, record: unknown): string | null;
32
+ /**
33
+ * P2-15 (v19): the value must survive the seam's copy discipline. A payload
34
+ * that cannot be structured-cloned (functions, symbols, class instances with
35
+ * private state) would make every later read throw far away from the write.
36
+ * @param record - the candidate record.
37
+ * @returns a human-readable issue, or null when the value is cloneable.
38
+ */
39
+ export declare function assertCloneable(record: unknown): string | null;
40
+ /** Deep copy for every boundary crossing (read AND write). @param record - the value to copy. @returns an independent deep copy. */
41
+ export declare function cloneRecord<T>(record: T): T;
42
+ //# sourceMappingURL=record-contract.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-state-storage",
3
3
  "description": "Provider registry seam for durable evolution state (community build)",
4
- "version": "0.3.63",
4
+ "version": "0.3.65",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },