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