@rulvar/store-conformance 1.47.0 → 1.49.0

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/dist/index.d.ts CHANGED
@@ -61,6 +61,223 @@ interface FencedTranscriptsFixture {
61
61
  }
62
62
  declare function fencedTranscriptsConformance(mk: StoreFactory<FencedTranscriptsFixture>): ConformanceSuite;
63
63
  //#endregion
64
+ //#region src/multi-process-soak.d.ts
65
+ /** Accepted-mutation surfaces of the soaked run (serial-history members). */
66
+ type SoakAcceptSurface = "marker" | "append" | "meta" | "blob-put" | "blob-delete";
67
+ /** Surfaces of the stale-probe sweep; every one must reject typed. */
68
+ type SoakProbeSurface = "append" | "meta" | "blob-put" | "blob-delete" | "run-delete" | "renew" | "cross-run" | "release";
69
+ /** One JSONL line of a writer's report file (`w` is the writer index). */
70
+ type SoakEvent = {
71
+ t: "grant";
72
+ w: number;
73
+ epoch: number;
74
+ } | {
75
+ t: "accept";
76
+ w: number;
77
+ surface: SoakAcceptSurface;
78
+ epoch: number;
79
+ counter: number;
80
+ nonce: string;
81
+ seq?: number;
82
+ ref?: string;
83
+ } | {
84
+ t: "victim";
85
+ w: number;
86
+ epoch: number;
87
+ vid: string;
88
+ } | {
89
+ t: "stale-reject";
90
+ w: number;
91
+ surface: SoakProbeSurface;
92
+ epoch: number;
93
+ } | {
94
+ t: "stale-accept";
95
+ w: number;
96
+ surface: string;
97
+ epoch: number;
98
+ } | {
99
+ t: "live-cross-reject";
100
+ w: number;
101
+ epoch: number;
102
+ } | {
103
+ t: "fence-kick";
104
+ w: number;
105
+ surface: string;
106
+ epoch: number;
107
+ } | {
108
+ t: "busy";
109
+ w: number;
110
+ surface: string;
111
+ } | {
112
+ t: "renewed";
113
+ w: number;
114
+ epoch: number;
115
+ } | {
116
+ t: "released";
117
+ w: number;
118
+ epoch: number;
119
+ } | {
120
+ t: "stall";
121
+ w: number;
122
+ epoch: number;
123
+ } | {
124
+ t: "victim-abandoned";
125
+ w: number;
126
+ vid: string;
127
+ surface: string;
128
+ why: string;
129
+ } | {
130
+ t: "error";
131
+ w: number;
132
+ surface: string;
133
+ message: string;
134
+ } | {
135
+ t: "fatal";
136
+ w: number;
137
+ message: string;
138
+ } | {
139
+ t: "done";
140
+ w: number;
141
+ };
142
+ /**
143
+ * The per-writer contract, serialized as JSON into the
144
+ * `RULVAR_SOAK_CONFIG` environment variable of each spawned writer.
145
+ */
146
+ interface SoakWriterConfig {
147
+ /** Store location the writer script constructs its store over. */
148
+ storePath: string;
149
+ /** The soaked run id every writer competes for. */
150
+ runId: string;
151
+ /** This writer's index (0-based; also its report identity). */
152
+ writer: number;
153
+ /** Lease ttl the writer's store MUST be constructed with. */
154
+ ttlMs: number;
155
+ /** Deterministic PRNG seed (writers derive per-index streams). */
156
+ seed: number;
157
+ /** JSONL report file this writer appends its events to. */
158
+ reportPath: string;
159
+ /** The storm ends when this file exists. */
160
+ stopPath: string;
161
+ }
162
+ /** Consumer hooks for {@link runSoakWriter}. */
163
+ interface SoakWriterHooks {
164
+ /**
165
+ * Classifies a thrown store error as transient contention worth an
166
+ * in-place retry (for `SqliteStore`, the driver's SQLITE_BUSY under
167
+ * `BEGIN IMMEDIATE`). Typed `LeaseHeldError` and
168
+ * `JournalOrderViolation` are classified by the protocol itself and
169
+ * never reach this hook. Default: nothing is retryable.
170
+ */
171
+ retryable?: (thrown: unknown) => boolean;
172
+ }
173
+ /**
174
+ * Minimum activity the storm must reach before the referee stops it:
175
+ * run-until-quorum makes the soak adaptive (a slow CI machine storms
176
+ * longer, it never asserts on thin coverage).
177
+ */
178
+ interface SoakQuorum {
179
+ /** Distinct fencing epochs granted (each one is a takeover). */
180
+ epochs: number;
181
+ /** Typed rejections observed by stale probe sweeps, all surfaces. */
182
+ staleRejects: number;
183
+ /** Accepted journal appends (markers included). */
184
+ appends: number;
185
+ /** Accepted meta writes. */
186
+ metaWrites: number;
187
+ /** Accepted transcript blob puts. */
188
+ blobPuts: number;
189
+ /** Accepted transcript blob deletes. */
190
+ blobDeletes: number;
191
+ /** Full fenced-deletion cycles on side runs. */
192
+ victimCycles: number;
193
+ /** Typed rejections of a live lease guarding a foreign run. */
194
+ liveCrossRejects: number;
195
+ }
196
+ /** Default quorum: a few seconds of storm on a developer machine. */
197
+ declare const DEFAULT_SOAK_QUORUM: SoakQuorum;
198
+ /** Activity counters derived from the merged report events. */
199
+ interface SoakActivity {
200
+ epochs: number;
201
+ staleRejects: number;
202
+ appends: number;
203
+ metaWrites: number;
204
+ blobPuts: number;
205
+ blobDeletes: number;
206
+ victimCycles: number;
207
+ liveCrossRejects: number;
208
+ busyRetries: number;
209
+ }
210
+ interface MultiProcessSoakOptions {
211
+ /**
212
+ * Absolute path of the consumer's writer script. It must construct
213
+ * the store over `soakWriterConfigFromEnv().storePath` (bare, no
214
+ * retry wrapper: concurrent boot is part of the promise under test),
215
+ * call {@link runSoakWriter}, and exit 0.
216
+ */
217
+ writerScript: string;
218
+ /** Scratch directory for the store file, reports, and stop file. */
219
+ dir: string;
220
+ /**
221
+ * Opens the referee's own fixture over the SAME store location once
222
+ * the storm has ended, for state verification.
223
+ */
224
+ openStore: (storePath: string) => Promise<FencedTranscriptsFixture> | FencedTranscriptsFixture;
225
+ /** Closes what {@link openStore} opened. */
226
+ closeStore?: (fixture: FencedTranscriptsFixture) => void | Promise<void>;
227
+ /** Store location; default `join(dir, 'soak.db')`. */
228
+ storePath?: string;
229
+ /** Concurrent writer processes; default 3. */
230
+ writers?: number;
231
+ /** Lease ttl for the storm; default 250 ms (short = many takeovers). */
232
+ ttlMs?: number;
233
+ /** PRNG seed; default 1. */
234
+ seed?: number;
235
+ /** Activity quorum overrides; see {@link DEFAULT_SOAK_QUORUM}. */
236
+ quorum?: Partial<SoakQuorum>;
237
+ /** Hard wall-clock cap on the storm; default 60000 ms. */
238
+ capMs?: number;
239
+ /** Extra environment for the writer processes. */
240
+ env?: Record<string, string>;
241
+ /** Extra `node` arguments placed before the writer script. */
242
+ execArgv?: string[];
243
+ }
244
+ /** What a green soak returns (the storm's observed coverage). */
245
+ interface MultiProcessSoakResult {
246
+ activity: SoakActivity;
247
+ stormMs: number;
248
+ journalEntries: number;
249
+ events: SoakEvent[];
250
+ }
251
+ /** Reads the writer contract a referee serialized into the child env. */
252
+ declare function soakWriterConfigFromEnv(env?: Record<string, string | undefined>): SoakWriterConfig;
253
+ /**
254
+ * The writer protocol: run it in a spawned process against the
255
+ * consumer-constructed store pair. Appends every observation to the
256
+ * report file; protocol-level anomalies (a stale acceptance, an
257
+ * unexpected error class) are logged as events for the referee, never
258
+ * thrown, so one writer's finding cannot vanish with its process.
259
+ */
260
+ declare function runSoakWriter(fixture: FencedTranscriptsFixture, config: SoakWriterConfig, hooks?: SoakWriterHooks): Promise<void>;
261
+ /** Parses one report file, tolerating a torn trailing line. */
262
+ declare function parseSoakReport(path: string): SoakEvent[];
263
+ /** Derives the activity counters the quorum is judged against. */
264
+ declare function countSoakActivity(events: readonly SoakEvent[]): SoakActivity;
265
+ /**
266
+ * The pure referee: rebuilds the serial history from the merged report
267
+ * events and diffs it against the actual post-storm store state.
268
+ * Returns every violation as a descriptive string; an empty array means
269
+ * the fencing promise held for the whole storm.
270
+ */
271
+ declare function verifySoakHistory(fixture: FencedTranscriptsFixture, events: readonly SoakEvent[], runId: string): Promise<string[]>;
272
+ /**
273
+ * Spawns the writer processes, stops the storm at quorum (or at the
274
+ * hard cap), verifies the serial history against the store, and throws
275
+ * one Error naming every violation. The returned result is the storm's
276
+ * observed coverage; assert on it if the caller wants a floor beyond
277
+ * the quorum.
278
+ */
279
+ declare function runMultiProcessSoak(options: MultiProcessSoakOptions): Promise<MultiProcessSoakResult>;
280
+ //#endregion
64
281
  //#region src/fixtures/golden-fold.d.ts
65
282
  /**
66
283
  * seq 0 agent spawn (running; abandoned by seq 6)
@@ -85,4 +302,4 @@ declare function foldStateSha256(entries: readonly JournalEntry[]): string;
85
302
  /** The reference hash; computed once from the kernel fold and frozen. */
86
303
  declare const GOLDEN_FOLD_STATE_SHA256 = "81e6ccff549fb3e6c1de4d34ba65b912162eba6f66403b5d5f23a3e1ec69243c";
87
304
  //#endregion
88
- export { type ConformanceCheck, type ConformanceSuite, type FencedTranscriptsFixture, GOLDEN_FOLD_JOURNAL, GOLDEN_FOLD_STATE_SHA256, type StoreFactory, type TestRegistrar, fencedTranscriptsConformance, fencedWritesConformance, foldStateSha256, journalStoreConformance, leasableStoreConformance, makeSuite, materializeFoldState, registerConformance, stableStringify };
305
+ export { type ConformanceCheck, type ConformanceSuite, DEFAULT_SOAK_QUORUM, type FencedTranscriptsFixture, GOLDEN_FOLD_JOURNAL, GOLDEN_FOLD_STATE_SHA256, type MultiProcessSoakOptions, type MultiProcessSoakResult, type SoakAcceptSurface, type SoakActivity, type SoakEvent, type SoakProbeSurface, type SoakQuorum, type SoakWriterConfig, type SoakWriterHooks, type StoreFactory, type TestRegistrar, countSoakActivity, fencedTranscriptsConformance, fencedWritesConformance, foldStateSha256, journalStoreConformance, leasableStoreConformance, makeSuite, materializeFoldState, parseSoakReport, registerConformance, runMultiProcessSoak, runSoakWriter, soakWriterConfigFromEnv, stableStringify, verifySoakHistory };
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
- import { LeaseHeldError, Replayer, ResolutionFold, agentScope, buildDeriverRegistry, deriveContentKey, dispositionHook } from "@rulvar/core";
1
+ import { JournalOrderViolation, LeaseHeldError, Replayer, ResolutionFold, agentScope, buildDeriverRegistry, deriveContentKey, dispositionHook } from "@rulvar/core";
2
2
  import { createHash } from "node:crypto";
3
+ import { spawn } from "node:child_process";
4
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
3
6
  //#region src/types.ts
4
7
  /** Registers the suite as one `describe` block with one `it` per check. */
5
8
  function registerConformance(suite, api) {
@@ -628,7 +631,7 @@ function leaseEntry(seq) {
628
631
  startedAt: (/* @__PURE__ */ new Date(17e11 + seq * 1e3)).toISOString()
629
632
  };
630
633
  }
631
- function sleep(ms) {
634
+ function sleep$1(ms) {
632
635
  return new Promise((resolve) => setTimeout(resolve, ms));
633
636
  }
634
637
  async function mustReject(operation) {
@@ -692,13 +695,13 @@ function leasableStoreConformance(mk, options) {
692
695
  const cadence = Math.floor(ttlMs / 3);
693
696
  const deadline = Date.now() + Math.ceil(ttlMs * 1.5);
694
697
  while (Date.now() < deadline) {
695
- await sleep(cadence);
698
+ await sleep$1(cadence);
696
699
  await store.renew(held);
697
700
  ensure(await mustReject(() => store.acquire(RUN$2, "owner-b")) instanceof LeaseHeldError, "lease-ttl-and-renew-cadence", "a lease renewed at ttl/3 must stay held past the original ttl");
698
701
  }
699
702
  await store.release(held);
700
703
  const abandoned = await store.acquire("expiring-run", "owner-a");
701
- await sleep(Math.ceil(ttlMs * 1.2));
704
+ await sleep$1(Math.ceil(ttlMs * 1.2));
702
705
  ensure((await store.acquire("expiring-run", "owner-b")).epoch > abandoned.epoch, "lease-ttl-and-renew-cadence", "reclaiming an expired lease must advance the fencing epoch");
703
706
  }
704
707
  });
@@ -744,7 +747,7 @@ function meta(runId, status, segments) {
744
747
  updatedAt: `at-${status}-${String(segments)}`
745
748
  };
746
749
  }
747
- async function readMeta(store, runId) {
750
+ async function readMeta$1(store, runId) {
748
751
  const lookup = store;
749
752
  if (typeof lookup.getMeta === "function") return lookup.getMeta(runId);
750
753
  return (await store.listRuns()).find((m) => m.runId === runId);
@@ -778,7 +781,7 @@ function fencedWritesConformance(mk) {
778
781
  const second = await store.acquire(RUN$1, "owner-b");
779
782
  await store.putMeta(meta(RUN$1, "running", 3), second);
780
783
  await mustRejectFenced$1("fenced-meta-stale-rejected", "a stale putMeta", () => store.putMeta(meta(RUN$1, "cancelled", 2), first));
781
- const current = await readMeta(store, RUN$1);
784
+ const current = await readMeta$1(store, RUN$1);
782
785
  ensure(current !== void 0 && current.status === "running" && current.segments === 3, "fenced-meta-stale-rejected", `the successor meta (status running, segments 3) must survive a stale putMeta unchanged, got ${JSON.stringify(current)}`);
783
786
  }
784
787
  },
@@ -794,9 +797,9 @@ function fencedWritesConformance(mk) {
794
797
  const second = await store.acquire(RUN$1, "owner-b");
795
798
  await mustRejectFenced$1("fenced-delete-stale-rejected", "a stale delete", () => store.delete(RUN$1, first));
796
799
  ensure((await store.load(RUN$1)).length === 1, "fenced-delete-stale-rejected", "the journal must survive a stale delete unchanged");
797
- ensure(await readMeta(store, RUN$1) !== void 0, "fenced-delete-stale-rejected", "the meta row must survive a stale delete unchanged");
800
+ ensure(await readMeta$1(store, RUN$1) !== void 0, "fenced-delete-stale-rejected", "the meta row must survive a stale delete unchanged");
798
801
  await store.delete(RUN$1, second);
799
- ensure((await store.load(RUN$1)).length === 0 && await readMeta(store, RUN$1) === void 0, "fenced-delete-stale-rejected", "a delete under the current lease must remove the journal and the meta row");
802
+ ensure((await store.load(RUN$1)).length === 0 && await readMeta$1(store, RUN$1) === void 0, "fenced-delete-stale-rejected", "a delete under the current lease must remove the journal and the meta row");
800
803
  }
801
804
  },
802
805
  {
@@ -810,7 +813,7 @@ function fencedWritesConformance(mk) {
810
813
  await mustRejectFenced$1("fenced-run-match", "a cross-run putMeta", () => store.putMeta(meta(RUN$1, "cancelled", 1), foreign));
811
814
  await mustRejectFenced$1("fenced-run-match", "a cross-run append", () => store.append(RUN$1, fencedEntry(1), foreign));
812
815
  await mustRejectFenced$1("fenced-run-match", "a cross-run delete", () => store.delete(RUN$1, foreign));
813
- const current = await readMeta(store, RUN$1);
816
+ const current = await readMeta$1(store, RUN$1);
814
817
  ensure(current !== void 0 && current.status === "running", "fenced-run-match", "the run guarded by nobody must survive every cross-run mutation attempt");
815
818
  ensure((await store.load(RUN$1)).length === 1, "fenced-run-match", "a cross-run append must never become visible");
816
819
  }
@@ -822,10 +825,10 @@ function fencedWritesConformance(mk) {
822
825
  const store = await mk();
823
826
  await store.putMeta(meta(RUN$1, "running", 1));
824
827
  await store.append(RUN$1, fencedEntry(0));
825
- const current = await readMeta(store, RUN$1);
828
+ const current = await readMeta$1(store, RUN$1);
826
829
  ensure(current !== void 0 && current.status === "running", "fenced-unleased-passthrough", "an unleased putMeta must keep working");
827
830
  await store.delete(RUN$1);
828
- ensure((await store.load(RUN$1)).length === 0 && await readMeta(store, RUN$1) === void 0, "fenced-unleased-passthrough", "an unleased delete must keep working");
831
+ ensure((await store.load(RUN$1)).length === 0 && await readMeta$1(store, RUN$1) === void 0, "fenced-unleased-passthrough", "an unleased delete must keep working");
829
832
  }
830
833
  }
831
834
  ]);
@@ -943,4 +946,694 @@ function fencedTranscriptsConformance(mk) {
943
946
  ]);
944
947
  }
945
948
  //#endregion
946
- export { GOLDEN_FOLD_JOURNAL, GOLDEN_FOLD_STATE_SHA256, fencedTranscriptsConformance, fencedWritesConformance, foldStateSha256, journalStoreConformance, leasableStoreConformance, makeSuite, materializeFoldState, registerConformance, stableStringify };
949
+ //#region src/multi-process-soak.ts
950
+ /**
951
+ * The adversarial multi-process soak (the fenced run state RFC, phase
952
+ * 3's last open item): real OS processes storm one store file through
953
+ * EVERY write surface the `fencedWrites` capability names (journal
954
+ * append, meta write, transcript blob put and delete, fenced run
955
+ * deletion, renew, release), with injected stalls past the lease ttl so
956
+ * takeovers happen while superseded holders are still alive and
957
+ * probing. The harness then rebuilds the ONE serial history the fencing
958
+ * epochs promise (accepted mutations ordered by `(epoch, counter)`) and
959
+ * diffs it against the actual store state: any stale acceptance, lost
960
+ * accepted write, epoch inversion, or divergent final byte is a
961
+ * violation.
962
+ *
963
+ * Three pieces, split so the child side never needs the referee:
964
+ * - {@link runSoakWriter} is the writer protocol one child process runs
965
+ * against the consumer-constructed store (the consumer's writer
966
+ * script is a few lines: construct the store over
967
+ * `soakWriterConfigFromEnv().storePath`, call `runSoakWriter`, exit).
968
+ * Constructing the store bare, concurrently with every other writer,
969
+ * is deliberately part of the exercise: a store whose boot dies under
970
+ * concurrent construction fails the soak in the referee's exit-code
971
+ * check before any fencing is probed.
972
+ * - {@link verifySoakHistory} is the pure referee: given the merged
973
+ * report events and a fixture opened AFTER the storm, it returns
974
+ * every violation as a descriptive string (empty array = the promise
975
+ * held).
976
+ * - {@link runMultiProcessSoak} orchestrates: spawns the writers, stops
977
+ * the storm once the activity quorum is met (so slow machines run
978
+ * longer instead of asserting on thin coverage), waits for clean
979
+ * exits, verifies, and throws one Error naming every violation.
980
+ *
981
+ * The probe sweep defeats the A5 monotonic-seq mask on purpose: a stale
982
+ * append is attempted with a FRESH tail seq (re-loading the journal
983
+ * first), so the fencing check is the only thing standing between the
984
+ * write and the journal.
985
+ */
986
+ /** Default quorum: a few seconds of storm on a developer machine. */
987
+ const DEFAULT_SOAK_QUORUM = {
988
+ epochs: 4,
989
+ staleRejects: 16,
990
+ appends: 8,
991
+ metaWrites: 5,
992
+ blobPuts: 5,
993
+ blobDeletes: 1,
994
+ victimCycles: 1,
995
+ liveCrossRejects: 1
996
+ };
997
+ const SOAK_CONFIG_ENV = "RULVAR_SOAK_CONFIG";
998
+ /** Reads the writer contract a referee serialized into the child env. */
999
+ function soakWriterConfigFromEnv(env = process.env) {
1000
+ const raw = env[SOAK_CONFIG_ENV];
1001
+ if (raw === void 0) throw new Error(`store-conformance multi-process-soak: the writer expects its config in ${SOAK_CONFIG_ENV}`);
1002
+ return JSON.parse(raw);
1003
+ }
1004
+ const wallClock = Date.now.bind(globalThis);
1005
+ /** Deterministic PRNG (mulberry32): the soak never uses Math.random. */
1006
+ function prng(seed) {
1007
+ let a = seed >>> 0;
1008
+ return () => {
1009
+ a |= 0;
1010
+ a = a + 1831565813 | 0;
1011
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
1012
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
1013
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
1014
+ };
1015
+ }
1016
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1017
+ function soakEntry(seq, nonce) {
1018
+ return {
1019
+ hashVersion: 2,
1020
+ seq,
1021
+ scope: "",
1022
+ key: `soak-${nonce}`,
1023
+ ordinal: 0,
1024
+ kind: "step",
1025
+ status: "ok",
1026
+ value: { soak: { nonce } },
1027
+ spanId: "soak-span",
1028
+ startedAt: (/* @__PURE__ */ new Date(17e11 + seq * 1e3)).toISOString()
1029
+ };
1030
+ }
1031
+ const soakBlob = (nonce) => new TextEncoder().encode(JSON.stringify({ soak: { nonce } }));
1032
+ const nonceOfBlob = (bytes) => {
1033
+ if (bytes === null) return;
1034
+ try {
1035
+ return JSON.parse(new TextDecoder().decode(bytes)).soak?.nonce;
1036
+ } catch {
1037
+ return;
1038
+ }
1039
+ };
1040
+ const nonceOfEntry = (entry) => entry.value?.soak?.nonce;
1041
+ async function readMeta(journal, runId) {
1042
+ const lookup = journal;
1043
+ if (typeof lookup.getMeta === "function") return lookup.getMeta(runId);
1044
+ return (await journal.listRuns()).find((m) => m.runId === runId);
1045
+ }
1046
+ /**
1047
+ * The writer protocol: run it in a spawned process against the
1048
+ * consumer-constructed store pair. Appends every observation to the
1049
+ * report file; protocol-level anomalies (a stale acceptance, an
1050
+ * unexpected error class) are logged as events for the referee, never
1051
+ * thrown, so one writer's finding cannot vanish with its process.
1052
+ */
1053
+ async function runSoakWriter(fixture, config, hooks = {}) {
1054
+ const { journal, transcripts } = fixture;
1055
+ const retryable = hooks.retryable ?? (() => false);
1056
+ const owner = `soak-w${config.writer}`;
1057
+ const rnd = prng(config.seed * 1000003 + config.writer * 7919);
1058
+ const log = (event) => {
1059
+ appendFileSync(config.reportPath, `${JSON.stringify(event)}\n`);
1060
+ };
1061
+ const stopRequested = () => existsSync(config.stopPath);
1062
+ const tailSeq = async () => {
1063
+ let max = 0;
1064
+ for (const entry of await journal.load(config.runId)) if (Number.isFinite(entry.seq) && entry.seq > max) max = entry.seq;
1065
+ return max;
1066
+ };
1067
+ const attempt = async (surface, op) => {
1068
+ for (let round = 0; round < 500; round += 1) try {
1069
+ await op();
1070
+ return "ok";
1071
+ } catch (thrown) {
1072
+ if (thrown instanceof LeaseHeldError) return "lease";
1073
+ if (thrown instanceof JournalOrderViolation) return "order";
1074
+ if (retryable(thrown)) {
1075
+ log({
1076
+ t: "busy",
1077
+ w: config.writer,
1078
+ surface
1079
+ });
1080
+ await sleep(1 + Math.floor(rnd() * 3));
1081
+ continue;
1082
+ }
1083
+ log({
1084
+ t: "error",
1085
+ w: config.writer,
1086
+ surface,
1087
+ message: String(thrown)
1088
+ });
1089
+ throw thrown;
1090
+ }
1091
+ log({
1092
+ t: "error",
1093
+ w: config.writer,
1094
+ surface,
1095
+ message: "transient retries exhausted"
1096
+ });
1097
+ throw new Error(`soak writer: transient retries exhausted on ${surface}`);
1098
+ };
1099
+ const staleSweep = async (deadLease) => {
1100
+ const probes = [
1101
+ ["append", async () => {
1102
+ const tail = await tailSeq();
1103
+ await journal.append(config.runId, soakEntry(tail + 1, `stale-w${config.writer}-e${deadLease.epoch}`), deadLease);
1104
+ }],
1105
+ ["meta", () => journal.putMeta({
1106
+ runId: config.runId,
1107
+ status: "cancelled",
1108
+ updatedAt: `stale-w${config.writer}`
1109
+ }, deadLease)],
1110
+ ["blob-put", () => transcripts.put(`${config.runId}/blob-0`, soakBlob(`stale-w${config.writer}-e${deadLease.epoch}`), deadLease)],
1111
+ ["blob-delete", () => transcripts.delete(`${config.runId}/blob-0`, deadLease)],
1112
+ ["run-delete", () => journal.delete(config.runId, deadLease)],
1113
+ ["renew", () => journal.renew(deadLease)],
1114
+ ["cross-run", () => transcripts.put("soak-other/blob", soakBlob(`cross-w${config.writer}`), deadLease)],
1115
+ ["release", () => journal.release(deadLease)]
1116
+ ];
1117
+ for (const [surface, op] of probes) {
1118
+ let outcome;
1119
+ try {
1120
+ outcome = await attempt(`stale-${surface}`, op);
1121
+ } catch {
1122
+ continue;
1123
+ }
1124
+ if (outcome === "ok") log({
1125
+ t: "stale-accept",
1126
+ w: config.writer,
1127
+ surface,
1128
+ epoch: deadLease.epoch
1129
+ });
1130
+ else if (outcome === "lease") log({
1131
+ t: "stale-reject",
1132
+ w: config.writer,
1133
+ surface,
1134
+ epoch: deadLease.epoch
1135
+ });
1136
+ else log({
1137
+ t: "error",
1138
+ w: config.writer,
1139
+ surface: `stale-${surface}`,
1140
+ message: "JournalOrderViolation where the fence should reject first"
1141
+ });
1142
+ }
1143
+ };
1144
+ let victimSeq = 0;
1145
+ const victimCycle = async (epoch) => {
1146
+ const vid = `victim-w${config.writer}-e${epoch}-${victimSeq}`;
1147
+ victimSeq += 1;
1148
+ let vlease;
1149
+ const acquired = await attempt("victim-acquire", async () => {
1150
+ vlease = await journal.acquire(vid, owner);
1151
+ });
1152
+ if (acquired !== "ok" || vlease === void 0) {
1153
+ log({
1154
+ t: "victim-abandoned",
1155
+ w: config.writer,
1156
+ vid,
1157
+ surface: "victim-acquire",
1158
+ why: acquired
1159
+ });
1160
+ return acquired;
1161
+ }
1162
+ const lease = vlease;
1163
+ const steps = [
1164
+ ["victim-append", () => journal.append(vid, soakEntry(1, `${vid}-entry`), lease)],
1165
+ ["victim-meta", () => journal.putMeta({
1166
+ runId: vid,
1167
+ status: "running",
1168
+ updatedAt: `soak-${vid}`
1169
+ }, lease)],
1170
+ ["victim-blob", () => transcripts.put(`${vid}/blob`, soakBlob(vid), lease)],
1171
+ ["victim-blob-delete", () => transcripts.delete(`${vid}/blob`, lease)],
1172
+ ["victim-delete", () => journal.delete(vid, lease)]
1173
+ ];
1174
+ for (const [surface, op] of steps) {
1175
+ const outcome = await attempt(surface, op);
1176
+ if (outcome !== "ok") {
1177
+ log({
1178
+ t: "victim-abandoned",
1179
+ w: config.writer,
1180
+ vid,
1181
+ surface,
1182
+ why: outcome
1183
+ });
1184
+ return outcome;
1185
+ }
1186
+ }
1187
+ log({
1188
+ t: "victim",
1189
+ w: config.writer,
1190
+ epoch,
1191
+ vid
1192
+ });
1193
+ return "ok";
1194
+ };
1195
+ const tenure = async () => {
1196
+ let lease;
1197
+ try {
1198
+ lease = await journal.acquire(config.runId, owner);
1199
+ } catch (thrown) {
1200
+ if (thrown instanceof LeaseHeldError || retryable(thrown)) {
1201
+ await sleep(3 + Math.floor(rnd() * 12));
1202
+ return;
1203
+ }
1204
+ log({
1205
+ t: "error",
1206
+ w: config.writer,
1207
+ surface: "acquire",
1208
+ message: String(thrown)
1209
+ });
1210
+ throw thrown;
1211
+ }
1212
+ const epoch = lease.epoch;
1213
+ log({
1214
+ t: "grant",
1215
+ w: config.writer,
1216
+ epoch
1217
+ });
1218
+ let tail = await tailSeq();
1219
+ let counter = 0;
1220
+ let alive = true;
1221
+ const nonce = () => `w${config.writer}-e${epoch}-c${counter}`;
1222
+ const accepted = (surface, extra = {}) => {
1223
+ log({
1224
+ t: "accept",
1225
+ w: config.writer,
1226
+ surface,
1227
+ epoch,
1228
+ counter,
1229
+ nonce: nonce(),
1230
+ ...extra
1231
+ });
1232
+ counter += 1;
1233
+ };
1234
+ {
1235
+ const seq = tail + 1;
1236
+ const outcome = await attempt("marker", () => journal.append(config.runId, soakEntry(seq, nonce()), lease));
1237
+ if (outcome === "ok") {
1238
+ tail = seq;
1239
+ accepted("marker", { seq });
1240
+ } else if (outcome === "lease") {
1241
+ log({
1242
+ t: "fence-kick",
1243
+ w: config.writer,
1244
+ surface: "marker",
1245
+ epoch
1246
+ });
1247
+ await staleSweep(lease);
1248
+ return;
1249
+ } else {
1250
+ log({
1251
+ t: "error",
1252
+ w: config.writer,
1253
+ surface: "marker",
1254
+ message: "JournalOrderViolation under a live lease"
1255
+ });
1256
+ return;
1257
+ }
1258
+ }
1259
+ const ops = 3 + Math.floor(rnd() * 6);
1260
+ for (let k = 0; k < ops && alive && !stopRequested(); k += 1) {
1261
+ const dice = rnd();
1262
+ if (dice < .3) {
1263
+ const seq = tail + 1;
1264
+ const outcome = await attempt("append", () => journal.append(config.runId, soakEntry(seq, nonce()), lease));
1265
+ if (outcome === "ok") {
1266
+ tail = seq;
1267
+ accepted("append", { seq });
1268
+ } else if (outcome === "lease") {
1269
+ log({
1270
+ t: "fence-kick",
1271
+ w: config.writer,
1272
+ surface: "append",
1273
+ epoch
1274
+ });
1275
+ alive = false;
1276
+ } else {
1277
+ log({
1278
+ t: "error",
1279
+ w: config.writer,
1280
+ surface: "append",
1281
+ message: "JournalOrderViolation under a live lease"
1282
+ });
1283
+ alive = false;
1284
+ }
1285
+ } else if (dice < .5) {
1286
+ const meta = {
1287
+ runId: config.runId,
1288
+ status: "running",
1289
+ updatedAt: `soak-${nonce()}`,
1290
+ soak: { nonce: nonce() }
1291
+ };
1292
+ if (await attempt("meta", () => journal.putMeta(meta, lease)) === "ok") accepted("meta");
1293
+ else {
1294
+ log({
1295
+ t: "fence-kick",
1296
+ w: config.writer,
1297
+ surface: "meta",
1298
+ epoch
1299
+ });
1300
+ alive = false;
1301
+ }
1302
+ } else if (dice < .65) {
1303
+ const ref = `${config.runId}/blob-${Math.floor(rnd() * 4)}`;
1304
+ if (await attempt("blob-put", () => transcripts.put(ref, soakBlob(nonce()), lease)) === "ok") accepted("blob-put", { ref });
1305
+ else {
1306
+ log({
1307
+ t: "fence-kick",
1308
+ w: config.writer,
1309
+ surface: "blob-put",
1310
+ epoch
1311
+ });
1312
+ alive = false;
1313
+ }
1314
+ } else if (dice < .75) {
1315
+ const ref = `${config.runId}/blob-${Math.floor(rnd() * 4)}`;
1316
+ if (await attempt("blob-delete", () => transcripts.delete(ref, lease)) === "ok") accepted("blob-delete", { ref });
1317
+ else {
1318
+ log({
1319
+ t: "fence-kick",
1320
+ w: config.writer,
1321
+ surface: "blob-delete",
1322
+ epoch
1323
+ });
1324
+ alive = false;
1325
+ }
1326
+ } else if (dice < .85) {
1327
+ if (await victimCycle(epoch) === "lease") {
1328
+ log({
1329
+ t: "fence-kick",
1330
+ w: config.writer,
1331
+ surface: "victim",
1332
+ epoch
1333
+ });
1334
+ alive = false;
1335
+ }
1336
+ } else if (dice < .93) if (await attempt("renew", () => journal.renew(lease)) === "ok") log({
1337
+ t: "renewed",
1338
+ w: config.writer,
1339
+ epoch
1340
+ });
1341
+ else {
1342
+ log({
1343
+ t: "fence-kick",
1344
+ w: config.writer,
1345
+ surface: "renew",
1346
+ epoch
1347
+ });
1348
+ alive = false;
1349
+ }
1350
+ else {
1351
+ const outcome = await attempt("cross-run-live", () => transcripts.put("soak-other/blob", soakBlob(nonce()), lease));
1352
+ if (outcome === "lease") log({
1353
+ t: "live-cross-reject",
1354
+ w: config.writer,
1355
+ epoch
1356
+ });
1357
+ else if (outcome === "ok") log({
1358
+ t: "stale-accept",
1359
+ w: config.writer,
1360
+ surface: "cross-run-live",
1361
+ epoch
1362
+ });
1363
+ }
1364
+ }
1365
+ if (!alive) {
1366
+ await staleSweep(lease);
1367
+ return;
1368
+ }
1369
+ if (rnd() < .5) {
1370
+ log({
1371
+ t: "stall",
1372
+ w: config.writer,
1373
+ epoch
1374
+ });
1375
+ await sleep(Math.ceil(config.ttlMs * 1.4));
1376
+ await staleSweep(lease);
1377
+ return;
1378
+ }
1379
+ if (await attempt("release", () => journal.release(lease)) === "ok") log({
1380
+ t: "released",
1381
+ w: config.writer,
1382
+ epoch
1383
+ });
1384
+ else {
1385
+ log({
1386
+ t: "fence-kick",
1387
+ w: config.writer,
1388
+ surface: "release",
1389
+ epoch
1390
+ });
1391
+ await staleSweep(lease);
1392
+ }
1393
+ };
1394
+ try {
1395
+ while (!stopRequested()) await tenure();
1396
+ log({
1397
+ t: "done",
1398
+ w: config.writer
1399
+ });
1400
+ } catch (thrown) {
1401
+ log({
1402
+ t: "fatal",
1403
+ w: config.writer,
1404
+ message: String(thrown)
1405
+ });
1406
+ throw thrown;
1407
+ }
1408
+ }
1409
+ /** Parses one report file, tolerating a torn trailing line. */
1410
+ function parseSoakReport(path) {
1411
+ if (!existsSync(path)) return [];
1412
+ const events = [];
1413
+ for (const line of readFileSync(path, "utf8").split("\n")) {
1414
+ if (line.trim() === "") continue;
1415
+ try {
1416
+ events.push(JSON.parse(line));
1417
+ } catch {}
1418
+ }
1419
+ return events;
1420
+ }
1421
+ /** Derives the activity counters the quorum is judged against. */
1422
+ function countSoakActivity(events) {
1423
+ const epochs = /* @__PURE__ */ new Set();
1424
+ const activity = {
1425
+ epochs: 0,
1426
+ staleRejects: 0,
1427
+ appends: 0,
1428
+ metaWrites: 0,
1429
+ blobPuts: 0,
1430
+ blobDeletes: 0,
1431
+ victimCycles: 0,
1432
+ liveCrossRejects: 0,
1433
+ busyRetries: 0
1434
+ };
1435
+ for (const event of events) switch (event.t) {
1436
+ case "grant":
1437
+ epochs.add(event.epoch);
1438
+ break;
1439
+ case "stale-reject":
1440
+ activity.staleRejects += 1;
1441
+ break;
1442
+ case "accept":
1443
+ if (event.surface === "marker" || event.surface === "append") activity.appends += 1;
1444
+ else if (event.surface === "meta") activity.metaWrites += 1;
1445
+ else if (event.surface === "blob-put") activity.blobPuts += 1;
1446
+ else activity.blobDeletes += 1;
1447
+ break;
1448
+ case "victim":
1449
+ activity.victimCycles += 1;
1450
+ break;
1451
+ case "live-cross-reject":
1452
+ activity.liveCrossRejects += 1;
1453
+ break;
1454
+ case "busy":
1455
+ activity.busyRetries += 1;
1456
+ break;
1457
+ default: break;
1458
+ }
1459
+ activity.epochs = epochs.size;
1460
+ return activity;
1461
+ }
1462
+ function quorumMet(activity, quorum) {
1463
+ return activity.epochs >= quorum.epochs && activity.staleRejects >= quorum.staleRejects && activity.appends >= quorum.appends && activity.metaWrites >= quorum.metaWrites && activity.blobPuts >= quorum.blobPuts && activity.blobDeletes >= quorum.blobDeletes && activity.victimCycles >= quorum.victimCycles && activity.liveCrossRejects >= quorum.liveCrossRejects;
1464
+ }
1465
+ /**
1466
+ * The pure referee: rebuilds the serial history from the merged report
1467
+ * events and diffs it against the actual post-storm store state.
1468
+ * Returns every violation as a descriptive string; an empty array means
1469
+ * the fencing promise held for the whole storm.
1470
+ */
1471
+ async function verifySoakHistory(fixture, events, runId) {
1472
+ const violations = [];
1473
+ for (const event of events) if (event.t === "stale-accept") violations.push(`writer ${event.w}: STALE ACCEPT on ${event.surface} under epoch ${event.epoch}`);
1474
+ else if (event.t === "error") violations.push(`writer ${event.w}: unexpected error on ${event.surface}: ${event.message}`);
1475
+ else if (event.t === "fatal") violations.push(`writer ${event.w}: fatal: ${event.message}`);
1476
+ const grantee = /* @__PURE__ */ new Map();
1477
+ const lastGrant = /* @__PURE__ */ new Map();
1478
+ for (const event of events) {
1479
+ if (event.t !== "grant") continue;
1480
+ if (grantee.has(event.epoch)) violations.push(`epoch ${event.epoch} granted twice (writers ${String(grantee.get(event.epoch))} and ${event.w})`);
1481
+ grantee.set(event.epoch, event.w);
1482
+ const prior = lastGrant.get(event.w) ?? 0;
1483
+ if (event.epoch <= prior) violations.push(`writer ${event.w}: grant epochs not increasing (${prior} then ${event.epoch})`);
1484
+ lastGrant.set(event.w, event.epoch);
1485
+ }
1486
+ const accepts = events.filter((event) => event.t === "accept");
1487
+ for (const event of accepts) if (grantee.get(event.epoch) !== event.w) violations.push(`writer ${event.w} accepted a mutation under epoch ${event.epoch} granted to ${String(grantee.get(event.epoch))}`);
1488
+ const serial = [...accepts].sort((a, b) => a.epoch - b.epoch || a.counter - b.counter);
1489
+ const positions = /* @__PURE__ */ new Set();
1490
+ for (const event of serial) {
1491
+ const position = `${event.epoch}:${event.counter}`;
1492
+ if (positions.has(position)) violations.push(`duplicate serial position ${position}`);
1493
+ positions.add(position);
1494
+ }
1495
+ const journal = await fixture.journal.load(runId);
1496
+ const expectedAppends = serial.filter((event) => event.surface === "marker" || event.surface === "append");
1497
+ if (journal.length !== expectedAppends.length) violations.push(`journal has ${journal.length} entries, the serial history accepted ${expectedAppends.length}`);
1498
+ let lastEpoch = 0;
1499
+ const comparable = Math.min(journal.length, expectedAppends.length);
1500
+ for (let i = 0; i < comparable; i += 1) {
1501
+ const actual = journal[i];
1502
+ const expected = expectedAppends[i];
1503
+ if (nonceOfEntry(actual) !== expected.nonce) {
1504
+ violations.push(`journal[${i}] carries nonce ${String(nonceOfEntry(actual))}, expected ${expected.nonce}`);
1505
+ break;
1506
+ }
1507
+ if (actual.seq !== i + 1) violations.push(`journal[${i}] seq ${actual.seq} breaks the contiguous 1..N order`);
1508
+ if (expected.seq !== i + 1) violations.push(`accepted append ${expected.nonce} logged seq ${String(expected.seq)}, expected ${i + 1}`);
1509
+ if (expected.epoch < lastEpoch) violations.push(`epoch inversion along the journal at index ${i}: ${expected.epoch} after ${lastEpoch}`);
1510
+ lastEpoch = Math.max(lastEpoch, expected.epoch);
1511
+ }
1512
+ const nonceOfMeta = (meta) => meta?.soak?.nonce;
1513
+ const metaAccepts = serial.filter((event) => event.surface === "meta");
1514
+ if (metaAccepts.length > 0) {
1515
+ const got = nonceOfMeta(await readMeta(fixture.journal, runId));
1516
+ const want = metaAccepts[metaAccepts.length - 1].nonce;
1517
+ if (got !== want) violations.push(`final meta nonce ${String(got)} is not the last accepted ${want}`);
1518
+ }
1519
+ const expectedBlobs = /* @__PURE__ */ new Map();
1520
+ for (const event of serial) if (event.surface === "blob-put" && event.ref !== void 0) expectedBlobs.set(event.ref, event.nonce);
1521
+ else if (event.surface === "blob-delete" && event.ref !== void 0) expectedBlobs.delete(event.ref);
1522
+ const listed = await fixture.transcripts.list(runId);
1523
+ const expectedRefs = [...expectedBlobs.keys()].sort();
1524
+ if (JSON.stringify(listed) !== JSON.stringify(expectedRefs)) violations.push(`blob list ${JSON.stringify(listed)} diverges from the serial history ${JSON.stringify(expectedRefs)}`);
1525
+ for (const [ref, want] of expectedBlobs) {
1526
+ const got = nonceOfBlob(await fixture.transcripts.get(ref));
1527
+ if (got !== want) violations.push(`blob ${ref} carries nonce ${String(got)}, expected ${want}`);
1528
+ }
1529
+ const foreign = await fixture.transcripts.list("soak-other");
1530
+ if (foreign.length !== 0) violations.push(`cross-run writes leaked into soak-other: ${JSON.stringify(foreign)}`);
1531
+ for (const event of events) {
1532
+ if (event.t !== "victim") continue;
1533
+ const entries = await fixture.journal.load(event.vid);
1534
+ const meta = await readMeta(fixture.journal, event.vid);
1535
+ const blobs = await fixture.transcripts.list(event.vid);
1536
+ if (entries.length !== 0 || meta !== void 0 || blobs.length !== 0) violations.push(`victim ${event.vid} not fully deleted: ${entries.length} entries, meta ${JSON.stringify(meta)}, ${blobs.length} blobs`);
1537
+ }
1538
+ return violations;
1539
+ }
1540
+ /**
1541
+ * Spawns the writer processes, stops the storm at quorum (or at the
1542
+ * hard cap), verifies the serial history against the store, and throws
1543
+ * one Error naming every violation. The returned result is the storm's
1544
+ * observed coverage; assert on it if the caller wants a floor beyond
1545
+ * the quorum.
1546
+ */
1547
+ async function runMultiProcessSoak(options) {
1548
+ const writers = options.writers ?? 3;
1549
+ const ttlMs = options.ttlMs ?? 250;
1550
+ const seed = options.seed ?? 1;
1551
+ const capMs = options.capMs ?? 6e4;
1552
+ const quorum = {
1553
+ ...DEFAULT_SOAK_QUORUM,
1554
+ ...options.quorum
1555
+ };
1556
+ const storePath = options.storePath ?? join(options.dir, "soak.db");
1557
+ const stopPath = join(options.dir, "soak-stop");
1558
+ const runId = "soak-run";
1559
+ const reportPaths = Array.from({ length: writers }, (_, i) => join(options.dir, `soak-report-${i}.jsonl`));
1560
+ const children = reportPaths.map((reportPath, i) => {
1561
+ const config = {
1562
+ storePath,
1563
+ runId,
1564
+ writer: i,
1565
+ ttlMs,
1566
+ seed,
1567
+ reportPath,
1568
+ stopPath
1569
+ };
1570
+ const child = spawn(process.execPath, [...options.execArgv ?? [], options.writerScript], {
1571
+ env: {
1572
+ ...process.env,
1573
+ ...options.env,
1574
+ [SOAK_CONFIG_ENV]: JSON.stringify(config)
1575
+ },
1576
+ stdio: [
1577
+ "ignore",
1578
+ "ignore",
1579
+ "pipe"
1580
+ ]
1581
+ });
1582
+ let stderr = "";
1583
+ child.stderr.on("data", (chunk) => {
1584
+ stderr += String(chunk);
1585
+ });
1586
+ return {
1587
+ child,
1588
+ exit: new Promise((resolve) => {
1589
+ child.on("exit", (code) => resolve(code));
1590
+ }),
1591
+ stderrOf: () => stderr
1592
+ };
1593
+ });
1594
+ const startedAt = wallClock();
1595
+ let capped = false;
1596
+ for (;;) {
1597
+ await sleep(100);
1598
+ if (quorumMet(countSoakActivity(reportPaths.flatMap((path) => parseSoakReport(path))), quorum)) break;
1599
+ if (wallClock() - startedAt > capMs) {
1600
+ capped = true;
1601
+ break;
1602
+ }
1603
+ }
1604
+ writeFileSync(stopPath, "");
1605
+ const exitDeadline = wallClock() + 3e4;
1606
+ const codes = await Promise.all(children.map(async ({ child, exit }) => {
1607
+ const timeout = Math.max(1, exitDeadline - wallClock());
1608
+ const code = await Promise.race([exit, sleep(timeout).then(() => "hung")]);
1609
+ if (code === "hung") {
1610
+ child.kill("SIGKILL");
1611
+ return "hung";
1612
+ }
1613
+ return code;
1614
+ }));
1615
+ const stormMs = wallClock() - startedAt;
1616
+ const events = reportPaths.flatMap((path) => parseSoakReport(path));
1617
+ const activity = countSoakActivity(events);
1618
+ const violations = [];
1619
+ for (const [i, code] of codes.entries()) if (code === "hung") violations.push(`writer ${i} did not exit after the stop signal (killed)`);
1620
+ else if (code !== 0) violations.push(`writer ${i} exited with code ${String(code)}: ${children[i]?.stderrOf().slice(0, 2e3) ?? ""}`);
1621
+ if (capped) violations.push(`activity quorum not reached inside ${capMs} ms: ${JSON.stringify(activity)} versus ` + JSON.stringify(quorum));
1622
+ const fixture = await options.openStore(storePath);
1623
+ let journalEntries = 0;
1624
+ try {
1625
+ violations.push(...await verifySoakHistory(fixture, events, runId));
1626
+ journalEntries = (await fixture.journal.load(runId)).length;
1627
+ } finally {
1628
+ await options.closeStore?.(fixture);
1629
+ }
1630
+ if (violations.length > 0) throw new Error(`store-conformance multi-process-soak: ${violations.length} violation(s)\n - ` + violations.join("\n - "));
1631
+ return {
1632
+ activity,
1633
+ stormMs,
1634
+ journalEntries,
1635
+ events
1636
+ };
1637
+ }
1638
+ //#endregion
1639
+ export { DEFAULT_SOAK_QUORUM, GOLDEN_FOLD_JOURNAL, GOLDEN_FOLD_STATE_SHA256, countSoakActivity, fencedTranscriptsConformance, fencedWritesConformance, foldStateSha256, journalStoreConformance, leasableStoreConformance, makeSuite, materializeFoldState, parseSoakReport, registerConformance, runMultiProcessSoak, runSoakWriter, soakWriterConfigFromEnv, stableStringify, verifySoakHistory };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/store-conformance",
3
- "version": "1.47.0",
3
+ "version": "1.49.0",
4
4
  "description": "Rulvar executable store conformance kit (DEF-4).",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,7 +22,7 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@rulvar/core": "1.47.0"
25
+ "@rulvar/core": "1.49.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.20.0",