@affordance/core 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.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/dist/engine/compute.d.ts +147 -0
  4. package/dist/engine/compute.js +152 -0
  5. package/dist/engine/compute.js.map +1 -0
  6. package/dist/engine/engine.d.ts +162 -0
  7. package/dist/engine/engine.js +115 -0
  8. package/dist/engine/engine.js.map +1 -0
  9. package/dist/engine/errors.d.ts +6 -0
  10. package/dist/engine/errors.js +11 -0
  11. package/dist/engine/errors.js.map +1 -0
  12. package/dist/engine/index.d.ts +16 -0
  13. package/dist/engine/index.js +15 -0
  14. package/dist/engine/index.js.map +1 -0
  15. package/dist/errors.d.ts +46 -0
  16. package/dist/errors.js +42 -0
  17. package/dist/errors.js.map +1 -0
  18. package/dist/execution/delta.d.ts +42 -0
  19. package/dist/execution/delta.js +93 -0
  20. package/dist/execution/delta.js.map +1 -0
  21. package/dist/execution/errors.d.ts +94 -0
  22. package/dist/execution/errors.js +132 -0
  23. package/dist/execution/errors.js.map +1 -0
  24. package/dist/execution/execute.d.ts +179 -0
  25. package/dist/execution/execute.js +356 -0
  26. package/dist/execution/execute.js.map +1 -0
  27. package/dist/execution/index.d.ts +25 -0
  28. package/dist/execution/index.js +20 -0
  29. package/dist/execution/index.js.map +1 -0
  30. package/dist/execution/journal.d.ts +190 -0
  31. package/dist/execution/journal.js +192 -0
  32. package/dist/execution/journal.js.map +1 -0
  33. package/dist/execution/port.d.ts +81 -0
  34. package/dist/execution/port.js +101 -0
  35. package/dist/execution/port.js.map +1 -0
  36. package/dist/execution/replay.d.ts +58 -0
  37. package/dist/execution/replay.js +59 -0
  38. package/dist/execution/replay.js.map +1 -0
  39. package/dist/execution/timers.d.ts +21 -0
  40. package/dist/execution/timers.js +26 -0
  41. package/dist/execution/timers.js.map +1 -0
  42. package/dist/execution/transaction.d.ts +24 -0
  43. package/dist/execution/transaction.js +49 -0
  44. package/dist/execution/transaction.js.map +1 -0
  45. package/dist/guards/condition.d.ts +86 -0
  46. package/dist/guards/condition.js +11 -0
  47. package/dist/guards/condition.js.map +1 -0
  48. package/dist/guards/evaluate.d.ts +143 -0
  49. package/dist/guards/evaluate.js +167 -0
  50. package/dist/guards/evaluate.js.map +1 -0
  51. package/dist/guards/guard.d.ts +136 -0
  52. package/dist/guards/guard.js +118 -0
  53. package/dist/guards/guard.js.map +1 -0
  54. package/dist/guards/index.d.ts +20 -0
  55. package/dist/guards/index.js +17 -0
  56. package/dist/guards/index.js.map +1 -0
  57. package/dist/guards/time.d.ts +32 -0
  58. package/dist/guards/time.js +45 -0
  59. package/dist/guards/time.js.map +1 -0
  60. package/dist/index.d.ts +31 -0
  61. package/dist/index.js +20 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/ingestion/correlation.d.ts +54 -0
  64. package/dist/ingestion/correlation.js +78 -0
  65. package/dist/ingestion/correlation.js.map +1 -0
  66. package/dist/ingestion/index.d.ts +16 -0
  67. package/dist/ingestion/index.js +15 -0
  68. package/dist/ingestion/index.js.map +1 -0
  69. package/dist/ingestion/ingest.d.ts +196 -0
  70. package/dist/ingestion/ingest.js +275 -0
  71. package/dist/ingestion/ingest.js.map +1 -0
  72. package/dist/migration/index.d.ts +10 -0
  73. package/dist/migration/index.js +10 -0
  74. package/dist/migration/index.js.map +1 -0
  75. package/dist/migration/migrate.d.ts +119 -0
  76. package/dist/migration/migrate.js +195 -0
  77. package/dist/migration/migrate.js.map +1 -0
  78. package/dist/model/casetype.d.ts +53 -0
  79. package/dist/model/casetype.js +51 -0
  80. package/dist/model/casetype.js.map +1 -0
  81. package/dist/model/errors.d.ts +28 -0
  82. package/dist/model/errors.js +39 -0
  83. package/dist/model/errors.js.map +1 -0
  84. package/dist/model/handler.d.ts +120 -0
  85. package/dist/model/handler.js +16 -0
  86. package/dist/model/handler.js.map +1 -0
  87. package/dist/model/index.d.ts +24 -0
  88. package/dist/model/index.js +19 -0
  89. package/dist/model/index.js.map +1 -0
  90. package/dist/model/retry.d.ts +40 -0
  91. package/dist/model/retry.js +51 -0
  92. package/dist/model/retry.js.map +1 -0
  93. package/dist/model/scope.d.ts +67 -0
  94. package/dist/model/scope.js +19 -0
  95. package/dist/model/scope.js.map +1 -0
  96. package/dist/model/step.d.ts +232 -0
  97. package/dist/model/step.js +210 -0
  98. package/dist/model/step.js.map +1 -0
  99. package/dist/model/target.d.ts +175 -0
  100. package/dist/model/target.js +236 -0
  101. package/dist/model/target.js.map +1 -0
  102. package/dist/store/bootstrap.d.ts +57 -0
  103. package/dist/store/bootstrap.js +268 -0
  104. package/dist/store/bootstrap.js.map +1 -0
  105. package/dist/store/errors.d.ts +18 -0
  106. package/dist/store/errors.js +26 -0
  107. package/dist/store/errors.js.map +1 -0
  108. package/dist/store/ids.d.ts +15 -0
  109. package/dist/store/ids.js +12 -0
  110. package/dist/store/ids.js.map +1 -0
  111. package/dist/store/index.d.ts +12 -0
  112. package/dist/store/index.js +9 -0
  113. package/dist/store/index.js.map +1 -0
  114. package/dist/store/queryable.d.ts +60 -0
  115. package/dist/store/queryable.js +7 -0
  116. package/dist/store/queryable.js.map +1 -0
  117. package/dist/store/resolve.d.ts +66 -0
  118. package/dist/store/resolve.js +71 -0
  119. package/dist/store/resolve.js.map +1 -0
  120. package/dist/store/sql.d.ts +26 -0
  121. package/dist/store/sql.js +21 -0
  122. package/dist/store/sql.js.map +1 -0
  123. package/dist/store/store.d.ts +66 -0
  124. package/dist/store/store.js +108 -0
  125. package/dist/store/store.js.map +1 -0
  126. package/package.json +61 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve.js","sourceRoot":"","sources":["../../src/store/resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAGH,OAAO,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAA;AAGtD,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,qBAAqB,GACtB,MAAM,YAAY,CAAA;AAcnB;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,KAAK,EACpC,UAAuB,EACvB,KAAc,EACd,OAAO,GAAG,cAAc,EACN,EAAE,CAAC,qBAAqB,CAAC,UAAU,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,CAAC,CAAA;AAE9E;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,KAAK,EACrC,UAAuB,EACvB,KAAc,EAC+B,EAAE;IAC/C,IAAI,CAAC;QACH,OAAO,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC,UAAU,EAAE,KAAK,CAAC,EAAE,CAAA;IAC9D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,KAAK,YAAY,wBAAwB;YAAE,OAAO,IAAI,CAAA;QAC1D,MAAM,KAAK,CAAA;IACb,CAAC;AACH,CAAC,CAAA;AAED,MAAM,QAAQ,GAAG,KAAK,EACpB,MAA2B,EAC3B,WAA2B,EACJ,EAAE;IACzB,MAAM,UAAU,GAAG,WAAW,CAAC,MAAM,CAAC,YAAY,CAAC,CAAA;IACnD,OAAO;QACL,UAAU;QACV,MAAM;QACN,KAAK,EAAE,MAAM,iBAAiB,CAAC,UAAU,EAAE,MAAM,CAAC,KAAK,CAAC;KACzD,CAAA;AACH,CAAC,CAAA;AAED,oGAAoG;AACpG,MAAM,CAAC,MAAM,WAAW,GAAG,KAAK,EAC9B,EAAa,EACb,WAA2B,EAC3B,MAAc,EACS,EAAE,CACzB,QAAQ,CAAC,MAAM,iBAAiB,CAAC,EAAE,EAAE,MAAM,CAAC,EAAE,WAAW,CAAC,CAAA;AAE5D;;;GAGG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,KAAK,EACvC,EAAe,EACf,WAA2B,EAC3B,MAAc,EACS,EAAE,CACzB,QAAQ,CAAC,MAAM,mBAAmB,CAAC,EAAE,EAAE,MAAM,CAAC,EAAE,WAAW,CAAC,CAAA","sourcesContent":["/**\n * Where the registry and the store meet: turning a case row into a Case Type\n * definition and a Case State that can be trusted.\n *\n * Every read path needs the same three moves, in the same order — load the\n * row, resolve `case_type` against the registered definitions, validate the\n * stored document against that definition's schema — because the schema to\n * validate against is only knowable *from* the row. What to do with a\n * document that fails validation is a real decision, so it is expressed\n * here as the interface rather than left to each caller:\n *\n * - {@link resolveCase} / {@link resolveCaseForUpdate} are **loud**. Their\n * callers were handed a case id by somebody and owe them an answer about\n * *that* case; a document that no longer validates is an app bug and says\n * so ({@link CaseStateValidationError}).\n * - {@link resolveStoredState} is **lenient**. Its callers sweep — a\n * migration scanning a case type — and one\n * unreadable case must not take the sweep down.\n *\n * The same lenient/loud pair the model draws around scope selection\n * (`addressTarget`'s value vs `resolveTarget`'s throw), for the same reason.\n */\n\nimport type { AnyCaseType } from '../model/index.js'\nimport { CaseStateValidationError } from './errors.js'\nimport type { Queryable, Transaction } from './queryable.js'\nimport type { CaseHandle } from './store.js'\nimport {\n selectCaseForUpdate,\n selectCaseUntyped,\n validateAgainstSchema,\n} from './store.js'\n\n/** Resolve a persisted `case_type` name to its registered definition; throws if unknown. */\nexport type CaseTypeLookup = (caseTypeName: string) => AnyCaseType\n\n/** A case row, its Case Type definition, and its validated Case State. */\nexport interface ResolvedCase {\n readonly definition: AnyCaseType\n /** The row as persisted. Its `state` is the raw document; prefer {@link ResolvedCase.state}. */\n readonly handle: CaseHandle<unknown>\n /** The stored Case State, validated against the definition's schema (defaults applied). */\n readonly state: unknown\n}\n\n/**\n * Validate a Case State document against a Case Type's schema, loudly.\n *\n * `context` names what is being validated, and lands in the error message:\n * `'stored state'` for a document read back, `\"state returned by step 'x'\"`\n * for a handler's return. One function, because \"does this document satisfy\n * the case type\" is one question however the document was obtained.\n */\nexport const validateCaseState = async (\n definition: AnyCaseType,\n value: unknown,\n context = 'stored state',\n): Promise<unknown> => validateAgainstSchema(definition.state, value, context)\n\n/**\n * Validate a Case State document already in hand, leniently: `null` when it\n * no longer satisfies its Case Type's schema.\n *\n * The lenient twin of {@link validateCaseState} — literally: the same single\n * Standard-Schema invocation (`validateAgainstSchema`), with the loud\n * verdict absorbed. Only the validation verdict is absorbed; a schema whose\n * `validate` itself throws is a definition bug and stays loud.\n *\n * Wrapped in an object rather than returned bare, because a valid Case State\n * may legitimately *be* `null` and a sweep must not confuse the two.\n */\nexport const resolveStoredState = async (\n definition: AnyCaseType,\n value: unknown,\n): Promise<{ readonly state: unknown } | null> => {\n try {\n return { state: await validateCaseState(definition, value) }\n } catch (error) {\n if (error instanceof CaseStateValidationError) return null\n throw error\n }\n}\n\nconst resolved = async (\n handle: CaseHandle<unknown>,\n caseTypeFor: CaseTypeLookup,\n): Promise<ResolvedCase> => {\n const definition = caseTypeFor(handle.caseTypeName)\n return {\n definition,\n handle,\n state: await validateCaseState(definition, handle.state),\n }\n}\n\n/** Load a case and resolve it against the registered definitions. Loud — see this module's note. */\nexport const resolveCase = async (\n db: Queryable,\n caseTypeFor: CaseTypeLookup,\n caseId: string,\n): Promise<ResolvedCase> =>\n resolved(await selectCaseUntyped(db, caseId), caseTypeFor)\n\n/**\n * {@link resolveCase} taking the case row's lock — the execution lifecycle's\n * serialization point.\n */\nexport const resolveCaseForUpdate = async (\n tx: Transaction,\n caseTypeFor: CaseTypeLookup,\n caseId: string,\n): Promise<ResolvedCase> =>\n resolved(await selectCaseForUpdate(tx, caseId), caseTypeFor)\n"]}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The one idiom behind every filtered read in the package: a `where` clause
3
+ * assembled from a list of AND-ed conditions and a positional values array.
4
+ * The journal, the dead-letter surface, and the migration candidate query
5
+ * each read a different table, but they all number their placeholders and
6
+ * join their conditions the same way — stated once here, so a new filter
7
+ * cannot number a placeholder wrong.
8
+ */
9
+ /** A `where` clause under assembly. */
10
+ export interface SqlWhere {
11
+ /** The AND-ed conditions; push to add one. */
12
+ readonly conditions: string[];
13
+ /** The bound values, positionally matching the `$n` placeholders. */
14
+ readonly values: unknown[];
15
+ /** Append a value and return its `$n` placeholder. */
16
+ readonly bind: (value: unknown) => string;
17
+ /** The conditions joined with ` and ` — the body of the `where` clause. */
18
+ readonly where: () => string;
19
+ }
20
+ /**
21
+ * Start a `where` clause. Fixed conditions and their values are the seed —
22
+ * a seeded condition may spell its own `$n` as long as it matches the
23
+ * value's position — and optional conditions are pushed with placeholders
24
+ * from `bind`.
25
+ */
26
+ export declare const sqlWhere: (conditions?: string[], values?: unknown[]) => SqlWhere;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The one idiom behind every filtered read in the package: a `where` clause
3
+ * assembled from a list of AND-ed conditions and a positional values array.
4
+ * The journal, the dead-letter surface, and the migration candidate query
5
+ * each read a different table, but they all number their placeholders and
6
+ * join their conditions the same way — stated once here, so a new filter
7
+ * cannot number a placeholder wrong.
8
+ */
9
+ /**
10
+ * Start a `where` clause. Fixed conditions and their values are the seed —
11
+ * a seeded condition may spell its own `$n` as long as it matches the
12
+ * value's position — and optional conditions are pushed with placeholders
13
+ * from `bind`.
14
+ */
15
+ export const sqlWhere = (conditions = [], values = []) => ({
16
+ conditions,
17
+ values,
18
+ bind: (value) => `$${values.push(value)}`,
19
+ where: () => conditions.join(' and '),
20
+ });
21
+ //# sourceMappingURL=sql.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sql.js","sourceRoot":"","sources":["../../src/store/sql.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAcH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,CACtB,aAAuB,EAAE,EACzB,SAAoB,EAAE,EACZ,EAAE,CAAC,CAAC;IACd,UAAU;IACV,MAAM;IACN,IAAI,EAAE,CAAC,KAAc,EAAU,EAAE,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE;IAC1D,KAAK,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC;CACtC,CAAC,CAAA","sourcesContent":["/**\n * The one idiom behind every filtered read in the package: a `where` clause\n * assembled from a list of AND-ed conditions and a positional values array.\n * The journal, the dead-letter surface, and the migration candidate query\n * each read a different table, but they all number their placeholders and\n * join their conditions the same way — stated once here, so a new filter\n * cannot number a placeholder wrong.\n */\n\n/** A `where` clause under assembly. */\nexport interface SqlWhere {\n /** The AND-ed conditions; push to add one. */\n readonly conditions: string[]\n /** The bound values, positionally matching the `$n` placeholders. */\n readonly values: unknown[]\n /** Append a value and return its `$n` placeholder. */\n readonly bind: (value: unknown) => string\n /** The conditions joined with ` and ` — the body of the `where` clause. */\n readonly where: () => string\n}\n\n/**\n * Start a `where` clause. Fixed conditions and their values are the seed —\n * a seeded condition may spell its own `$n` as long as it matches the\n * value's position — and optional conditions are pushed with placeholders\n * from `bind`.\n */\nexport const sqlWhere = (\n conditions: string[] = [],\n values: unknown[] = [],\n): SqlWhere => ({\n conditions,\n values,\n bind: (value: unknown): string => `$${values.push(value)}`,\n where: () => conditions.join(' and '),\n})\n"]}
@@ -0,0 +1,66 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ import type { Queryable, Transaction } from './queryable.js';
3
+ /**
4
+ * A typed handle to one Case as persisted: the materialized Case State plus
5
+ * the row-level bookkeeping the engine builds on.
6
+ */
7
+ export interface CaseHandle<State> {
8
+ /** Case id — a UUID generated by the store on creation. */
9
+ id: string;
10
+ /** Name of the Case Type this case is an instance of. */
11
+ caseTypeName: string;
12
+ /** The materialized Case State document, validated against the schema. */
13
+ state: State;
14
+ /** Per-case monotonic sequence counter, 0 at creation, bumped by every committed Execution. */
15
+ seq: number;
16
+ /** Dormancy marker set by `end()`; null while the case is active. */
17
+ endedAt: Date | null;
18
+ createdAt: Date;
19
+ updatedAt: Date;
20
+ }
21
+ /**
22
+ * Validate a document against a state schema, loudly. The one implementation
23
+ * of "this document must satisfy this schema or the caller hears about it" —
24
+ * `resolve.ts` layers the case-type registry on top of it, and everything
25
+ * that validates a Case State goes through one of the two.
26
+ */
27
+ export declare const validateAgainstSchema: <S extends StandardSchemaV1>(schema: S, value: unknown, context: string) => Promise<StandardSchemaV1.InferOutput<S>>;
28
+ /**
29
+ * `createCase` against an explicit {@link Queryable} — the shared-transaction
30
+ * seam. State is stringified explicitly so array-rooted documents are stored
31
+ * as jsonb rather than misread as Postgres arrays.
32
+ */
33
+ export declare const insertCase: <S extends StandardSchemaV1>(db: Queryable, caseTypeName: string, stateSchema: S, initialState: StandardSchemaV1.InferInput<S>) => Promise<CaseHandle<StandardSchemaV1.InferOutput<S>>>;
34
+ /** `loadCase` against an explicit {@link Queryable} — the shared-transaction seam. */
35
+ export declare const selectCase: <S extends StandardSchemaV1>(db: Queryable, id: string, stateSchema: S) => Promise<CaseHandle<StandardSchemaV1.InferOutput<S>>>;
36
+ /**
37
+ * Load a Case by id with the stored state **unvalidated** (`unknown`).
38
+ *
39
+ * Additive export for the engine: the engine learns which state
40
+ * schema applies only *from* the loaded row — `case_type` names the
41
+ * registered case type — so it must read the row before it can validate.
42
+ * Every other caller should prefer {@link selectCase} / `loadCase`, which
43
+ * validate; whoever consumes this handle owns validating `state` against the
44
+ * type's schema before trusting it.
45
+ */
46
+ export declare const selectCaseUntyped: (db: Queryable, id: string) => Promise<CaseHandle<unknown>>;
47
+ /**
48
+ * Load a Case by id **for update** — `select … for update`, so the row lock is
49
+ * held until the calling transaction ends.
50
+ *
51
+ * This is the execution lifecycle's serialization point: the claim and the
52
+ * commit both take the case row's lock first, which is what makes the
53
+ * one-in-flight-execution check (and an expired claim's takeover) a decision
54
+ * no two transactions can make concurrently.
55
+ */
56
+ export declare const selectCaseForUpdate: (tx: Transaction, id: string) => Promise<CaseHandle<unknown>>;
57
+ /** The dormancy transition a committing Execution applies to the case row (`end()` / `reopen()`). */
58
+ export type Dormancy = 'ended' | 'reopened';
59
+ /**
60
+ * Write the next Case State, bump `seq`, and apply the Execution's dormancy
61
+ * transition, if any: `'ended'` stamps `ended_at`, `'reopened'` clears it,
62
+ * `null` leaves it exactly as it was. The state document is **not** validated
63
+ * here — the execution lifecycle validates the handler's return against the
64
+ * case type's schema before calling this, and reports a failure there.
65
+ */
66
+ export declare const updateCaseState: (tx: Transaction, id: string, state: unknown, dormancy?: Dormancy | null) => Promise<CaseHandle<unknown>>;
@@ -0,0 +1,108 @@
1
+ import { FRAMEWORK_SCHEMA } from './bootstrap.js';
2
+ import { CaseNotFoundError, CaseStateValidationError } from './errors.js';
3
+ import { mintId } from './ids.js';
4
+ const CASES = `${FRAMEWORK_SCHEMA}.cases`;
5
+ const CASE_COLUMNS = 'id, case_type, state, seq, ended_at, created_at, updated_at';
6
+ const toHandle = (row, state) => ({
7
+ id: row.id,
8
+ caseTypeName: row.case_type,
9
+ state,
10
+ // Number() is safe: seq counts executions of one human-paced case and will
11
+ // never approach 2^53.
12
+ seq: Number(row.seq),
13
+ endedAt: row.ended_at,
14
+ createdAt: row.created_at,
15
+ updatedAt: row.updated_at,
16
+ });
17
+ /**
18
+ * Validate a document against a state schema, loudly. The one implementation
19
+ * of "this document must satisfy this schema or the caller hears about it" —
20
+ * `resolve.ts` layers the case-type registry on top of it, and everything
21
+ * that validates a Case State goes through one of the two.
22
+ */
23
+ export const validateAgainstSchema = async (schema, value, context) => {
24
+ const result = await schema['~standard'].validate(value);
25
+ if (result.issues)
26
+ throw new CaseStateValidationError(context, result.issues);
27
+ return result.value;
28
+ };
29
+ /**
30
+ * `createCase` against an explicit {@link Queryable} — the shared-transaction
31
+ * seam. State is stringified explicitly so array-rooted documents are stored
32
+ * as jsonb rather than misread as Postgres arrays.
33
+ */
34
+ export const insertCase = async (db, caseTypeName, stateSchema, initialState) => {
35
+ const state = await validateAgainstSchema(stateSchema, initialState, 'initial state');
36
+ const id = mintId('case');
37
+ const { rows } = await db.query(`insert into ${CASES} (id, case_type, state)
38
+ values ($1, $2, $3::jsonb)
39
+ returning ${CASE_COLUMNS}`, [id, caseTypeName, JSON.stringify(state)]);
40
+ const row = rows[0];
41
+ if (!row)
42
+ throw new Error(`insert into ${CASES} returned no row`);
43
+ return toHandle(row, state);
44
+ };
45
+ /** `loadCase` against an explicit {@link Queryable} — the shared-transaction seam. */
46
+ export const selectCase = async (db, id, stateSchema) => {
47
+ const handle = await selectCaseUntyped(db, id);
48
+ const state = await validateAgainstSchema(stateSchema, handle.state, 'stored state');
49
+ return { ...handle, state };
50
+ };
51
+ /**
52
+ * Load a Case by id with the stored state **unvalidated** (`unknown`).
53
+ *
54
+ * Additive export for the engine: the engine learns which state
55
+ * schema applies only *from* the loaded row — `case_type` names the
56
+ * registered case type — so it must read the row before it can validate.
57
+ * Every other caller should prefer {@link selectCase} / `loadCase`, which
58
+ * validate; whoever consumes this handle owns validating `state` against the
59
+ * type's schema before trusting it.
60
+ */
61
+ export const selectCaseUntyped = async (db, id) => {
62
+ const { rows } = await db.query(`select ${CASE_COLUMNS} from ${CASES} where id = $1`, [id]);
63
+ const row = rows[0];
64
+ if (!row)
65
+ throw new CaseNotFoundError(id);
66
+ return toHandle(row, row.state);
67
+ };
68
+ /**
69
+ * Load a Case by id **for update** — `select … for update`, so the row lock is
70
+ * held until the calling transaction ends.
71
+ *
72
+ * This is the execution lifecycle's serialization point: the claim and the
73
+ * commit both take the case row's lock first, which is what makes the
74
+ * one-in-flight-execution check (and an expired claim's takeover) a decision
75
+ * no two transactions can make concurrently.
76
+ */
77
+ export const selectCaseForUpdate = async (tx, id) => {
78
+ const { rows } = await tx.query(`select ${CASE_COLUMNS} from ${CASES} where id = $1 for update`, [id]);
79
+ const row = rows[0];
80
+ if (!row)
81
+ throw new CaseNotFoundError(id);
82
+ return toHandle(row, row.state);
83
+ };
84
+ /**
85
+ * Write the next Case State, bump `seq`, and apply the Execution's dormancy
86
+ * transition, if any: `'ended'` stamps `ended_at`, `'reopened'` clears it,
87
+ * `null` leaves it exactly as it was. The state document is **not** validated
88
+ * here — the execution lifecycle validates the handler's return against the
89
+ * case type's schema before calling this, and reports a failure there.
90
+ */
91
+ export const updateCaseState = async (tx, id, state, dormancy = null) => {
92
+ const { rows } = await tx.query(`update ${CASES}
93
+ set state = $2::jsonb,
94
+ seq = seq + 1,
95
+ ended_at = case
96
+ when $3::text = 'ended' then now()
97
+ when $3::text = 'reopened' then null
98
+ else ended_at
99
+ end,
100
+ updated_at = now()
101
+ where id = $1
102
+ returning ${CASE_COLUMNS}`, [id, JSON.stringify(state), dormancy]);
103
+ const row = rows[0];
104
+ if (!row)
105
+ throw new CaseNotFoundError(id);
106
+ return toHandle(row, row.state);
107
+ };
108
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/store/store.ts"],"names":[],"mappings":"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","sourcesContent":["import type { StandardSchemaV1 } from '@standard-schema/spec'\nimport { FRAMEWORK_SCHEMA } from './bootstrap.js'\nimport { CaseNotFoundError, CaseStateValidationError } from './errors.js'\nimport { mintId } from './ids.js'\nimport type { Queryable, Transaction } from './queryable.js'\n\nconst CASES = `${FRAMEWORK_SCHEMA}.cases`\nconst CASE_COLUMNS =\n 'id, case_type, state, seq, ended_at, created_at, updated_at'\n\n/**\n * A typed handle to one Case as persisted: the materialized Case State plus\n * the row-level bookkeeping the engine builds on.\n */\nexport interface CaseHandle<State> {\n /** Case id — a UUID generated by the store on creation. */\n id: string\n /** Name of the Case Type this case is an instance of. */\n caseTypeName: string\n /** The materialized Case State document, validated against the schema. */\n state: State\n /** Per-case monotonic sequence counter, 0 at creation, bumped by every committed Execution. */\n seq: number\n /** Dormancy marker set by `end()`; null while the case is active. */\n endedAt: Date | null\n createdAt: Date\n updatedAt: Date\n}\n\n/** Row shape of `affordance.cases` (int8 arrives as text from the pg driver). */\ntype CaseRow = {\n id: string\n case_type: string\n state: unknown\n seq: string | number\n ended_at: Date | null\n created_at: Date\n updated_at: Date\n}\n\nconst toHandle = <State>(row: CaseRow, state: State): CaseHandle<State> => ({\n id: row.id,\n caseTypeName: row.case_type,\n state,\n // Number() is safe: seq counts executions of one human-paced case and will\n // never approach 2^53.\n seq: Number(row.seq),\n endedAt: row.ended_at,\n createdAt: row.created_at,\n updatedAt: row.updated_at,\n})\n\n/**\n * Validate a document against a state schema, loudly. The one implementation\n * of \"this document must satisfy this schema or the caller hears about it\" —\n * `resolve.ts` layers the case-type registry on top of it, and everything\n * that validates a Case State goes through one of the two.\n */\nexport const validateAgainstSchema = async <S extends StandardSchemaV1>(\n schema: S,\n value: unknown,\n context: string,\n): Promise<StandardSchemaV1.InferOutput<S>> => {\n const result = await schema['~standard'].validate(value)\n if (result.issues) throw new CaseStateValidationError(context, result.issues)\n return result.value as StandardSchemaV1.InferOutput<S>\n}\n\n/**\n * `createCase` against an explicit {@link Queryable} — the shared-transaction\n * seam. State is stringified explicitly so array-rooted documents are stored\n * as jsonb rather than misread as Postgres arrays.\n */\nexport const insertCase = async <S extends StandardSchemaV1>(\n db: Queryable,\n caseTypeName: string,\n stateSchema: S,\n initialState: StandardSchemaV1.InferInput<S>,\n): Promise<CaseHandle<StandardSchemaV1.InferOutput<S>>> => {\n const state = await validateAgainstSchema(\n stateSchema,\n initialState,\n 'initial state',\n )\n const id = mintId('case')\n const { rows } = await db.query<CaseRow>(\n `insert into ${CASES} (id, case_type, state)\n values ($1, $2, $3::jsonb)\n returning ${CASE_COLUMNS}`,\n [id, caseTypeName, JSON.stringify(state)],\n )\n const row = rows[0]\n if (!row) throw new Error(`insert into ${CASES} returned no row`)\n return toHandle(row, state)\n}\n\n/** `loadCase` against an explicit {@link Queryable} — the shared-transaction seam. */\nexport const selectCase = async <S extends StandardSchemaV1>(\n db: Queryable,\n id: string,\n stateSchema: S,\n): Promise<CaseHandle<StandardSchemaV1.InferOutput<S>>> => {\n const handle = await selectCaseUntyped(db, id)\n const state = await validateAgainstSchema(\n stateSchema,\n handle.state,\n 'stored state',\n )\n return { ...handle, state }\n}\n\n/**\n * Load a Case by id with the stored state **unvalidated** (`unknown`).\n *\n * Additive export for the engine: the engine learns which state\n * schema applies only *from* the loaded row — `case_type` names the\n * registered case type — so it must read the row before it can validate.\n * Every other caller should prefer {@link selectCase} / `loadCase`, which\n * validate; whoever consumes this handle owns validating `state` against the\n * type's schema before trusting it.\n */\nexport const selectCaseUntyped = async (\n db: Queryable,\n id: string,\n): Promise<CaseHandle<unknown>> => {\n const { rows } = await db.query<CaseRow>(\n `select ${CASE_COLUMNS} from ${CASES} where id = $1`,\n [id],\n )\n const row = rows[0]\n if (!row) throw new CaseNotFoundError(id)\n return toHandle(row, row.state)\n}\n\n/**\n * Load a Case by id **for update** — `select … for update`, so the row lock is\n * held until the calling transaction ends.\n *\n * This is the execution lifecycle's serialization point: the claim and the\n * commit both take the case row's lock first, which is what makes the\n * one-in-flight-execution check (and an expired claim's takeover) a decision\n * no two transactions can make concurrently.\n */\nexport const selectCaseForUpdate = async (\n tx: Transaction,\n id: string,\n): Promise<CaseHandle<unknown>> => {\n const { rows } = await tx.query<CaseRow>(\n `select ${CASE_COLUMNS} from ${CASES} where id = $1 for update`,\n [id],\n )\n const row = rows[0]\n if (!row) throw new CaseNotFoundError(id)\n return toHandle(row, row.state)\n}\n\n/** The dormancy transition a committing Execution applies to the case row (`end()` / `reopen()`). */\nexport type Dormancy = 'ended' | 'reopened'\n\n/**\n * Write the next Case State, bump `seq`, and apply the Execution's dormancy\n * transition, if any: `'ended'` stamps `ended_at`, `'reopened'` clears it,\n * `null` leaves it exactly as it was. The state document is **not** validated\n * here — the execution lifecycle validates the handler's return against the\n * case type's schema before calling this, and reports a failure there.\n */\nexport const updateCaseState = async (\n tx: Transaction,\n id: string,\n state: unknown,\n dormancy: Dormancy | null = null,\n): Promise<CaseHandle<unknown>> => {\n const { rows } = await tx.query<CaseRow>(\n `update ${CASES}\n set state = $2::jsonb,\n seq = seq + 1,\n ended_at = case\n when $3::text = 'ended' then now()\n when $3::text = 'reopened' then null\n else ended_at\n end,\n updated_at = now()\n where id = $1\n returning ${CASE_COLUMNS}`,\n [id, JSON.stringify(state), dormancy],\n )\n const row = rows[0]\n if (!row) throw new CaseNotFoundError(id)\n return toHandle(row, row.state)\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@affordance/core",
3
+ "version": "0.1.0",
4
+ "author": "Mochicode LLC (https://mochicode.com)",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "dependencies": {
14
+ "@standard-schema/spec": "^1.1.0",
15
+ "@types/pg": "^8.11.10",
16
+ "@affordance/contract": "0.1.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^24.0.0",
20
+ "typescript": "^5.7.2",
21
+ "vitest": "^3.2.0",
22
+ "pg": "^8.13.1",
23
+ "zod": "^4.4.3",
24
+ "@affordance/testkit": "0.0.0"
25
+ },
26
+ "description": "Adaptive case management with guarded steps, async execution, and a Postgres journal.",
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "engines": {
35
+ "node": ">=22.12"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/mochicodecom/affordance.git",
40
+ "directory": "packages/core"
41
+ },
42
+ "homepage": "https://github.com/mochicodecom/affordance#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/mochicodecom/affordance/issues"
45
+ },
46
+ "keywords": [
47
+ "affordance",
48
+ "case-management",
49
+ "typescript",
50
+ "postgres"
51
+ ],
52
+ "publishConfig": {
53
+ "access": "public",
54
+ "registry": "https://registry.npmjs.org"
55
+ },
56
+ "scripts": {
57
+ "typecheck": "tsc --noEmit",
58
+ "test": "vitest run",
59
+ "build": "node ../../scripts/build-package.mjs"
60
+ }
61
+ }