@emilia-protocol/gate 0.13.0 → 0.15.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 (57) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +89 -0
  3. package/aeb-consumption-store.js +3 -0
  4. package/dist/aeb-consumption-store.d.ts +80 -0
  5. package/dist/aeb-consumption-store.d.ts.map +1 -0
  6. package/dist/aeb-consumption-store.js +430 -0
  7. package/dist/aeb-consumption-store.js.map +1 -0
  8. package/dist/capability-receipt.d.ts +8 -0
  9. package/dist/capability-receipt.d.ts.map +1 -1
  10. package/dist/capability-receipt.js +17 -0
  11. package/dist/capability-receipt.js.map +1 -1
  12. package/dist/index.d.ts +13 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +17 -4
  15. package/dist/index.js.map +1 -1
  16. package/dist/proposal-to-effect-postgres.d.ts +129 -0
  17. package/dist/proposal-to-effect-postgres.d.ts.map +1 -0
  18. package/dist/proposal-to-effect-postgres.js +1511 -0
  19. package/dist/proposal-to-effect-postgres.js.map +1 -0
  20. package/dist/proposal-to-effect-status.d.ts +55 -0
  21. package/dist/proposal-to-effect-status.d.ts.map +1 -0
  22. package/dist/proposal-to-effect-status.js +280 -0
  23. package/dist/proposal-to-effect-status.js.map +1 -0
  24. package/dist/proposal-to-effect.d.ts +294 -0
  25. package/dist/proposal-to-effect.d.ts.map +1 -0
  26. package/dist/proposal-to-effect.js +970 -0
  27. package/dist/proposal-to-effect.js.map +1 -0
  28. package/dist/remedy-case-set-postgres.d.ts +73 -0
  29. package/dist/remedy-case-set-postgres.d.ts.map +1 -0
  30. package/dist/remedy-case-set-postgres.js +846 -0
  31. package/dist/remedy-case-set-postgres.js.map +1 -0
  32. package/dist/remedy-case-set.d.ts +53 -0
  33. package/dist/remedy-case-set.d.ts.map +1 -0
  34. package/dist/remedy-case-set.js +571 -0
  35. package/dist/remedy-case-set.js.map +1 -0
  36. package/dist/remedy-program-adapters.d.ts +137 -0
  37. package/dist/remedy-program-adapters.d.ts.map +1 -0
  38. package/dist/remedy-program-adapters.js +590 -0
  39. package/dist/remedy-program-adapters.js.map +1 -0
  40. package/dist/trust-program-revocation.js.map +1 -1
  41. package/package.json +280 -63
  42. package/proposal-to-effect-postgres.js +3 -0
  43. package/proposal-to-effect-status.js +2 -0
  44. package/proposal-to-effect.js +4 -0
  45. package/remedy-case-set-postgres.js +3 -0
  46. package/remedy-case-set.js +3 -0
  47. package/remedy-program-adapters.js +3 -0
  48. package/src/aeb-consumption-store.ts +521 -0
  49. package/src/capability-receipt.ts +17 -0
  50. package/src/index.ts +57 -3
  51. package/src/proposal-to-effect-postgres.ts +1740 -0
  52. package/src/proposal-to-effect-status.ts +357 -0
  53. package/src/proposal-to-effect.ts +1276 -0
  54. package/src/remedy-case-set-postgres.ts +1008 -0
  55. package/src/remedy-case-set.ts +603 -0
  56. package/src/remedy-program-adapters.ts +657 -0
  57. package/src/trust-program-revocation.ts +1 -1
@@ -0,0 +1,1008 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Tenant-scoped PostgreSQL custody for Remedy Program case sets.
4
+ *
5
+ * The current row and its append-only state events are changed in one pinned
6
+ * transaction. PostgreSQL supplies the transaction clock, while owner-digest
7
+ * and revision fences are checked under a row lock and repeated in the UPDATE.
8
+ * Malformed or ambiguous database outcomes throw so callers fail closed.
9
+ */
10
+ import { createHash } from 'node:crypto';
11
+
12
+ import { canonicalize } from '../execution-binding.js';
13
+ import type {
14
+ RemedyCaseSetResult,
15
+ RemedyCaseSetState,
16
+ RemedyCaseSetStore,
17
+ } from './remedy-case-set.js';
18
+
19
+ export const REMEDY_CASE_SET_PG_STORE_VERSION = 'EP-GATE-REMEDY-CASE-SET-PG-STORE-v1';
20
+ export const REMEDY_CASE_SET_TABLE = 'ep_remedy_case_sets';
21
+ export const REMEDY_CASE_SET_EVENT_TABLE = 'ep_remedy_case_set_events';
22
+ export const REMEDY_CASE_SET_EXECUTOR_ROLE = 'ep_remedy_executor';
23
+ export const REMEDY_CASE_SET_OWNER_ROLE = 'ep_remedy_store_owner';
24
+ export const REMEDY_CASE_SET_MAX_STATE_BYTES = 4 * 1024 * 1024;
25
+ export const REMEDY_CASE_SET_MAX_MANIFEST_BYTES = 1024 * 1024;
26
+ export const REMEDY_CASE_SET_MAX_FORWARD_SKEW_MINUTES = 5;
27
+
28
+ const CASE_SET_VERSION = 'EP-GATE-REMEDY-CASE-SET-v1';
29
+ const CASE_SET_ID = /^[A-Za-z0-9][A-Za-z0-9._:/#@+-]{0,255}$/;
30
+ const DIGEST = /^sha256:[0-9a-f]{64}$/;
31
+ const STRICT_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
32
+ const STATE_KEYS = new Set([
33
+ 'version', 'tenant_id', 'case_set_id', 'status', 'revision', 'created_at',
34
+ 'updated_at', 'owner_token_digest', 'manifest', 'manifest_digest',
35
+ 'observations', 'create_request_digest', 'last_request_digest',
36
+ ]);
37
+ const MANIFEST_KEYS = new Set(['version', 'tenant_id', 'case_set_id', 'legs']);
38
+ const OBSERVATION_KEYS = new Set([
39
+ 'leg_id', 'status', 'case_revision', 'receipt_content_digest',
40
+ 'state_snapshot_digest',
41
+ ]);
42
+ const CURRENT_ROW_KEYS = new Set([
43
+ 'tenant_id', 'case_set_id', 'revision', 'status', 'owner_token_digest',
44
+ 'manifest_json', 'manifest_digest', 'state_json', 'state_digest', 'recorded_at',
45
+ ]);
46
+ const EVENT_ROW_KEYS = new Set([
47
+ 'tenant_id', 'case_set_id', 'revision', 'state_digest', 'recorded_at',
48
+ ]);
49
+ const TENANT_ROW_KEYS = new Set(['tenant_id']);
50
+ const CLOCK_ROW_KEYS = new Set(['recorded_at']);
51
+
52
+ /** Exact schema consumed by createRemedyCaseSetPostgresStore(). */
53
+ export const REMEDY_CASE_SET_POSTGRES_DDL = `CREATE TABLE IF NOT EXISTS ${REMEDY_CASE_SET_TABLE} (
54
+ tenant_id TEXT NOT NULL CHECK (octet_length(tenant_id) BETWEEN 1 AND 512),
55
+ case_set_id TEXT NOT NULL CHECK (octet_length(case_set_id) BETWEEN 1 AND 256),
56
+ revision BIGINT NOT NULL CHECK (revision >= 0),
57
+ status TEXT NOT NULL CHECK (status IN ('open', 'indeterminate', 'completed')),
58
+ owner_token_digest TEXT NOT NULL CHECK (owner_token_digest ~ '^sha256:[0-9a-f]{64}$'),
59
+ manifest_json TEXT NOT NULL,
60
+ manifest_digest TEXT NOT NULL CHECK (manifest_digest ~ '^sha256:[0-9a-f]{64}$'),
61
+ state_json TEXT NOT NULL,
62
+ state_digest TEXT NOT NULL CHECK (state_digest ~ '^sha256:[0-9a-f]{64}$'),
63
+ recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
64
+ PRIMARY KEY (tenant_id, case_set_id),
65
+ CHECK (octet_length(manifest_json) <= ${REMEDY_CASE_SET_MAX_MANIFEST_BYTES}),
66
+ CHECK (octet_length(state_json) <= ${REMEDY_CASE_SET_MAX_STATE_BYTES})
67
+ );
68
+ CREATE TABLE IF NOT EXISTS ${REMEDY_CASE_SET_EVENT_TABLE} (
69
+ tenant_id TEXT NOT NULL,
70
+ case_set_id TEXT NOT NULL,
71
+ revision BIGINT NOT NULL CHECK (revision >= 0),
72
+ previous_revision BIGINT NULL CHECK (previous_revision IS NULL OR previous_revision >= 0),
73
+ status TEXT NOT NULL CHECK (status IN ('open', 'indeterminate', 'completed')),
74
+ state_json TEXT NOT NULL,
75
+ state_digest TEXT NOT NULL CHECK (state_digest ~ '^sha256:[0-9a-f]{64}$'),
76
+ recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
77
+ PRIMARY KEY (tenant_id, case_set_id, revision),
78
+ FOREIGN KEY (tenant_id, case_set_id)
79
+ REFERENCES ${REMEDY_CASE_SET_TABLE} (tenant_id, case_set_id) ON DELETE RESTRICT,
80
+ CHECK (
81
+ (revision = 0 AND previous_revision IS NULL)
82
+ OR (revision > 0 AND previous_revision = revision - 1)
83
+ ),
84
+ CHECK (octet_length(state_json) <= ${REMEDY_CASE_SET_MAX_STATE_BYTES})
85
+ );
86
+ CREATE OR REPLACE FUNCTION ep_remedy_case_sets_guard()
87
+ RETURNS trigger
88
+ LANGUAGE plpgsql
89
+ AS $$
90
+ BEGIN
91
+ IF TG_OP = 'DELETE' THEN
92
+ RAISE EXCEPTION 'remedy case sets cannot be deleted' USING ERRCODE = 'check_violation';
93
+ END IF;
94
+ IF OLD.tenant_id IS DISTINCT FROM NEW.tenant_id
95
+ OR OLD.case_set_id IS DISTINCT FROM NEW.case_set_id
96
+ OR OLD.owner_token_digest IS DISTINCT FROM NEW.owner_token_digest
97
+ OR OLD.manifest_json IS DISTINCT FROM NEW.manifest_json
98
+ OR OLD.manifest_digest IS DISTINCT FROM NEW.manifest_digest THEN
99
+ RAISE EXCEPTION 'remedy case-set identity, owner, and manifest are immutable'
100
+ USING ERRCODE = 'check_violation';
101
+ END IF;
102
+ IF OLD.status = 'completed' THEN
103
+ RAISE EXCEPTION 'completed remedy case sets are immutable'
104
+ USING ERRCODE = 'check_violation';
105
+ END IF;
106
+ IF NEW.revision <> OLD.revision + 1 THEN
107
+ RAISE EXCEPTION 'remedy case-set revision must advance by one'
108
+ USING ERRCODE = 'check_violation';
109
+ END IF;
110
+ IF NEW.status = 'open' THEN
111
+ RAISE EXCEPTION 'remedy case sets cannot reopen'
112
+ USING ERRCODE = 'check_violation';
113
+ END IF;
114
+ RETURN NEW;
115
+ END;
116
+ $$;
117
+ DROP TRIGGER IF EXISTS ep_remedy_case_sets_guard_trigger ON ${REMEDY_CASE_SET_TABLE};
118
+ CREATE TRIGGER ep_remedy_case_sets_guard_trigger
119
+ BEFORE UPDATE OR DELETE ON ${REMEDY_CASE_SET_TABLE}
120
+ FOR EACH ROW EXECUTE FUNCTION ep_remedy_case_sets_guard();
121
+ CREATE OR REPLACE FUNCTION ep_remedy_case_set_events_immutable()
122
+ RETURNS trigger
123
+ LANGUAGE plpgsql
124
+ AS $$
125
+ BEGIN
126
+ RAISE EXCEPTION 'remedy case-set events are immutable'
127
+ USING ERRCODE = 'check_violation';
128
+ END;
129
+ $$;
130
+ DROP TRIGGER IF EXISTS ep_remedy_case_set_events_immutable_trigger
131
+ ON ${REMEDY_CASE_SET_EVENT_TABLE};
132
+ CREATE TRIGGER ep_remedy_case_set_events_immutable_trigger
133
+ BEFORE UPDATE OR DELETE ON ${REMEDY_CASE_SET_EVENT_TABLE}
134
+ FOR EACH ROW EXECUTE FUNCTION ep_remedy_case_set_events_immutable();
135
+ ALTER TABLE ${REMEDY_CASE_SET_TABLE} ENABLE ROW LEVEL SECURITY;
136
+ ALTER TABLE ${REMEDY_CASE_SET_EVENT_TABLE} ENABLE ROW LEVEL SECURITY;
137
+ ALTER TABLE ${REMEDY_CASE_SET_TABLE} FORCE ROW LEVEL SECURITY;
138
+ ALTER TABLE ${REMEDY_CASE_SET_EVENT_TABLE} FORCE ROW LEVEL SECURITY;
139
+ DO $roles$
140
+ BEGIN
141
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${REMEDY_CASE_SET_EXECUTOR_ROLE}') THEN
142
+ CREATE ROLE ${REMEDY_CASE_SET_EXECUTOR_ROLE} NOLOGIN
143
+ NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
144
+ END IF;
145
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${REMEDY_CASE_SET_OWNER_ROLE}') THEN
146
+ CREATE ROLE ${REMEDY_CASE_SET_OWNER_ROLE} NOLOGIN
147
+ NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
148
+ END IF;
149
+ END
150
+ $roles$;
151
+ GRANT ${REMEDY_CASE_SET_OWNER_ROLE} TO CURRENT_USER;
152
+ CREATE SCHEMA IF NOT EXISTS ep_remedy_private;
153
+ REVOKE ALL ON SCHEMA ep_remedy_private FROM PUBLIC, anon, authenticated, service_role;
154
+ CREATE TABLE IF NOT EXISTS ep_remedy_private.tenant_principals (
155
+ principal_name NAME NOT NULL,
156
+ tenant_id TEXT NOT NULL CHECK (octet_length(tenant_id) BETWEEN 1 AND 512),
157
+ PRIMARY KEY (principal_name, tenant_id)
158
+ );
159
+ ALTER SCHEMA ep_remedy_private OWNER TO ${REMEDY_CASE_SET_OWNER_ROLE};
160
+ ALTER TABLE ep_remedy_private.tenant_principals OWNER TO ${REMEDY_CASE_SET_OWNER_ROLE};
161
+ ALTER TABLE ${REMEDY_CASE_SET_TABLE} OWNER TO ${REMEDY_CASE_SET_OWNER_ROLE};
162
+ ALTER TABLE ${REMEDY_CASE_SET_EVENT_TABLE} OWNER TO ${REMEDY_CASE_SET_OWNER_ROLE};
163
+ ALTER FUNCTION ep_remedy_case_sets_guard() OWNER TO ${REMEDY_CASE_SET_OWNER_ROLE};
164
+ ALTER FUNCTION ep_remedy_case_set_events_immutable() OWNER TO ${REMEDY_CASE_SET_OWNER_ROLE};
165
+ ALTER TABLE ep_remedy_private.tenant_principals ENABLE ROW LEVEL SECURITY;
166
+ ALTER TABLE ep_remedy_private.tenant_principals FORCE ROW LEVEL SECURITY;
167
+ DROP POLICY IF EXISTS ep_remedy_principals_owner_only ON ep_remedy_private.tenant_principals;
168
+ CREATE POLICY ep_remedy_principals_owner_only ON ep_remedy_private.tenant_principals
169
+ TO ${REMEDY_CASE_SET_OWNER_ROLE} USING (TRUE) WITH CHECK (TRUE);
170
+ DROP POLICY IF EXISTS ep_remedy_case_sets_tenant_policy ON ${REMEDY_CASE_SET_TABLE};
171
+ DROP POLICY IF EXISTS ep_remedy_case_sets_owner_only ON ${REMEDY_CASE_SET_TABLE};
172
+ CREATE POLICY ep_remedy_case_sets_owner_only ON ${REMEDY_CASE_SET_TABLE}
173
+ TO ${REMEDY_CASE_SET_OWNER_ROLE} USING (TRUE) WITH CHECK (TRUE);
174
+ DROP POLICY IF EXISTS ep_remedy_case_set_events_tenant_policy ON ${REMEDY_CASE_SET_EVENT_TABLE};
175
+ DROP POLICY IF EXISTS ep_remedy_case_set_events_owner_only ON ${REMEDY_CASE_SET_EVENT_TABLE};
176
+ CREATE POLICY ep_remedy_case_set_events_owner_only ON ${REMEDY_CASE_SET_EVENT_TABLE}
177
+ TO ${REMEDY_CASE_SET_OWNER_ROLE} USING (TRUE) WITH CHECK (TRUE);
178
+ REVOKE ALL ON ep_remedy_private.tenant_principals
179
+ FROM PUBLIC, anon, authenticated, service_role, ${REMEDY_CASE_SET_EXECUTOR_ROLE};
180
+ REVOKE ALL ON ${REMEDY_CASE_SET_TABLE}
181
+ FROM PUBLIC, anon, authenticated, service_role, ${REMEDY_CASE_SET_EXECUTOR_ROLE};
182
+ REVOKE ALL ON ${REMEDY_CASE_SET_EVENT_TABLE}
183
+ FROM PUBLIC, anon, authenticated, service_role, ${REMEDY_CASE_SET_EXECUTOR_ROLE};
184
+ REVOKE EXECUTE ON FUNCTION ep_remedy_case_sets_guard()
185
+ FROM PUBLIC, anon, authenticated, service_role, ${REMEDY_CASE_SET_EXECUTOR_ROLE};
186
+ REVOKE EXECUTE ON FUNCTION ep_remedy_case_set_events_immutable()
187
+ FROM PUBLIC, anon, authenticated, service_role, ${REMEDY_CASE_SET_EXECUTOR_ROLE};
188
+ CREATE OR REPLACE FUNCTION ep_remedy_private.assert_tenant_principal(p_tenant_id TEXT)
189
+ RETURNS TEXT
190
+ LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = ''
191
+ AS $fn$
192
+ DECLARE v_role_ok BOOLEAN; v_binding_ok BOOLEAN;
193
+ BEGIN
194
+ v_role_ok := pg_catalog.pg_has_role(SESSION_USER, '${REMEDY_CASE_SET_EXECUTOR_ROLE}', 'MEMBER');
195
+ SELECT EXISTS (
196
+ SELECT 1 FROM ep_remedy_private.tenant_principals AS principals
197
+ WHERE principals.principal_name = SESSION_USER AND principals.tenant_id = p_tenant_id
198
+ ) INTO v_binding_ok;
199
+ IF v_role_ok IS NOT TRUE OR v_binding_ok IS NOT TRUE THEN
200
+ RAISE EXCEPTION 'REMEDY_TENANT_PRINCIPAL_REFUSED' USING ERRCODE = '42501';
201
+ END IF;
202
+ RETURN p_tenant_id;
203
+ END
204
+ $fn$;
205
+ CREATE OR REPLACE FUNCTION ep_remedy_private.create_case_set(
206
+ p_tenant_id TEXT, p_case_set_id TEXT, p_status TEXT, p_owner_token_digest TEXT,
207
+ p_manifest_json TEXT, p_manifest_digest TEXT, p_state_json TEXT,
208
+ p_state_digest TEXT, p_recorded_at TIMESTAMPTZ
209
+ ) RETURNS TABLE(
210
+ tenant_id TEXT, case_set_id TEXT, revision BIGINT, status TEXT,
211
+ owner_token_digest TEXT, manifest_json TEXT, manifest_digest TEXT,
212
+ state_json TEXT, state_digest TEXT, recorded_at TEXT
213
+ )
214
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
215
+ AS $fn$
216
+ BEGIN
217
+ PERFORM ep_remedy_private.assert_tenant_principal(p_tenant_id);
218
+ RETURN QUERY WITH inserted AS (
219
+ INSERT INTO public.${REMEDY_CASE_SET_TABLE} (
220
+ tenant_id, case_set_id, revision, status, owner_token_digest,
221
+ manifest_json, manifest_digest, state_json, state_digest, recorded_at
222
+ ) VALUES (
223
+ p_tenant_id, p_case_set_id, 0, p_status, p_owner_token_digest,
224
+ p_manifest_json, p_manifest_digest, p_state_json, p_state_digest, p_recorded_at
225
+ ) ON CONFLICT ON CONSTRAINT ep_remedy_case_sets_pkey DO NOTHING RETURNING *
226
+ ) SELECT inserted.tenant_id, inserted.case_set_id, inserted.revision, inserted.status,
227
+ inserted.owner_token_digest, inserted.manifest_json, inserted.manifest_digest,
228
+ inserted.state_json, inserted.state_digest,
229
+ pg_catalog.to_char(inserted.recorded_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
230
+ FROM inserted;
231
+ END
232
+ $fn$;
233
+ CREATE OR REPLACE FUNCTION ep_remedy_private.get_case_set(
234
+ p_tenant_id TEXT, p_case_set_id TEXT
235
+ ) RETURNS TABLE(
236
+ tenant_id TEXT, case_set_id TEXT, revision BIGINT, status TEXT,
237
+ owner_token_digest TEXT, manifest_json TEXT, manifest_digest TEXT,
238
+ state_json TEXT, state_digest TEXT, recorded_at TEXT
239
+ )
240
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
241
+ AS $fn$
242
+ BEGIN
243
+ PERFORM ep_remedy_private.assert_tenant_principal(p_tenant_id);
244
+ RETURN QUERY SELECT current.tenant_id, current.case_set_id, current.revision, current.status,
245
+ current.owner_token_digest, current.manifest_json, current.manifest_digest,
246
+ current.state_json, current.state_digest,
247
+ pg_catalog.to_char(current.recorded_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
248
+ FROM public.${REMEDY_CASE_SET_TABLE} AS current
249
+ WHERE current.tenant_id = p_tenant_id AND current.case_set_id = p_case_set_id;
250
+ END
251
+ $fn$;
252
+ CREATE OR REPLACE FUNCTION ep_remedy_private.get_case_set_for_update(
253
+ p_tenant_id TEXT, p_case_set_id TEXT
254
+ ) RETURNS TABLE(
255
+ tenant_id TEXT, case_set_id TEXT, revision BIGINT, status TEXT,
256
+ owner_token_digest TEXT, manifest_json TEXT, manifest_digest TEXT,
257
+ state_json TEXT, state_digest TEXT, recorded_at TEXT
258
+ )
259
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
260
+ AS $fn$
261
+ BEGIN
262
+ PERFORM ep_remedy_private.assert_tenant_principal(p_tenant_id);
263
+ RETURN QUERY SELECT current.tenant_id, current.case_set_id, current.revision, current.status,
264
+ current.owner_token_digest, current.manifest_json, current.manifest_digest,
265
+ current.state_json, current.state_digest,
266
+ pg_catalog.to_char(current.recorded_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
267
+ FROM public.${REMEDY_CASE_SET_TABLE} AS current
268
+ WHERE current.tenant_id = p_tenant_id AND current.case_set_id = p_case_set_id
269
+ FOR UPDATE;
270
+ END
271
+ $fn$;
272
+ CREATE OR REPLACE FUNCTION ep_remedy_private.compare_and_swap_case_set(
273
+ p_tenant_id TEXT, p_case_set_id TEXT, p_expected_revision BIGINT,
274
+ p_owner_token_digest TEXT, p_next_revision BIGINT, p_status TEXT,
275
+ p_state_json TEXT, p_state_digest TEXT, p_recorded_at TIMESTAMPTZ,
276
+ p_manifest_json TEXT, p_manifest_digest TEXT
277
+ ) RETURNS TABLE(
278
+ tenant_id TEXT, case_set_id TEXT, revision BIGINT, status TEXT,
279
+ owner_token_digest TEXT, manifest_json TEXT, manifest_digest TEXT,
280
+ state_json TEXT, state_digest TEXT, recorded_at TEXT
281
+ )
282
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
283
+ AS $fn$
284
+ BEGIN
285
+ PERFORM ep_remedy_private.assert_tenant_principal(p_tenant_id);
286
+ RETURN QUERY WITH updated AS (
287
+ UPDATE public.${REMEDY_CASE_SET_TABLE}
288
+ SET revision = p_next_revision, status = p_status, state_json = p_state_json,
289
+ state_digest = p_state_digest, recorded_at = p_recorded_at
290
+ WHERE ${REMEDY_CASE_SET_TABLE}.tenant_id = p_tenant_id
291
+ AND ${REMEDY_CASE_SET_TABLE}.case_set_id = p_case_set_id
292
+ AND ${REMEDY_CASE_SET_TABLE}.revision = p_expected_revision
293
+ AND ${REMEDY_CASE_SET_TABLE}.owner_token_digest = p_owner_token_digest
294
+ AND ${REMEDY_CASE_SET_TABLE}.status <> 'completed'
295
+ AND ${REMEDY_CASE_SET_TABLE}.manifest_json = p_manifest_json
296
+ AND ${REMEDY_CASE_SET_TABLE}.manifest_digest = p_manifest_digest
297
+ RETURNING *
298
+ ) SELECT updated.tenant_id, updated.case_set_id, updated.revision, updated.status,
299
+ updated.owner_token_digest, updated.manifest_json, updated.manifest_digest,
300
+ updated.state_json, updated.state_digest,
301
+ pg_catalog.to_char(updated.recorded_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
302
+ FROM updated;
303
+ END
304
+ $fn$;
305
+ CREATE OR REPLACE FUNCTION ep_remedy_private.append_case_set_event(
306
+ p_tenant_id TEXT, p_case_set_id TEXT, p_revision BIGINT,
307
+ p_previous_revision BIGINT, p_status TEXT, p_state_json TEXT,
308
+ p_state_digest TEXT, p_recorded_at TIMESTAMPTZ
309
+ ) RETURNS TABLE(
310
+ tenant_id TEXT, case_set_id TEXT, revision BIGINT, state_digest TEXT, recorded_at TEXT
311
+ )
312
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
313
+ AS $fn$
314
+ BEGIN
315
+ PERFORM ep_remedy_private.assert_tenant_principal(p_tenant_id);
316
+ RETURN QUERY WITH inserted AS (
317
+ INSERT INTO public.${REMEDY_CASE_SET_EVENT_TABLE} (
318
+ tenant_id, case_set_id, revision, previous_revision, status,
319
+ state_json, state_digest, recorded_at
320
+ ) VALUES (
321
+ p_tenant_id, p_case_set_id, p_revision, p_previous_revision, p_status,
322
+ p_state_json, p_state_digest, p_recorded_at
323
+ ) RETURNING *
324
+ ) SELECT inserted.tenant_id, inserted.case_set_id, inserted.revision,
325
+ inserted.state_digest,
326
+ pg_catalog.to_char(inserted.recorded_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
327
+ FROM inserted;
328
+ END
329
+ $fn$;
330
+ ALTER FUNCTION ep_remedy_private.assert_tenant_principal(TEXT)
331
+ OWNER TO ${REMEDY_CASE_SET_OWNER_ROLE};
332
+ ALTER FUNCTION ep_remedy_private.create_case_set(TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TIMESTAMPTZ)
333
+ OWNER TO ${REMEDY_CASE_SET_OWNER_ROLE};
334
+ ALTER FUNCTION ep_remedy_private.get_case_set(TEXT, TEXT)
335
+ OWNER TO ${REMEDY_CASE_SET_OWNER_ROLE};
336
+ ALTER FUNCTION ep_remedy_private.get_case_set_for_update(TEXT, TEXT)
337
+ OWNER TO ${REMEDY_CASE_SET_OWNER_ROLE};
338
+ ALTER FUNCTION ep_remedy_private.compare_and_swap_case_set(TEXT, TEXT, BIGINT, TEXT, BIGINT, TEXT, TEXT, TEXT, TIMESTAMPTZ, TEXT, TEXT)
339
+ OWNER TO ${REMEDY_CASE_SET_OWNER_ROLE};
340
+ ALTER FUNCTION ep_remedy_private.append_case_set_event(TEXT, TEXT, BIGINT, BIGINT, TEXT, TEXT, TEXT, TIMESTAMPTZ)
341
+ OWNER TO ${REMEDY_CASE_SET_OWNER_ROLE};
342
+ REVOKE ALL ON ALL FUNCTIONS IN SCHEMA ep_remedy_private
343
+ FROM PUBLIC, anon, authenticated, service_role;
344
+ GRANT USAGE ON SCHEMA ep_remedy_private TO ${REMEDY_CASE_SET_EXECUTOR_ROLE};
345
+ GRANT EXECUTE ON FUNCTION ep_remedy_private.assert_tenant_principal(TEXT),
346
+ ep_remedy_private.create_case_set(TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TIMESTAMPTZ),
347
+ ep_remedy_private.get_case_set(TEXT, TEXT),
348
+ ep_remedy_private.get_case_set_for_update(TEXT, TEXT),
349
+ ep_remedy_private.compare_and_swap_case_set(TEXT, TEXT, BIGINT, TEXT, BIGINT, TEXT, TEXT, TEXT, TIMESTAMPTZ, TEXT, TEXT),
350
+ ep_remedy_private.append_case_set_event(TEXT, TEXT, BIGINT, BIGINT, TEXT, TEXT, TEXT, TIMESTAMPTZ)
351
+ TO ${REMEDY_CASE_SET_EXECUTOR_ROLE};
352
+ REVOKE ${REMEDY_CASE_SET_OWNER_ROLE} FROM CURRENT_USER;`;
353
+
354
+ /** Exact statements issued by the store, exported for audit and deterministic fakes. */
355
+ export const REMEDY_CASE_SET_POSTGRES_SQL = Object.freeze({
356
+ setTenant: `SELECT ep_remedy_private.assert_tenant_principal($1::text) AS tenant_id`,
357
+ clock: `SELECT to_char(
358
+ transaction_timestamp() AT TIME ZONE 'UTC',
359
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
360
+ ) AS recorded_at`,
361
+ create: `SELECT * FROM ep_remedy_private.create_case_set($1::text, $2::text, $3::text, $4::text, $5::text, $6::text, $7::text, $8::text, $9::timestamptz)`,
362
+ get: `SELECT * FROM ep_remedy_private.get_case_set($1::text, $2::text)`,
363
+ getForUpdate: `SELECT * FROM ep_remedy_private.get_case_set_for_update($1::text, $2::text)`,
364
+ compareAndSwap: `SELECT * FROM ep_remedy_private.compare_and_swap_case_set($1::text, $2::text, $3::bigint, $4::text, $5::bigint, $6::text, $7::text, $8::text, $9::timestamptz, $10::text, $11::text)`,
365
+ appendEvent: `SELECT * FROM ep_remedy_private.append_case_set_event($1::text, $2::text, $3::bigint, $4::bigint, $5::text, $6::text, $7::text, $8::timestamptz)`,
366
+ });
367
+
368
+ type RecordLike = Record<string, any>;
369
+ type QueryResult = {
370
+ rowCount: number | null;
371
+ rows?: Record<string, unknown>[];
372
+ };
373
+
374
+ export type RemedyCaseSetPgClient = {
375
+ query: (text: string, params?: any[]) => Promise<QueryResult>;
376
+ release: () => void;
377
+ };
378
+
379
+ export type RemedyCaseSetPgPool = {
380
+ connect: () => Promise<RemedyCaseSetPgClient>;
381
+ };
382
+
383
+ export interface RemedyCaseSetPostgresStore extends RemedyCaseSetStore {
384
+ readonly durable: true;
385
+ create(state: RemedyCaseSetState): Promise<RemedyCaseSetResult>;
386
+ get(input: Readonly<{
387
+ tenantId: string;
388
+ caseSetId: string;
389
+ }>): Promise<RemedyCaseSetResult>;
390
+ compareAndSwap(input: Readonly<{
391
+ tenantId: string;
392
+ caseSetId: string;
393
+ expectedRevision: number;
394
+ ownerTokenDigest: string;
395
+ state: RemedyCaseSetState;
396
+ }>): Promise<RemedyCaseSetResult>;
397
+ }
398
+
399
+ type EncodedState = {
400
+ state: RemedyCaseSetState;
401
+ stateJson: string;
402
+ stateDigest: string;
403
+ manifestJson: string;
404
+ manifestDigest: string;
405
+ eventAt: string;
406
+ eventAtMs: number;
407
+ createdAt: string;
408
+ ownerTokenDigest: string;
409
+ status: 'open' | 'indeterminate' | 'completed';
410
+ };
411
+
412
+ function isDataRecord(value: unknown): value is RecordLike {
413
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
414
+ const prototype = Object.getPrototypeOf(value);
415
+ if (prototype !== Object.prototype && prototype !== null) return false;
416
+ return Reflect.ownKeys(value).every((key) => {
417
+ if (typeof key !== 'string') return false;
418
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
419
+ return descriptor?.enumerable === true && Object.hasOwn(descriptor, 'value');
420
+ });
421
+ }
422
+
423
+ function exactKeys(value: unknown, expected: ReadonlySet<string>): value is RecordLike {
424
+ return isDataRecord(value)
425
+ && Reflect.ownKeys(value).length === expected.size
426
+ && Reflect.ownKeys(value).every(
427
+ (key) => typeof key === 'string' && expected.has(key),
428
+ );
429
+ }
430
+
431
+ function assertJsonData(value: unknown, seen = new Set<object>()): void {
432
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
433
+ if (typeof value === 'number') {
434
+ if (!Number.isFinite(value)) throw new TypeError('remedy case-set state contains a non-JSON number');
435
+ return;
436
+ }
437
+ if (typeof value !== 'object' || seen.has(value)) {
438
+ throw new TypeError('remedy case-set state must be acyclic plain JSON');
439
+ }
440
+ seen.add(value);
441
+ if (Array.isArray(value)) {
442
+ const keys = Reflect.ownKeys(value);
443
+ if (Object.getPrototypeOf(value) !== Array.prototype
444
+ || keys.length !== value.length + 1
445
+ || keys.some((key) => key !== 'length'
446
+ && (typeof key !== 'string' || !/^(?:0|[1-9]\d*)$/.test(key)))) {
447
+ throw new TypeError('remedy case-set state contains an invalid JSON array');
448
+ }
449
+ for (let index = 0; index < value.length; index += 1) {
450
+ if (!Object.hasOwn(value, index)) {
451
+ throw new TypeError('remedy case-set state contains a sparse JSON array');
452
+ }
453
+ assertJsonData(value[index], seen);
454
+ }
455
+ } else {
456
+ if (!isDataRecord(value)) {
457
+ throw new TypeError('remedy case-set state contains a non-data object');
458
+ }
459
+ for (const child of Object.values(value)) assertJsonData(child, seen);
460
+ }
461
+ seen.delete(value);
462
+ }
463
+
464
+ function assertTenantId(value: unknown): asserts value is string {
465
+ if (typeof value !== 'string'
466
+ || Buffer.byteLength(value, 'utf8') < 1
467
+ || Buffer.byteLength(value, 'utf8') > 512
468
+ || /[\u0000-\u001f\u007f]/.test(value)) {
469
+ throw new TypeError('remedy case-set tenantId is invalid');
470
+ }
471
+ }
472
+
473
+ function assertCaseSetId(value: unknown): asserts value is string {
474
+ if (typeof value !== 'string' || !CASE_SET_ID.test(value)) {
475
+ throw new TypeError('remedy case-set caseSetId is invalid');
476
+ }
477
+ }
478
+
479
+ function assertRevision(value: unknown, label: string): asserts value is number {
480
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
481
+ throw new TypeError(`remedy case-set ${label} revision is invalid`);
482
+ }
483
+ }
484
+
485
+ function assertDigest(value: unknown, label: string): asserts value is string {
486
+ if (typeof value !== 'string' || !DIGEST.test(value)) {
487
+ throw new TypeError(`remedy case-set ${label} is invalid`);
488
+ }
489
+ }
490
+
491
+ function instant(value: unknown, label: string): number {
492
+ if (typeof value !== 'string' || !STRICT_INSTANT.test(value)) {
493
+ throw new TypeError(`remedy case-set ${label} is invalid`);
494
+ }
495
+ const parsed = Date.parse(value);
496
+ if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== value) {
497
+ throw new TypeError(`remedy case-set ${label} is invalid`);
498
+ }
499
+ return parsed;
500
+ }
501
+
502
+ function digestJson(value: string) {
503
+ return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
504
+ }
505
+
506
+ function validObservation(value: unknown) {
507
+ if (!exactKeys(value, OBSERVATION_KEYS)
508
+ || typeof value.leg_id !== 'string'
509
+ || !CASE_SET_ID.test(value.leg_id)
510
+ || !['pending', 'indeterminate', 'executed'].includes(value.status)) {
511
+ return false;
512
+ }
513
+ if (value.status === 'pending') {
514
+ return value.case_revision === null
515
+ && value.receipt_content_digest === null
516
+ && value.state_snapshot_digest === null;
517
+ }
518
+ return Number.isSafeInteger(value.case_revision)
519
+ && value.case_revision >= 0
520
+ && typeof value.receipt_content_digest === 'string'
521
+ && DIGEST.test(value.receipt_content_digest)
522
+ && typeof value.state_snapshot_digest === 'string'
523
+ && DIGEST.test(value.state_snapshot_digest);
524
+ }
525
+
526
+ function encodeState(
527
+ value: unknown,
528
+ tenantId: string,
529
+ caseSetId: string,
530
+ revision: number,
531
+ ): EncodedState {
532
+ assertJsonData(value);
533
+ let stateJson: string;
534
+ let state: unknown;
535
+ try {
536
+ stateJson = canonicalize(value);
537
+ state = JSON.parse(stateJson);
538
+ } catch {
539
+ throw new TypeError('remedy case-set state must be canonical JSON');
540
+ }
541
+ if (Buffer.byteLength(stateJson, 'utf8') > REMEDY_CASE_SET_MAX_STATE_BYTES) {
542
+ throw new TypeError('remedy case-set state exceeds the durable store limit');
543
+ }
544
+ if (!exactKeys(state, STATE_KEYS)
545
+ || state.version !== CASE_SET_VERSION
546
+ || state.tenant_id !== tenantId
547
+ || state.case_set_id !== caseSetId
548
+ || state.revision !== revision
549
+ || !['open', 'indeterminate', 'completed'].includes(state.status)
550
+ || !exactKeys(state.manifest, MANIFEST_KEYS)
551
+ || state.manifest.version !== CASE_SET_VERSION
552
+ || state.manifest.tenant_id !== tenantId
553
+ || state.manifest.case_set_id !== caseSetId
554
+ || !Array.isArray(state.manifest.legs)
555
+ || state.manifest.legs.length < 1
556
+ || state.manifest.legs.length > 256
557
+ || state.manifest.legs.some(
558
+ (leg: unknown) => !isDataRecord(leg)
559
+ || typeof leg.leg_id !== 'string'
560
+ || !CASE_SET_ID.test(leg.leg_id),
561
+ )
562
+ || !Array.isArray(state.observations)
563
+ || state.observations.length !== state.manifest.legs.length
564
+ || !state.observations.every(validObservation)
565
+ || state.observations.some(
566
+ (observation: RecordLike, index: number) => (
567
+ observation.leg_id !== state.manifest.legs[index].leg_id
568
+ ),
569
+ )) {
570
+ throw new TypeError('remedy case-set state binding is invalid');
571
+ }
572
+ const statuses = state.observations.map((entry: RecordLike) => entry.status);
573
+ if ((state.status === 'open' && !statuses.every((status: string) => status === 'pending'))
574
+ || (state.status === 'completed'
575
+ && !statuses.every((status: string) => status === 'executed'))
576
+ || (state.status === 'indeterminate'
577
+ && (!statuses.includes('indeterminate')
578
+ || !statuses.every((status: string) => status !== 'pending')))) {
579
+ throw new TypeError('remedy case-set status binding is invalid');
580
+ }
581
+
582
+ const createdAtMs = instant(state.created_at, 'created_at');
583
+ const eventAtMs = instant(state.updated_at, 'updated_at');
584
+ if (eventAtMs < createdAtMs) {
585
+ throw new TypeError('remedy case-set state clock regressed before creation');
586
+ }
587
+ assertDigest(state.owner_token_digest, 'owner_token_digest');
588
+ assertDigest(state.manifest_digest, 'manifest_digest');
589
+ assertDigest(state.create_request_digest, 'create_request_digest');
590
+ if (state.last_request_digest !== null) {
591
+ assertDigest(state.last_request_digest, 'last_request_digest');
592
+ }
593
+
594
+ const manifestJson = canonicalize(state.manifest);
595
+ if (Buffer.byteLength(manifestJson, 'utf8') > REMEDY_CASE_SET_MAX_MANIFEST_BYTES) {
596
+ throw new TypeError('remedy case-set manifest exceeds the durable store limit');
597
+ }
598
+ const manifestDigest = digestJson(manifestJson);
599
+ if (state.manifest_digest !== manifestDigest) {
600
+ throw new TypeError('remedy case-set manifest digest is invalid');
601
+ }
602
+ return {
603
+ state: state as RemedyCaseSetState,
604
+ stateJson,
605
+ stateDigest: digestJson(stateJson),
606
+ manifestJson,
607
+ manifestDigest,
608
+ eventAt: state.updated_at,
609
+ eventAtMs,
610
+ createdAt: state.created_at,
611
+ ownerTokenDigest: state.owner_token_digest,
612
+ status: state.status,
613
+ };
614
+ }
615
+
616
+ function safeRevision(value: unknown): number {
617
+ const revision = typeof value === 'string' && /^(?:0|[1-9]\d*)$/.test(value)
618
+ ? Number(value)
619
+ : value;
620
+ if (!Number.isSafeInteger(revision) || (revision as number) < 0) {
621
+ throw new Error('remedy case-set PostgreSQL returned an invalid revision');
622
+ }
623
+ return revision as number;
624
+ }
625
+
626
+ function strictRows(result: QueryResult, operation: string, maximum: 0 | 1) {
627
+ if (!result
628
+ || !Number.isSafeInteger(result.rowCount)
629
+ || (result.rowCount as number) < 0
630
+ || (result.rowCount as number) > maximum
631
+ || !Array.isArray(result.rows)
632
+ || result.rows.length !== result.rowCount) {
633
+ throw new Error(`remedy case-set ${operation} outcome is ambiguous`);
634
+ }
635
+ return result.rows;
636
+ }
637
+
638
+ function decodeCurrentRow(
639
+ row: unknown,
640
+ tenantId: string,
641
+ caseSetId: string,
642
+ expected?: EncodedState,
643
+ expectedRecordedAt?: string,
644
+ ): EncodedState {
645
+ if (!exactKeys(row, CURRENT_ROW_KEYS)
646
+ || row.tenant_id !== tenantId
647
+ || row.case_set_id !== caseSetId
648
+ || !['open', 'indeterminate', 'completed'].includes(row.status)
649
+ || typeof row.owner_token_digest !== 'string'
650
+ || !DIGEST.test(row.owner_token_digest)
651
+ || typeof row.manifest_json !== 'string'
652
+ || Buffer.byteLength(row.manifest_json, 'utf8') > REMEDY_CASE_SET_MAX_MANIFEST_BYTES
653
+ || typeof row.manifest_digest !== 'string'
654
+ || !DIGEST.test(row.manifest_digest)
655
+ || row.manifest_digest !== digestJson(row.manifest_json)
656
+ || typeof row.state_json !== 'string'
657
+ || Buffer.byteLength(row.state_json, 'utf8') > REMEDY_CASE_SET_MAX_STATE_BYTES
658
+ || typeof row.state_digest !== 'string'
659
+ || !DIGEST.test(row.state_digest)
660
+ || row.state_digest !== digestJson(row.state_json)) {
661
+ throw new Error('remedy case-set PostgreSQL returned an invalid state envelope');
662
+ }
663
+ const revision = safeRevision(row.revision);
664
+ let parsed: unknown;
665
+ try {
666
+ parsed = JSON.parse(row.state_json);
667
+ if (canonicalize(parsed) !== row.state_json) throw new Error('non-canonical state');
668
+ } catch {
669
+ throw new Error('remedy case-set PostgreSQL returned non-canonical state');
670
+ }
671
+ const encoded = encodeState(parsed, tenantId, caseSetId, revision);
672
+ if (encoded.status !== row.status
673
+ || encoded.ownerTokenDigest !== row.owner_token_digest
674
+ || encoded.manifestJson !== row.manifest_json
675
+ || encoded.manifestDigest !== row.manifest_digest
676
+ || encoded.stateJson !== row.state_json
677
+ || encoded.stateDigest !== row.state_digest) {
678
+ throw new Error('remedy case-set PostgreSQL returned mismatched state columns');
679
+ }
680
+ const recordedAt = instant(row.recorded_at, 'PostgreSQL recorded_at');
681
+ if (expectedRecordedAt !== undefined
682
+ && (row.recorded_at !== expectedRecordedAt
683
+ || recordedAt !== instant(expectedRecordedAt, 'transaction clock'))) {
684
+ throw new Error('remedy case-set PostgreSQL returned an unexpected transaction clock');
685
+ }
686
+ if (expected && (
687
+ encoded.stateJson !== expected.stateJson
688
+ || encoded.stateDigest !== expected.stateDigest
689
+ || encoded.manifestJson !== expected.manifestJson
690
+ || encoded.manifestDigest !== expected.manifestDigest
691
+ )) {
692
+ throw new Error('remedy case-set PostgreSQL did not persist the exact state');
693
+ }
694
+ return encoded;
695
+ }
696
+
697
+ function validateTenantContext(result: QueryResult, tenantId: string) {
698
+ const rows = strictRows(result, 'tenant context', 1);
699
+ const row = rows[0];
700
+ if (!exactKeys(row, TENANT_ROW_KEYS) || row.tenant_id !== tenantId) {
701
+ throw new Error('remedy case-set PostgreSQL tenant context is invalid');
702
+ }
703
+ }
704
+
705
+ function transactionClock(result: QueryResult) {
706
+ const rows = strictRows(result, 'transaction clock', 1);
707
+ const row = rows[0];
708
+ if (!exactKeys(row, CLOCK_ROW_KEYS)) {
709
+ throw new Error('remedy case-set PostgreSQL transaction clock is malformed');
710
+ }
711
+ return {
712
+ value: row.recorded_at as string,
713
+ milliseconds: instant(row.recorded_at, 'PostgreSQL transaction clock'),
714
+ };
715
+ }
716
+
717
+ function validateEvent(
718
+ result: QueryResult,
719
+ tenantId: string,
720
+ caseSetId: string,
721
+ revision: number,
722
+ stateDigest: string,
723
+ recordedAt: string,
724
+ ) {
725
+ const rows = strictRows(result, 'appendEvent', 1);
726
+ const row = rows[0];
727
+ if (!exactKeys(row, EVENT_ROW_KEYS)
728
+ || row.tenant_id !== tenantId
729
+ || row.case_set_id !== caseSetId
730
+ || safeRevision(row.revision) !== revision
731
+ || row.state_digest !== stateDigest
732
+ || row.recorded_at !== recordedAt
733
+ || instant(row.recorded_at, 'event recorded_at') !== Date.parse(recordedAt)) {
734
+ throw new Error('remedy case-set appendEvent outcome is malformed');
735
+ }
736
+ }
737
+
738
+ function lookupInput(value: unknown) {
739
+ if (!isDataRecord(value)
740
+ || Reflect.ownKeys(value).length !== 2
741
+ || !Object.hasOwn(value, 'tenantId')
742
+ || !Object.hasOwn(value, 'caseSetId')) {
743
+ throw new TypeError('remedy case-set lookup input is invalid');
744
+ }
745
+ assertTenantId(value.tenantId);
746
+ assertCaseSetId(value.caseSetId);
747
+ return { tenantId: value.tenantId, caseSetId: value.caseSetId };
748
+ }
749
+
750
+ function fail(reason: string): RemedyCaseSetResult {
751
+ return { ok: false, reason };
752
+ }
753
+
754
+ function assertNotForwardSkewed(eventAt: number, databaseNow: number) {
755
+ const maximum = databaseNow + REMEDY_CASE_SET_MAX_FORWARD_SKEW_MINUTES * 60_000;
756
+ if (eventAt > maximum) {
757
+ throw new TypeError('remedy case-set state clock exceeds PostgreSQL forward-skew limit');
758
+ }
759
+ }
760
+
761
+ /** Build the exact durable store consumed by createRemedyCaseSetCoordinator(). */
762
+ export function createRemedyCaseSetPostgresStore(
763
+ { pool }: { pool?: RemedyCaseSetPgPool } = {},
764
+ ): RemedyCaseSetPostgresStore {
765
+ if (!pool || typeof pool.connect !== 'function') {
766
+ throw new TypeError(
767
+ 'createRemedyCaseSetPostgresStore requires a transaction-capable pg pool',
768
+ );
769
+ }
770
+
771
+ async function transaction<T>(
772
+ readOnly: boolean,
773
+ tenantId: string,
774
+ work: (
775
+ client: RemedyCaseSetPgClient,
776
+ clock: Readonly<{ value: string; milliseconds: number }>,
777
+ ) => Promise<T>,
778
+ ): Promise<T> {
779
+ const client = await pool!.connect();
780
+ if (!client || typeof client.query !== 'function' || typeof client.release !== 'function') {
781
+ throw new TypeError('remedy case-set pg pool returned an invalid client');
782
+ }
783
+ let began = false;
784
+ try {
785
+ await client.query(readOnly
786
+ ? 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'
787
+ : 'BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE');
788
+ began = true;
789
+ validateTenantContext(
790
+ await client.query(REMEDY_CASE_SET_POSTGRES_SQL.setTenant, [tenantId]),
791
+ tenantId,
792
+ );
793
+ const clock = transactionClock(
794
+ await client.query(REMEDY_CASE_SET_POSTGRES_SQL.clock),
795
+ );
796
+ const result = await work(client, clock);
797
+ await client.query('COMMIT');
798
+ began = false;
799
+ return result;
800
+ } catch (error) {
801
+ if (began) {
802
+ try {
803
+ await client.query('ROLLBACK');
804
+ } catch (rollbackError) {
805
+ throw new AggregateError(
806
+ [error, rollbackError],
807
+ 'remedy case-set transaction and rollback both failed',
808
+ );
809
+ }
810
+ }
811
+ throw error;
812
+ } finally {
813
+ client.release();
814
+ }
815
+ }
816
+
817
+ return Object.freeze({
818
+ durable: true,
819
+ async create(state: RemedyCaseSetState) {
820
+ const tenantId = (state as RecordLike)?.tenant_id;
821
+ const caseSetId = (state as RecordLike)?.case_set_id;
822
+ assertTenantId(tenantId);
823
+ assertCaseSetId(caseSetId);
824
+ const encoded = encodeState(state, tenantId, caseSetId, 0);
825
+ if (encoded.status !== 'open') {
826
+ throw new TypeError('remedy case-set creation requires an open revision zero');
827
+ }
828
+ return transaction(false, tenantId, async (client, clock) => {
829
+ assertNotForwardSkewed(encoded.eventAtMs, clock.milliseconds);
830
+ const rows = strictRows(
831
+ await client.query(REMEDY_CASE_SET_POSTGRES_SQL.create, [
832
+ tenantId,
833
+ caseSetId,
834
+ encoded.status,
835
+ encoded.ownerTokenDigest,
836
+ encoded.manifestJson,
837
+ encoded.manifestDigest,
838
+ encoded.stateJson,
839
+ encoded.stateDigest,
840
+ clock.value,
841
+ ]),
842
+ 'create',
843
+ 1,
844
+ );
845
+ if (rows.length === 0) return fail('case_set_exists');
846
+ const stored = decodeCurrentRow(
847
+ rows[0],
848
+ tenantId,
849
+ caseSetId,
850
+ encoded,
851
+ clock.value,
852
+ );
853
+ validateEvent(
854
+ await client.query(REMEDY_CASE_SET_POSTGRES_SQL.appendEvent, [
855
+ tenantId,
856
+ caseSetId,
857
+ 0,
858
+ null,
859
+ encoded.status,
860
+ encoded.stateJson,
861
+ encoded.stateDigest,
862
+ clock.value,
863
+ ]),
864
+ tenantId,
865
+ caseSetId,
866
+ 0,
867
+ encoded.stateDigest,
868
+ clock.value,
869
+ );
870
+ return { ok: true, state: stored.state };
871
+ });
872
+ },
873
+ async get(input: { tenantId: string; caseSetId: string }) {
874
+ const { tenantId, caseSetId } = lookupInput(input);
875
+ return transaction(true, tenantId, async (client) => {
876
+ const rows = strictRows(
877
+ await client.query(REMEDY_CASE_SET_POSTGRES_SQL.get, [tenantId, caseSetId]),
878
+ 'get',
879
+ 1,
880
+ );
881
+ return rows.length === 0
882
+ ? fail('case_set_not_found')
883
+ : { ok: true, state: decodeCurrentRow(rows[0], tenantId, caseSetId).state };
884
+ });
885
+ },
886
+ async compareAndSwap(input: {
887
+ tenantId: string;
888
+ caseSetId: string;
889
+ expectedRevision: number;
890
+ ownerTokenDigest: string;
891
+ state: RemedyCaseSetState;
892
+ }) {
893
+ if (!isDataRecord(input)
894
+ || Reflect.ownKeys(input).length !== 5
895
+ || !Object.hasOwn(input, 'tenantId')
896
+ || !Object.hasOwn(input, 'caseSetId')
897
+ || !Object.hasOwn(input, 'expectedRevision')
898
+ || !Object.hasOwn(input, 'ownerTokenDigest')
899
+ || !Object.hasOwn(input, 'state')) {
900
+ throw new TypeError('remedy case-set compareAndSwap input is invalid');
901
+ }
902
+ const {
903
+ tenantId, caseSetId, expectedRevision, ownerTokenDigest, state,
904
+ } = input;
905
+ assertTenantId(tenantId);
906
+ assertCaseSetId(caseSetId);
907
+ assertRevision(expectedRevision, 'expected');
908
+ assertDigest(ownerTokenDigest, 'ownerTokenDigest');
909
+ const nextRevision = expectedRevision + 1;
910
+ if (!Number.isSafeInteger(nextRevision)) {
911
+ throw new TypeError('remedy case-set next revision is invalid');
912
+ }
913
+ const encoded = encodeState(state, tenantId, caseSetId, nextRevision);
914
+
915
+ return transaction(false, tenantId, async (client, clock) => {
916
+ const rows = strictRows(
917
+ await client.query(
918
+ REMEDY_CASE_SET_POSTGRES_SQL.getForUpdate,
919
+ [tenantId, caseSetId],
920
+ ),
921
+ 'getForUpdate',
922
+ 1,
923
+ );
924
+ if (rows.length === 0) return fail('case_set_not_found');
925
+ const current = decodeCurrentRow(rows[0], tenantId, caseSetId);
926
+ if (current.ownerTokenDigest !== ownerTokenDigest) {
927
+ return fail('ownership_conflict');
928
+ }
929
+ if (current.state.revision !== expectedRevision) {
930
+ return fail('revision_conflict');
931
+ }
932
+ if (current.status === 'completed') return fail('case_set_terminal');
933
+ if (encoded.ownerTokenDigest !== current.ownerTokenDigest) {
934
+ throw new TypeError('remedy case-set owner digest is immutable');
935
+ }
936
+ if (encoded.manifestJson !== current.manifestJson
937
+ || encoded.manifestDigest !== current.manifestDigest) {
938
+ return fail('manifest_conflict');
939
+ }
940
+ if (encoded.createdAt !== current.createdAt) {
941
+ throw new TypeError('remedy case-set created_at is immutable');
942
+ }
943
+ if (encoded.status === 'open') return fail('case_set_reopen_refused');
944
+ if (encoded.eventAtMs < current.eventAtMs) return fail('clock_regression');
945
+ assertNotForwardSkewed(encoded.eventAtMs, clock.milliseconds);
946
+
947
+ const updatedRows = strictRows(
948
+ await client.query(REMEDY_CASE_SET_POSTGRES_SQL.compareAndSwap, [
949
+ tenantId,
950
+ caseSetId,
951
+ expectedRevision,
952
+ ownerTokenDigest,
953
+ nextRevision,
954
+ encoded.status,
955
+ encoded.stateJson,
956
+ encoded.stateDigest,
957
+ clock.value,
958
+ encoded.manifestJson,
959
+ encoded.manifestDigest,
960
+ ]),
961
+ 'compareAndSwap',
962
+ 1,
963
+ );
964
+ if (updatedRows.length !== 1) {
965
+ throw new Error('remedy case-set compareAndSwap lost its locked row');
966
+ }
967
+ const stored = decodeCurrentRow(
968
+ updatedRows[0],
969
+ tenantId,
970
+ caseSetId,
971
+ encoded,
972
+ clock.value,
973
+ );
974
+ validateEvent(
975
+ await client.query(REMEDY_CASE_SET_POSTGRES_SQL.appendEvent, [
976
+ tenantId,
977
+ caseSetId,
978
+ nextRevision,
979
+ expectedRevision,
980
+ encoded.status,
981
+ encoded.stateJson,
982
+ encoded.stateDigest,
983
+ clock.value,
984
+ ]),
985
+ tenantId,
986
+ caseSetId,
987
+ nextRevision,
988
+ encoded.stateDigest,
989
+ clock.value,
990
+ );
991
+ return { ok: true, state: stored.state };
992
+ });
993
+ },
994
+ });
995
+ }
996
+
997
+ export default {
998
+ REMEDY_CASE_SET_PG_STORE_VERSION,
999
+ REMEDY_CASE_SET_TABLE,
1000
+ REMEDY_CASE_SET_EVENT_TABLE,
1001
+ REMEDY_CASE_SET_EXECUTOR_ROLE,
1002
+ REMEDY_CASE_SET_MAX_STATE_BYTES,
1003
+ REMEDY_CASE_SET_MAX_MANIFEST_BYTES,
1004
+ REMEDY_CASE_SET_MAX_FORWARD_SKEW_MINUTES,
1005
+ REMEDY_CASE_SET_POSTGRES_DDL,
1006
+ REMEDY_CASE_SET_POSTGRES_SQL,
1007
+ createRemedyCaseSetPostgresStore,
1008
+ };