@loomwork/plugin-durable-state 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +82 -2
- package/dist/InMemoryDurableStateStore.d.ts +17 -0
- package/dist/InMemoryDurableStateStore.d.ts.map +1 -0
- package/dist/InMemoryDurableStateStore.js +35 -0
- package/dist/InMemoryDurableStateStore.js.map +1 -0
- package/dist/canonical.d.ts +8 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +36 -0
- package/dist/canonical.js.map +1 -0
- package/dist/durableState.d.ts +10 -0
- package/dist/durableState.d.ts.map +1 -0
- package/dist/durableState.js +202 -0
- package/dist/durableState.js.map +1 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +48 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/sentinel.d.ts +33 -0
- package/dist/sentinel.d.ts.map +1 -0
- package/dist/sentinel.js +30 -0
- package/dist/sentinel.js.map +1 -0
- package/dist/types.d.ts +116 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +54 -3
- package/src/InMemoryDurableStateStore.ts +39 -0
- package/src/canonical.ts +33 -0
- package/src/durableState.ts +281 -0
- package/src/errors.ts +49 -0
- package/src/index.ts +25 -0
- package/src/sentinel.ts +62 -0
- package/src/types.ts +122 -0
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DS-4 — compare-and-set failed: the stored revision was not the expected one.
|
|
3
|
+
* A genuinely concurrent writer got there first (or the key was created/cleared
|
|
4
|
+
* underneath the caller). The schedule-driven pattern makes this a
|
|
5
|
+
* misconfiguration signal, not a normal outcome — fail fast, never lost-update.
|
|
6
|
+
*/
|
|
7
|
+
export declare class RevisionConflictError extends Error {
|
|
8
|
+
readonly key: string;
|
|
9
|
+
readonly expectedRevision: number;
|
|
10
|
+
readonly actualRevision: number;
|
|
11
|
+
constructor(key: string, expectedRevision: number, actualRevision: number);
|
|
12
|
+
}
|
|
13
|
+
export type StateContractMismatchReason = "no-migration-path" | "migration-failed" | "future-version" | "validation-failed";
|
|
14
|
+
/**
|
|
15
|
+
* DS-2/DS-3 — stored state cannot be brought to the current contract: a
|
|
16
|
+
* migration step is missing or threw, the stored version is newer than the
|
|
17
|
+
* contract (rollback deploy), or the (migrated) payload fails schema
|
|
18
|
+
* validation. Loud and typed by design: a run never proceeds on unvalidated
|
|
19
|
+
* state, and state is never silently coerced. Alert on it, register the
|
|
20
|
+
* missing migration (or fix the deploy), and the next run resumes cleanly.
|
|
21
|
+
*/
|
|
22
|
+
export declare class StateContractMismatchError extends Error {
|
|
23
|
+
readonly contractId: string;
|
|
24
|
+
readonly reason: StateContractMismatchReason;
|
|
25
|
+
readonly storedVersion: number;
|
|
26
|
+
readonly expectedVersion: number;
|
|
27
|
+
constructor(contractId: string, reason: StateContractMismatchReason, storedVersion: number, expectedVersion: number, detail?: string);
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;IAE5C,QAAQ,CAAC,GAAG,EAAE,MAAM;IACpB,QAAQ,CAAC,gBAAgB,EAAE,MAAM;IACjC,QAAQ,CAAC,cAAc,EAAE,MAAM;gBAFtB,GAAG,EAAE,MAAM,EACX,gBAAgB,EAAE,MAAM,EACxB,cAAc,EAAE,MAAM;CAQlC;AAED,MAAM,MAAM,2BAA2B,GACnC,mBAAmB,GACnB,kBAAkB,GAClB,gBAAgB,GAChB,mBAAmB,CAAC;AAExB;;;;;;;GAOG;AACH,qBAAa,0BAA2B,SAAQ,KAAK;IAEjD,QAAQ,CAAC,UAAU,EAAE,MAAM;IAC3B,QAAQ,CAAC,MAAM,EAAE,2BAA2B;IAC5C,QAAQ,CAAC,aAAa,EAAE,MAAM;IAC9B,QAAQ,CAAC,eAAe,EAAE,MAAM;gBAHvB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,2BAA2B,EACnC,aAAa,EAAE,MAAM,EACrB,eAAe,EAAE,MAAM,EAChC,MAAM,CAAC,EAAE,MAAM;CAQlB"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StateContractMismatchError = exports.RevisionConflictError = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* DS-4 — compare-and-set failed: the stored revision was not the expected one.
|
|
6
|
+
* A genuinely concurrent writer got there first (or the key was created/cleared
|
|
7
|
+
* underneath the caller). The schedule-driven pattern makes this a
|
|
8
|
+
* misconfiguration signal, not a normal outcome — fail fast, never lost-update.
|
|
9
|
+
*/
|
|
10
|
+
class RevisionConflictError extends Error {
|
|
11
|
+
key;
|
|
12
|
+
expectedRevision;
|
|
13
|
+
actualRevision;
|
|
14
|
+
constructor(key, expectedRevision, actualRevision) {
|
|
15
|
+
super(`durable-state CAS conflict on "${key}": expected revision ${expectedRevision}, found ${actualRevision}`);
|
|
16
|
+
this.key = key;
|
|
17
|
+
this.expectedRevision = expectedRevision;
|
|
18
|
+
this.actualRevision = actualRevision;
|
|
19
|
+
this.name = "RevisionConflictError";
|
|
20
|
+
Object.setPrototypeOf(this, RevisionConflictError.prototype);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
exports.RevisionConflictError = RevisionConflictError;
|
|
24
|
+
/**
|
|
25
|
+
* DS-2/DS-3 — stored state cannot be brought to the current contract: a
|
|
26
|
+
* migration step is missing or threw, the stored version is newer than the
|
|
27
|
+
* contract (rollback deploy), or the (migrated) payload fails schema
|
|
28
|
+
* validation. Loud and typed by design: a run never proceeds on unvalidated
|
|
29
|
+
* state, and state is never silently coerced. Alert on it, register the
|
|
30
|
+
* missing migration (or fix the deploy), and the next run resumes cleanly.
|
|
31
|
+
*/
|
|
32
|
+
class StateContractMismatchError extends Error {
|
|
33
|
+
contractId;
|
|
34
|
+
reason;
|
|
35
|
+
storedVersion;
|
|
36
|
+
expectedVersion;
|
|
37
|
+
constructor(contractId, reason, storedVersion, expectedVersion, detail) {
|
|
38
|
+
super(`durable-state contract mismatch for "${contractId}" (${reason}): stored v${storedVersion}, contract v${expectedVersion}${detail ? ` — ${detail}` : ""}`);
|
|
39
|
+
this.contractId = contractId;
|
|
40
|
+
this.reason = reason;
|
|
41
|
+
this.storedVersion = storedVersion;
|
|
42
|
+
this.expectedVersion = expectedVersion;
|
|
43
|
+
this.name = "StateContractMismatchError";
|
|
44
|
+
Object.setPrototypeOf(this, StateContractMismatchError.prototype);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
exports.StateContractMismatchError = StateContractMismatchError;
|
|
48
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":";;;AAAA;;;;;GAKG;AACH,MAAa,qBAAsB,SAAQ,KAAK;IAEnC;IACA;IACA;IAHX,YACW,GAAW,EACX,gBAAwB,EACxB,cAAsB;QAE/B,KAAK,CACH,kCAAkC,GAAG,wBAAwB,gBAAgB,WAAW,cAAc,EAAE,CACzG,CAAC;QANO,QAAG,GAAH,GAAG,CAAQ;QACX,qBAAgB,GAAhB,gBAAgB,CAAQ;QACxB,mBAAc,GAAd,cAAc,CAAQ;QAK/B,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;QACpC,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE,qBAAqB,CAAC,SAAS,CAAC,CAAC;IAC/D,CAAC;CACF;AAZD,sDAYC;AAQD;;;;;;;GAOG;AACH,MAAa,0BAA2B,SAAQ,KAAK;IAExC;IACA;IACA;IACA;IAJX,YACW,UAAkB,EAClB,MAAmC,EACnC,aAAqB,EACrB,eAAuB,EAChC,MAAe;QAEf,KAAK,CACH,wCAAwC,UAAU,MAAM,MAAM,cAAc,aAAa,eAAe,eAAe,GAAG,MAAM,CAAC,CAAC,CAAC,MAAM,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CACzJ,CAAC;QARO,eAAU,GAAV,UAAU,CAAQ;QAClB,WAAM,GAAN,MAAM,CAA6B;QACnC,kBAAa,GAAb,aAAa,CAAQ;QACrB,oBAAe,GAAf,eAAe,CAAQ;QAMhC,IAAI,CAAC,IAAI,GAAG,4BAA4B,CAAC;QACzC,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE,0BAA0B,CAAC,SAAS,CAAC,CAAC;IACpE,CAAC;CACF;AAdD,gEAcC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { durableState } from "./durableState";
|
|
2
|
+
export { sentinelState } from "./sentinel";
|
|
3
|
+
export type { SentinelStateConfig, SentinelStateHandle } from "./sentinel";
|
|
4
|
+
export { InMemoryDurableStateStore } from "./InMemoryDurableStateStore";
|
|
5
|
+
export { RevisionConflictError, StateContractMismatchError, type StateContractMismatchReason, } from "./errors";
|
|
6
|
+
export { stableStringify, canonicalEqual } from "./canonical";
|
|
7
|
+
export type { ClearInput, ClearResult, DurableStateConfig, DurableStateHandle, DurableStateRecord, DurableStateStore, EffectRegistry, LoadInput, LoadResult, Migration, SaveInput, SaveResult, TaskExecutor, } from "./types";
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAC3C,YAAY,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAC3E,OAAO,EAAE,yBAAyB,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EACL,qBAAqB,EACrB,0BAA0B,EAC1B,KAAK,2BAA2B,GACjC,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC9D,YAAY,EACV,UAAU,EACV,WAAW,EACX,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,iBAAiB,EACjB,cAAc,EACd,SAAS,EACT,UAAU,EACV,SAAS,EACT,SAAS,EACT,UAAU,EACV,YAAY,GACb,MAAM,SAAS,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.canonicalEqual = exports.stableStringify = exports.StateContractMismatchError = exports.RevisionConflictError = exports.InMemoryDurableStateStore = exports.sentinelState = exports.durableState = void 0;
|
|
4
|
+
var durableState_1 = require("./durableState");
|
|
5
|
+
Object.defineProperty(exports, "durableState", { enumerable: true, get: function () { return durableState_1.durableState; } });
|
|
6
|
+
var sentinel_1 = require("./sentinel");
|
|
7
|
+
Object.defineProperty(exports, "sentinelState", { enumerable: true, get: function () { return sentinel_1.sentinelState; } });
|
|
8
|
+
var InMemoryDurableStateStore_1 = require("./InMemoryDurableStateStore");
|
|
9
|
+
Object.defineProperty(exports, "InMemoryDurableStateStore", { enumerable: true, get: function () { return InMemoryDurableStateStore_1.InMemoryDurableStateStore; } });
|
|
10
|
+
var errors_1 = require("./errors");
|
|
11
|
+
Object.defineProperty(exports, "RevisionConflictError", { enumerable: true, get: function () { return errors_1.RevisionConflictError; } });
|
|
12
|
+
Object.defineProperty(exports, "StateContractMismatchError", { enumerable: true, get: function () { return errors_1.StateContractMismatchError; } });
|
|
13
|
+
var canonical_1 = require("./canonical");
|
|
14
|
+
Object.defineProperty(exports, "stableStringify", { enumerable: true, get: function () { return canonical_1.stableStringify; } });
|
|
15
|
+
Object.defineProperty(exports, "canonicalEqual", { enumerable: true, get: function () { return canonical_1.canonicalEqual; } });
|
|
16
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,+CAA8C;AAArC,4GAAA,YAAY,OAAA;AACrB,uCAA2C;AAAlC,yGAAA,aAAa,OAAA;AAEtB,yEAAwE;AAA/D,sIAAA,yBAAyB,OAAA;AAClC,mCAIkB;AAHhB,+GAAA,qBAAqB,OAAA;AACrB,oHAAA,0BAA0B,OAAA;AAG5B,yCAA8D;AAArD,4GAAA,eAAe,OAAA;AAAE,2GAAA,cAAc,OAAA"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ScheduleDefinition } from "@loomwork/core";
|
|
2
|
+
import type { ZodSchema } from "zod";
|
|
3
|
+
import type { ClearResult, DurableStateHandle, DurableStateStore, EffectRegistry, LoadResult, Migration, SaveResult, TaskExecutor } from "./types";
|
|
4
|
+
export interface SentinelStateConfig<T> {
|
|
5
|
+
/** The schedule whose occurrences share this state — the key derives from its id. */
|
|
6
|
+
schedule: Pick<ScheduleDefinition, "id">;
|
|
7
|
+
schema: ZodSchema<T>;
|
|
8
|
+
/** Current contract version; defaults to 1. */
|
|
9
|
+
version?: number;
|
|
10
|
+
migrations?: Readonly<Record<number, Migration>>;
|
|
11
|
+
store: DurableStateStore;
|
|
12
|
+
/** Contract id override; defaults to `<scheduleId>.state`. */
|
|
13
|
+
id?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SentinelStateHandle<T> {
|
|
16
|
+
/** The derived state key: `sentinel:<scheduleId>`. */
|
|
17
|
+
readonly key: string;
|
|
18
|
+
/** The underlying handle, for advanced use (extra keys, explicit step ids). */
|
|
19
|
+
readonly state: DurableStateHandle<T>;
|
|
20
|
+
register(runtime: EffectRegistry): void;
|
|
21
|
+
load(ctx: TaskExecutor): Promise<LoadResult<T>>;
|
|
22
|
+
save(ctx: TaskExecutor, value: T, expectedRevision: number): Promise<SaveResult>;
|
|
23
|
+
clear(ctx: TaskExecutor): Promise<ClearResult>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* DS-5 — the §2 P-1 sentinel pattern as one declaration: a `schedule$`-driven
|
|
27
|
+
* agent's carry state, keyed by the schedule id. Each occurrence loads at wake
|
|
28
|
+
* (recorded, validated, migrated), does its work, and saves with the loaded
|
|
29
|
+
* revision — T1's one-live-occurrence-per-schedule guarantee makes the CAS a
|
|
30
|
+
* misconfiguration tripwire rather than a contention point.
|
|
31
|
+
*/
|
|
32
|
+
export declare function sentinelState<T>(config: SentinelStateConfig<T>): SentinelStateHandle<T>;
|
|
33
|
+
//# sourceMappingURL=sentinel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sentinel.d.ts","sourceRoot":"","sources":["../src/sentinel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AACzD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,KAAK,CAAC;AAErC,OAAO,KAAK,EACV,WAAW,EACX,kBAAkB,EAClB,iBAAiB,EACjB,cAAc,EACd,UAAU,EACV,SAAS,EACT,UAAU,EACV,YAAY,EACb,MAAM,SAAS,CAAC;AAEjB,MAAM,WAAW,mBAAmB,CAAC,CAAC;IACpC,qFAAqF;IACrF,QAAQ,EAAE,IAAI,CAAC,kBAAkB,EAAE,IAAI,CAAC,CAAC;IACzC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC;IACrB,+CAA+C;IAC/C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC;IACjD,KAAK,EAAE,iBAAiB,CAAC;IACzB,8DAA8D;IAC9D,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,mBAAmB,CAAC,CAAC;IACpC,sDAAsD;IACtD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,+EAA+E;IAC/E,QAAQ,CAAC,KAAK,EAAE,kBAAkB,CAAC,CAAC,CAAC,CAAC;IACtC,QAAQ,CAAC,OAAO,EAAE,cAAc,GAAG,IAAI,CAAC;IACxC,IAAI,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;IAChD,IAAI,CAAC,GAAG,EAAE,YAAY,EAAE,KAAK,EAAE,CAAC,EAAE,gBAAgB,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IACjF,KAAK,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;CAChD;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,mBAAmB,CAAC,CAAC,CAAC,GAAG,mBAAmB,CAAC,CAAC,CAAC,CAiBvF"}
|
package/dist/sentinel.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sentinelState = sentinelState;
|
|
4
|
+
const durableState_1 = require("./durableState");
|
|
5
|
+
/**
|
|
6
|
+
* DS-5 — the §2 P-1 sentinel pattern as one declaration: a `schedule$`-driven
|
|
7
|
+
* agent's carry state, keyed by the schedule id. Each occurrence loads at wake
|
|
8
|
+
* (recorded, validated, migrated), does its work, and saves with the loaded
|
|
9
|
+
* revision — T1's one-live-occurrence-per-schedule guarantee makes the CAS a
|
|
10
|
+
* misconfiguration tripwire rather than a contention point.
|
|
11
|
+
*/
|
|
12
|
+
function sentinelState(config) {
|
|
13
|
+
const state = (0, durableState_1.durableState)({
|
|
14
|
+
id: config.id ?? `${config.schedule.id}.state`,
|
|
15
|
+
version: config.version ?? 1,
|
|
16
|
+
schema: config.schema,
|
|
17
|
+
...(config.migrations !== undefined && { migrations: config.migrations }),
|
|
18
|
+
store: config.store,
|
|
19
|
+
});
|
|
20
|
+
const key = `sentinel:${config.schedule.id}`;
|
|
21
|
+
return {
|
|
22
|
+
key,
|
|
23
|
+
state,
|
|
24
|
+
register: (runtime) => state.register(runtime),
|
|
25
|
+
load: (ctx) => state.load(ctx, key),
|
|
26
|
+
save: (ctx, value, expectedRevision) => state.save(ctx, key, value, expectedRevision),
|
|
27
|
+
clear: (ctx) => state.clear(ctx, key),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=sentinel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sentinel.js","sourceRoot":"","sources":["../src/sentinel.ts"],"names":[],"mappings":";;AA4CA,sCAiBC;AA3DD,iDAA8C;AAmC9C;;;;;;GAMG;AACH,SAAgB,aAAa,CAAI,MAA8B;IAC7D,MAAM,KAAK,GAAG,IAAA,2BAAY,EAAI;QAC5B,EAAE,EAAE,MAAM,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC,QAAQ,CAAC,EAAE,QAAQ;QAC9C,OAAO,EAAE,MAAM,CAAC,OAAO,IAAI,CAAC;QAC5B,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,GAAG,CAAC,MAAM,CAAC,UAAU,KAAK,SAAS,IAAI,EAAE,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE,CAAC;QACzE,KAAK,EAAE,MAAM,CAAC,KAAK;KACpB,CAAC,CAAC;IACH,MAAM,GAAG,GAAG,YAAY,MAAM,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;IAC7C,OAAO;QACL,GAAG;QACH,KAAK;QACL,QAAQ,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC;QAC9C,IAAI,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC;QACnC,IAAI,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,gBAAgB,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,gBAAgB,CAAC;QACrF,KAAK,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC;KACtC,CAAC;AACJ,CAAC"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ExecutableTask, TaskDefinition } from "@loomwork/core";
|
|
2
|
+
import type { Effect } from "@loomwork/effect-runtime";
|
|
3
|
+
import type { ZodSchema } from "zod";
|
|
4
|
+
/** The stored envelope: what a backend persists per key. Revisions start at 1. */
|
|
5
|
+
export interface DurableStateRecord {
|
|
6
|
+
contractVersion: number;
|
|
7
|
+
revision: number;
|
|
8
|
+
payload: unknown;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The durable-state store SPI. Backends are dumb keyed CAS stores — all
|
|
12
|
+
* contract knowledge (schemas, migrations) lives above, in {@link durableState}.
|
|
13
|
+
* `save` with `expectedRevision: 0` creates; any mismatch rejects with
|
|
14
|
+
* {@link RevisionConflictError} (`name: "RevisionConflictError"` for 3P
|
|
15
|
+
* backends that cannot share the class). Conformance:
|
|
16
|
+
* `runDurableStateStoreConformance` in `@loomwork/conformance`.
|
|
17
|
+
*/
|
|
18
|
+
export interface DurableStateStore {
|
|
19
|
+
load(key: string): Promise<DurableStateRecord | null>;
|
|
20
|
+
save(key: string, value: {
|
|
21
|
+
contractVersion: number;
|
|
22
|
+
payload: unknown;
|
|
23
|
+
}, expectedRevision: number): Promise<DurableStateRecord>;
|
|
24
|
+
/** Remove the key. Idempotent. */
|
|
25
|
+
clear(key: string): Promise<void>;
|
|
26
|
+
close?(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/** DS-3 — one migration step: payload at version N → payload at version N+1. */
|
|
29
|
+
export type Migration = (value: unknown) => unknown;
|
|
30
|
+
export interface DurableStateConfig<T> {
|
|
31
|
+
/** Contract id — namespaces the registered tool ids (`<id>.load` / `<id>.save` / `<id>.clear`). */
|
|
32
|
+
id: string;
|
|
33
|
+
/** Current contract version (integer ≥ 1). Stored envelopes carry the version they were written under. */
|
|
34
|
+
version: number;
|
|
35
|
+
/** The CURRENT version's schema. Every load validates against it after migration. */
|
|
36
|
+
schema: ZodSchema<T>;
|
|
37
|
+
/** `migrations[n]` migrates a payload from version n to n+1. Sparse maps stall loudly (DS-3). */
|
|
38
|
+
migrations?: Readonly<Record<number, Migration>>;
|
|
39
|
+
store: DurableStateStore;
|
|
40
|
+
}
|
|
41
|
+
export interface LoadInput {
|
|
42
|
+
key: string;
|
|
43
|
+
}
|
|
44
|
+
export interface LoadResult<T> {
|
|
45
|
+
found: boolean;
|
|
46
|
+
/** Validated, current-contract value — `null` when not found. */
|
|
47
|
+
value: T | null;
|
|
48
|
+
/** Pass back to `save` as `expectedRevision` (0 when not found → create). */
|
|
49
|
+
revision: number;
|
|
50
|
+
contractVersion: number;
|
|
51
|
+
/** Non-null when a stored older envelope was migrated on this load (recorded in the step output). */
|
|
52
|
+
migrated: {
|
|
53
|
+
from: number;
|
|
54
|
+
to: number;
|
|
55
|
+
} | null;
|
|
56
|
+
}
|
|
57
|
+
export interface SaveInput<T> {
|
|
58
|
+
key: string;
|
|
59
|
+
value: T;
|
|
60
|
+
expectedRevision: number;
|
|
61
|
+
}
|
|
62
|
+
export interface SaveResult {
|
|
63
|
+
revision: number;
|
|
64
|
+
}
|
|
65
|
+
export interface ClearInput {
|
|
66
|
+
key: string;
|
|
67
|
+
}
|
|
68
|
+
export interface ClearResult {
|
|
69
|
+
cleared: true;
|
|
70
|
+
}
|
|
71
|
+
/** Structural view of `EffectRuntime.register` — anything that accepts (toolId, effect). */
|
|
72
|
+
export interface EffectRegistry {
|
|
73
|
+
register(toolId: string, effect: Effect): void;
|
|
74
|
+
}
|
|
75
|
+
/** Structural view of the execution context — `WorkflowContext.execute` satisfies it. */
|
|
76
|
+
export interface TaskExecutor {
|
|
77
|
+
execute<TIn, TOut>(task: ExecutableTask<TIn, TOut>, input: TIn): Promise<TOut>;
|
|
78
|
+
}
|
|
79
|
+
export interface DurableStateHandle<T> {
|
|
80
|
+
readonly id: string;
|
|
81
|
+
readonly version: number;
|
|
82
|
+
readonly toolIds: {
|
|
83
|
+
load: string;
|
|
84
|
+
save: string;
|
|
85
|
+
clear: string;
|
|
86
|
+
};
|
|
87
|
+
/** `ctx.execute`-able task metadata (kind "memory"); ids default to the tool ids. */
|
|
88
|
+
readonly tasks: {
|
|
89
|
+
load: TaskDefinition<LoadInput, LoadResult<T>>;
|
|
90
|
+
save: TaskDefinition<SaveInput<T>, SaveResult>;
|
|
91
|
+
clear: TaskDefinition<ClearInput, ClearResult>;
|
|
92
|
+
};
|
|
93
|
+
/** The effects to register — or call {@link DurableStateHandle.register}. */
|
|
94
|
+
readonly effects: {
|
|
95
|
+
load: Effect<LoadInput, LoadResult<T>>;
|
|
96
|
+
save: Effect<SaveInput<T>, SaveResult>;
|
|
97
|
+
clear: Effect<ClearInput, ClearResult>;
|
|
98
|
+
};
|
|
99
|
+
register(runtime: EffectRegistry): void;
|
|
100
|
+
/**
|
|
101
|
+
* Recorded read (DS-1): the result folds from STEP_COMPLETED on replay — the
|
|
102
|
+
* store is never re-read. One call per default step id; pass `stepId` for
|
|
103
|
+
* additional loads in the same run.
|
|
104
|
+
*/
|
|
105
|
+
load(ctx: TaskExecutor, key: string, opts?: {
|
|
106
|
+
stepId?: string;
|
|
107
|
+
}): Promise<LoadResult<T>>;
|
|
108
|
+
/** Recorded CAS write (DS-1/DS-4). `expectedRevision` comes from the prior load (0 = create). */
|
|
109
|
+
save(ctx: TaskExecutor, key: string, value: T, expectedRevision: number, opts?: {
|
|
110
|
+
stepId?: string;
|
|
111
|
+
}): Promise<SaveResult>;
|
|
112
|
+
clear(ctx: TaskExecutor, key: string, opts?: {
|
|
113
|
+
stepId?: string;
|
|
114
|
+
}): Promise<ClearResult>;
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loomwork/plugin-durable-state",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Typed, versioned, migratable keyed state for Loom (ADR 0051 D2) — carry state across bounded runs as RECORDED effects: load/save results land in STEP_COMPLETED so replay folds the recorded value and never re-reads the store. Zod-validated envelopes, registered version-to-version migrations (loud StateContractMismatch when absent), compare-and-set revisions, and a sentinelState convenience for schedule-driven agents.",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"require": "./dist/index.js",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"zod": "^3.23.8",
|
|
16
|
+
"@loomwork/core": "^0.1.0",
|
|
17
|
+
"@loomwork/effect-runtime": "^0.1.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^22.0.0",
|
|
21
|
+
"typescript": "^5.6.3",
|
|
22
|
+
"vitest": "^2.1.8",
|
|
23
|
+
"@loomwork/conformance": "0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/chhetrisushil/loom.git",
|
|
29
|
+
"directory": "plugins/durable-state"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src",
|
|
34
|
+
"!src/**/*.test.ts",
|
|
35
|
+
"!src/**/*.test.tsx",
|
|
36
|
+
"!src/**/__tests__",
|
|
37
|
+
"!src/**/__fixtures__",
|
|
38
|
+
"!src/**/__snapshots__",
|
|
39
|
+
"!src/**/__golden__",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"NOTICE"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"tag": "next"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20.0.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsc --build",
|
|
52
|
+
"dev": "tsc --build --watch",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
55
|
+
"typecheck:tests": "tsc -p tsconfig.test.json"
|
|
56
|
+
}
|
|
6
57
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { RevisionConflictError } from "./errors";
|
|
2
|
+
import type { DurableStateRecord, DurableStateStore } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reference in-memory backend — the conformance baseline, and the test/dev
|
|
6
|
+
* default. Deep-clones on both write and read so callers can never alias the
|
|
7
|
+
* stored record (matching the value semantics of the durable backends, where
|
|
8
|
+
* a JSON round-trip breaks aliasing anyway).
|
|
9
|
+
*/
|
|
10
|
+
export class InMemoryDurableStateStore implements DurableStateStore {
|
|
11
|
+
private readonly records = new Map<string, DurableStateRecord>();
|
|
12
|
+
|
|
13
|
+
async load(key: string): Promise<DurableStateRecord | null> {
|
|
14
|
+
const record = this.records.get(key);
|
|
15
|
+
return record === undefined ? null : structuredClone(record);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async save(
|
|
19
|
+
key: string,
|
|
20
|
+
value: { contractVersion: number; payload: unknown },
|
|
21
|
+
expectedRevision: number
|
|
22
|
+
): Promise<DurableStateRecord> {
|
|
23
|
+
const actual = this.records.get(key)?.revision ?? 0;
|
|
24
|
+
if (expectedRevision !== actual) {
|
|
25
|
+
throw new RevisionConflictError(key, expectedRevision, actual);
|
|
26
|
+
}
|
|
27
|
+
const record: DurableStateRecord = {
|
|
28
|
+
contractVersion: value.contractVersion,
|
|
29
|
+
revision: actual + 1,
|
|
30
|
+
payload: structuredClone(value.payload),
|
|
31
|
+
};
|
|
32
|
+
this.records.set(key, record);
|
|
33
|
+
return structuredClone(record);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async clear(key: string): Promise<void> {
|
|
37
|
+
this.records.delete(key);
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/canonical.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Depth cap for canonicalization. JSON nesting beyond this is pathological
|
|
3
|
+
* (and could stack-overflow the save path — audit W8); exceeding it throws a
|
|
4
|
+
* loud RangeError rather than crashing the process.
|
|
5
|
+
*/
|
|
6
|
+
const MAX_DEPTH = 256;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Canonical JSON serialization: object keys sorted, `undefined` properties
|
|
10
|
+
* skipped (JSON semantics). Used for the save effect's adopt-own-write check —
|
|
11
|
+
* value equality must not depend on property insertion order.
|
|
12
|
+
*/
|
|
13
|
+
export function stableStringify(value: unknown, depth = 0): string {
|
|
14
|
+
if (value === null || typeof value !== "object") {
|
|
15
|
+
return JSON.stringify(value) ?? "null";
|
|
16
|
+
}
|
|
17
|
+
if (depth >= MAX_DEPTH) {
|
|
18
|
+
throw new RangeError(`stableStringify: value nests deeper than ${MAX_DEPTH} levels`);
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
return `[${value.map((item) => stableStringify(item, depth + 1)).join(",")}]`;
|
|
22
|
+
}
|
|
23
|
+
const entries = Object.keys(value as Record<string, unknown>)
|
|
24
|
+
.sort()
|
|
25
|
+
.map((key) => [key, (value as Record<string, unknown>)[key]] as const)
|
|
26
|
+
.filter(([, v]) => v !== undefined)
|
|
27
|
+
.map(([key, v]) => `${JSON.stringify(key)}:${stableStringify(v, depth + 1)}`);
|
|
28
|
+
return `{${entries.join(",")}}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function canonicalEqual(a: unknown, b: unknown): boolean {
|
|
32
|
+
return stableStringify(a) === stableStringify(b);
|
|
33
|
+
}
|