okengine 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -4
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +15 -9
- package/manifest.v1.schema.json +10 -0
- package/package.json +22 -25
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/client/calling.mdx +27 -19
- package/site/content/docs/client/index.mdx +1 -1
- package/site/content/docs/elements/channel/email.mdx +1 -1
- package/site/content/docs/elements/channel/index.mdx +1 -1
- package/site/content/docs/elements/flow/http.mdx +4 -4
- package/site/content/docs/elements/flow/index.mdx +3 -3
- package/site/content/docs/elements/flow/meta.json +1 -1
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/vault/rotation.mdx +15 -0
- package/site/content/docs/plugins/otp.mdx +31 -31
- package/site/content/docs/recipes/mailpit.mdx +1 -1
- package/site/content/docs/recipes/meilisearch.mdx +1 -1
- package/site/content/docs/recipes/pgdog.mdx +1 -1
- package/site/content/docs/reference/configuration.mdx +2 -2
- package/site/content/docs/reference/errors.mdx +12 -2
- package/site/content/docs/reference/fx.mdx +28 -15
- package/site/content/docs/reference/idempotency.mdx +192 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +2 -1
- package/site/content/docs/understand/meta.json +1 -1
- package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
- package/site/content/docs/understand/the-architecture.mdx +14 -12
- package/site/content/docs/understand/try-it.mdx +3 -3
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/cli/dev.test.ts +105 -2
- package/src/cli/dev.ts +37 -2
- package/src/cli/docker-cli.test.ts +1 -1
- package/src/cli/index.ts +9 -1
- package/src/cli/load-config.images.test.ts +10 -10
- package/src/cli/load-config.ts +1 -1
- package/src/cli/vault-cmd.test.ts +23 -0
- package/src/cli/vault-cmd.ts +7 -0
- package/src/client/create.ts +31 -27
- package/src/client/live.ts +44 -101
- package/src/client/sse.ts +20 -66
- package/src/client/stream.ts +25 -67
- package/src/client/transport.test.ts +225 -0
- package/src/client/transport.ts +158 -90
- package/src/client/types.ts +29 -3
- package/src/client/wire.ts +119 -0
- package/src/compiler/aot.ts +3 -32
- package/src/compiler/dynamic.ts +13 -11
- package/src/compiler/effects-infer.ts +726 -16
- package/src/compiler/extract.test.ts +37 -0
- package/src/compiler/extract.ts +113 -12
- package/src/compiler/fixtures/skyport.expected.json +24 -0
- package/src/compiler/fx-follow.test.ts +164 -0
- package/src/compiler/fx-index.ts +399 -0
- package/src/compiler/interpret.ts +45 -0
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Ze6ckjdd.js} +2 -2
- package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
- package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-CoJ6amHf.js} +1 -1
- package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
- package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
- package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
- package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
- package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
- package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-DTPevKmM.js} +1 -1
- package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
- package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
- package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
- package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-BjFT7DNl.js} +1 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
- package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-BOMr0go9.js} +2 -2
- package/src/console/ui-next/dist/index.html +2 -2
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
- package/src/docker/docker.test.ts +13 -13
- package/src/docker/images-config.test.ts +12 -12
- package/src/docker/stack-id.test.ts +1 -1
- package/src/drivers/journal-postgres.ts +168 -3
- package/src/drivers/meilisearch.ts +2 -0
- package/src/drivers/vault-builtin.test.ts +10 -0
- package/src/drivers/vault-builtin.ts +13 -1
- package/src/drivers/vault-remote-bag.ts +5 -1
- package/src/drivers/vault-types.ts +6 -1
- package/src/elements/store/emit-drizzle.ts +2 -2
- package/src/elements/store/live-default.test.ts +2 -0
- package/src/elements/store/live-http.test.ts +1 -0
- package/src/elements/store/resource-list-docs.test.ts +2 -2
- package/src/elements/store/resource.test.ts +3 -3
- package/src/elements/store/schema-decl.test.ts +1 -0
- package/src/elements/store/sql-condition.ts +24 -7
- package/src/elements/store/sql-session.ts +20 -4
- package/src/elements/store/upsert-app.test.ts +1 -1
- package/src/elements/vault/audit.test.ts +137 -0
- package/src/elements/vault/audit.ts +106 -13
- package/src/elements/vault/boot-chain.ts +9 -1
- package/src/elements/vault/builtin-adapter.ts +33 -3
- package/src/i18n/catalogs/ar.ts +4 -0
- package/src/i18n/catalogs/en.ts +4 -0
- package/src/kernel/abort-scope.ts +33 -2
- package/src/kernel/app.ts +200 -20
- package/src/kernel/auto-cache.test.ts +6 -6
- package/src/kernel/boot-bind/vault.ts +1 -0
- package/src/kernel/boot.ts +19 -1
- package/src/kernel/builtin-errors.ts +8 -0
- package/src/kernel/client-descriptor.test.ts +78 -0
- package/src/kernel/client-descriptor.ts +24 -0
- package/src/kernel/concurrency.test.ts +33 -0
- package/src/kernel/effects-stamping.test.ts +2 -2
- package/src/kernel/errors-compiler.ts +20 -0
- package/src/kernel/errors-text.ts +99 -0
- package/src/kernel/errors.ts +90 -138
- package/src/kernel/external-effects.test.ts +36 -0
- package/src/kernel/flow.ts +24 -0
- package/src/kernel/fx-fetch.ts +5 -1
- package/src/kernel/fx-sql-handle.ts +305 -0
- package/src/kernel/fx.ts +42 -330
- package/src/kernel/idempotency-store.ts +578 -0
- package/src/kernel/idempotency.test.ts +556 -0
- package/src/kernel/idempotency.ts +304 -0
- package/src/kernel/index.ts +1 -0
- package/src/kernel/journal.ts +56 -0
- package/src/kernel/json-result.ts +59 -0
- package/src/kernel/project-out.ts +6 -1
- package/src/manifest/types.ts +8 -0
- package/src/okid-extended.ts +175 -0
- package/src/okid-shared.ts +103 -0
- package/src/okid.ts +30 -213
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/otp.test.ts +65 -1
- package/src/plugins/otp.ts +59 -7
- package/src/release/absolute-regression.test.ts +63 -0
- package/src/release/build-lib.ts +9 -0
- package/src/release/http-graph.test.ts +25 -0
- package/src/release/http-graph.ts +90 -0
- package/src/release/index.ts +3 -0
- package/src/release/limits.ts +17 -2
- package/src/release/measure.ts +105 -14
- package/src/test/create-test-app.test.ts +3 -1
- package/src/test/create-test-app.ts +40 -0
- package/src/test/live-signals.test.ts +1 -0
- package/src/test/provisions.integration.test.ts +1 -0
- package/src/test/tenant-isolation.test.ts +1 -0
- package/src/console/ui-next/dist/assets/agent-disclosure-CSKumwS2.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
- package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
- package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.js +0 -4
- package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-CL0D9dOq.js +0 -41
- package/src/console/ui-next/dist/assets/units-page-1PlT19ft.js +0 -1
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotency records on the journal driver.
|
|
3
|
+
*
|
|
4
|
+
* A sibling of `oke_journal_runs`. Rows are not journal runs: boot orphan
|
|
5
|
+
* scan would try to resume them, and `JournalStore.put` is not an atomic insert.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdir } from "node:fs/promises";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
|
|
13
|
+
/** Default record lifetime. */
|
|
14
|
+
export const IDEMPOTENCY_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
/** Default in-progress lease. Renew every third of this while `do` runs. */
|
|
17
|
+
export const IDEMPOTENCY_LEASE_MS = 30_000;
|
|
18
|
+
|
|
19
|
+
/** Scope of one idempotent call. */
|
|
20
|
+
export interface IdempotencyScope {
|
|
21
|
+
readonly tenant: string;
|
|
22
|
+
readonly principal: string;
|
|
23
|
+
readonly flow: string;
|
|
24
|
+
readonly key: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Buffered HTTP response kept until TTL. */
|
|
28
|
+
export interface StoredResponse {
|
|
29
|
+
readonly status: number;
|
|
30
|
+
readonly body: string;
|
|
31
|
+
readonly contentType?: string;
|
|
32
|
+
readonly location?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One `oke_idempotency` row. */
|
|
36
|
+
export interface IdempotencyRow {
|
|
37
|
+
tenant: string;
|
|
38
|
+
principal: string;
|
|
39
|
+
flow: string;
|
|
40
|
+
key: string;
|
|
41
|
+
fingerprint: string;
|
|
42
|
+
status: "in_progress" | "completed";
|
|
43
|
+
claimToken: string;
|
|
44
|
+
leaseExpiresAt: number;
|
|
45
|
+
runId?: string;
|
|
46
|
+
responseStatus?: number;
|
|
47
|
+
responseHeaders?: { readonly contentType?: string; readonly location?: string };
|
|
48
|
+
responseBody?: string;
|
|
49
|
+
createdAt: number;
|
|
50
|
+
expiresAt: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Result of an atomic claim. */
|
|
54
|
+
export type ClaimResult =
|
|
55
|
+
| { readonly kind: "claimed"; readonly row: IdempotencyRow }
|
|
56
|
+
| { readonly kind: "reclaimed"; readonly row: IdempotencyRow }
|
|
57
|
+
| { readonly kind: "replay"; readonly row: IdempotencyRow }
|
|
58
|
+
| { readonly kind: "mismatch" }
|
|
59
|
+
| { readonly kind: "in_progress"; readonly retryAfterSeconds: number };
|
|
60
|
+
|
|
61
|
+
/** Arguments for {@link IdempotencyStore.claim}. */
|
|
62
|
+
export interface ClaimInput {
|
|
63
|
+
readonly scope: IdempotencyScope;
|
|
64
|
+
readonly fingerprint: string;
|
|
65
|
+
readonly claimToken: string;
|
|
66
|
+
readonly now: number;
|
|
67
|
+
readonly leaseMs: number;
|
|
68
|
+
readonly ttlMs: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Persistence for idempotency rows.
|
|
73
|
+
*
|
|
74
|
+
* `forfeit` drops the in-process holder and expires the lease without
|
|
75
|
+
* deleting the row — the stand-in for a crashed process in tests.
|
|
76
|
+
*/
|
|
77
|
+
export interface IdempotencyStore {
|
|
78
|
+
claim(input: ClaimInput): Promise<ClaimResult>;
|
|
79
|
+
complete(
|
|
80
|
+
scope: IdempotencyScope,
|
|
81
|
+
token: string,
|
|
82
|
+
response: StoredResponse,
|
|
83
|
+
runId?: string,
|
|
84
|
+
): Promise<boolean>;
|
|
85
|
+
remove(scope: IdempotencyScope, token: string): Promise<boolean>;
|
|
86
|
+
renew(scope: IdempotencyScope, token: string, leaseExpiresAt: number): Promise<boolean>;
|
|
87
|
+
attachRun(scope: IdempotencyScope, token: string, runId: string): Promise<boolean>;
|
|
88
|
+
/**
|
|
89
|
+
* Drop the in-process holder and rotate the token so a late renew or
|
|
90
|
+
* complete from the crashed attempt cannot touch the row.
|
|
91
|
+
*/
|
|
92
|
+
forfeit(scope: IdempotencyScope, token: string): Promise<boolean>;
|
|
93
|
+
/** Load one row. Tests use this to forfeit a live claim. */
|
|
94
|
+
read(scope: IdempotencyScope): Promise<IdempotencyRow | undefined>;
|
|
95
|
+
purgeExpired(now: number): Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Minimal SQL surface the postgres journal client already exposes. */
|
|
99
|
+
export interface IdempotencySql {
|
|
100
|
+
query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
|
|
101
|
+
exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Insert that loses the race returns zero changes. */
|
|
105
|
+
export const IDEM_INSERT_SQL = `INSERT INTO oke_idempotency (tenant, principal, flow, key, fingerprint, status, claim_token, lease_expires_at, run_id, response_status, response_headers, response_body, created_at, expires_at) VALUES (?, ?, ?, ?, ?, 'in_progress', ?, ?, NULL, NULL, NULL, NULL, ?, ?) ON CONFLICT (tenant, principal, flow, key) DO NOTHING RETURNING claim_token`;
|
|
106
|
+
|
|
107
|
+
/** Load the row after a lost insert. */
|
|
108
|
+
export const IDEM_SELECT_SQL = `SELECT * FROM oke_idempotency WHERE tenant=? AND principal=? AND flow=? AND key=?`;
|
|
109
|
+
|
|
110
|
+
/** Drop one expired row before a fresh claim. */
|
|
111
|
+
export const IDEM_DELETE_EXPIRED_ONE_SQL = `DELETE FROM oke_idempotency WHERE tenant=? AND principal=? AND flow=? AND key=? AND expires_at<=?`;
|
|
112
|
+
|
|
113
|
+
/** Sweep every expired row. */
|
|
114
|
+
export const IDEM_PURGE_SQL = `DELETE FROM oke_idempotency WHERE expires_at<=?`;
|
|
115
|
+
|
|
116
|
+
/** Compare-and-set a crashed holder's token. */
|
|
117
|
+
export const IDEM_RECLAIM_SQL = `UPDATE oke_idempotency SET claim_token=?, lease_expires_at=? WHERE tenant=? AND principal=? AND flow=? AND key=? AND claim_token=? AND status='in_progress' RETURNING claim_token`;
|
|
118
|
+
|
|
119
|
+
/** Store the response when this token still holds the row. */
|
|
120
|
+
export const IDEM_COMPLETE_SQL = `UPDATE oke_idempotency SET status='completed', response_status=?, response_headers=?, response_body=?, run_id=COALESCE(?, run_id) WHERE tenant=? AND principal=? AND flow=? AND key=? AND claim_token=? RETURNING claim_token`;
|
|
121
|
+
|
|
122
|
+
/** Delete a row this token still holds. */
|
|
123
|
+
export const IDEM_REMOVE_SQL = `DELETE FROM oke_idempotency WHERE tenant=? AND principal=? AND flow=? AND key=? AND claim_token=? RETURNING claim_token`;
|
|
124
|
+
|
|
125
|
+
/** Extend the lease when this token still holds the row. */
|
|
126
|
+
export const IDEM_RENEW_SQL = `UPDATE oke_idempotency SET lease_expires_at=? WHERE tenant=? AND principal=? AND flow=? AND key=? AND claim_token=? AND status='in_progress' RETURNING claim_token`;
|
|
127
|
+
|
|
128
|
+
/** Record the journal run id while this token holds the row. */
|
|
129
|
+
export const IDEM_ATTACH_SQL = `UPDATE oke_idempotency SET run_id=? WHERE tenant=? AND principal=? AND flow=? AND key=? AND claim_token=? RETURNING claim_token`;
|
|
130
|
+
|
|
131
|
+
/** Expire the lease. The in-process holder set is cleared by the caller. */
|
|
132
|
+
export const IDEM_FORFEIT_SQL = `UPDATE oke_idempotency SET claim_token=?, lease_expires_at=0 WHERE tenant=? AND principal=? AND flow=? AND key=? AND claim_token=? AND status='in_progress' RETURNING claim_token`;
|
|
133
|
+
|
|
134
|
+
const PURGE_INTERVAL_MS = 60_000;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* In-memory idempotency store. One mutex keeps same-process claims atomic.
|
|
138
|
+
*/
|
|
139
|
+
export function createMemoryIdempotencyStore(): IdempotencyStore {
|
|
140
|
+
const rows = new Map<string, IdempotencyRow>();
|
|
141
|
+
return createMapStore(rows, async () => undefined);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* File-backed idempotency store beside the journal file.
|
|
146
|
+
*
|
|
147
|
+
* @param path - JSON file path
|
|
148
|
+
*/
|
|
149
|
+
export function createFileIdempotencyStore(path: string): IdempotencyStore {
|
|
150
|
+
let rows: Map<string, IdempotencyRow> | undefined;
|
|
151
|
+
const load = async (): Promise<Map<string, IdempotencyRow>> => {
|
|
152
|
+
if (rows) return rows;
|
|
153
|
+
rows = new Map();
|
|
154
|
+
const file = Bun.file(path);
|
|
155
|
+
if (await file.exists()) {
|
|
156
|
+
const raw = (await file.json()) as { rows?: IdempotencyRow[] };
|
|
157
|
+
for (const row of raw.rows ?? []) rows.set(rowKey(row), { ...row });
|
|
158
|
+
}
|
|
159
|
+
return rows;
|
|
160
|
+
};
|
|
161
|
+
const flush = async (map: Map<string, IdempotencyRow>): Promise<void> => {
|
|
162
|
+
await mkdir(dirname(path), { recursive: true });
|
|
163
|
+
await Bun.write(path, JSON.stringify({ rows: [...map.values()] }));
|
|
164
|
+
};
|
|
165
|
+
return createMapStoreAsync(load, flush);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Postgres idempotency store on the journal connection.
|
|
170
|
+
*
|
|
171
|
+
* @param sql - Journal SQL client
|
|
172
|
+
*/
|
|
173
|
+
export function createPostgresIdempotencyStore(sql: IdempotencySql): IdempotencyStore {
|
|
174
|
+
const held = new Set<string>();
|
|
175
|
+
let lastPurge = 0;
|
|
176
|
+
let tail: Promise<void> = Promise.resolve();
|
|
177
|
+
|
|
178
|
+
const exclusive = <T>(fn: () => Promise<T>): Promise<T> => {
|
|
179
|
+
const run = tail.then(fn, fn);
|
|
180
|
+
tail = run.then(
|
|
181
|
+
() => undefined,
|
|
182
|
+
() => undefined,
|
|
183
|
+
);
|
|
184
|
+
return run;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const scopeParams = (scope: IdempotencyScope): readonly string[] => [
|
|
188
|
+
scope.tenant,
|
|
189
|
+
scope.principal,
|
|
190
|
+
scope.flow,
|
|
191
|
+
scope.key,
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
claim(input) {
|
|
196
|
+
return exclusive(async () => {
|
|
197
|
+
if (input.now - lastPurge >= PURGE_INTERVAL_MS) {
|
|
198
|
+
lastPurge = input.now;
|
|
199
|
+
await sql.exec(IDEM_PURGE_SQL, [input.now]);
|
|
200
|
+
}
|
|
201
|
+
await sql.exec(IDEM_DELETE_EXPIRED_ONE_SQL, [...scopeParams(input.scope), input.now]);
|
|
202
|
+
const inserted = await sql.exec(IDEM_INSERT_SQL, insertParams(input));
|
|
203
|
+
if (inserted.changes > 0) {
|
|
204
|
+
held.add(input.claimToken);
|
|
205
|
+
return { kind: "claimed" as const, row: freshRow(input) };
|
|
206
|
+
}
|
|
207
|
+
const found = await sql.query(IDEM_SELECT_SQL, scopeParams(input.scope));
|
|
208
|
+
const existing = found[0] ? postgresRow(found[0]) : undefined;
|
|
209
|
+
return applyDecision(sql, held, existing, input);
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
complete(scope, token, response, runId) {
|
|
213
|
+
return exclusive(async () => {
|
|
214
|
+
const headers = JSON.stringify({
|
|
215
|
+
...(response.contentType !== undefined ? { contentType: response.contentType } : {}),
|
|
216
|
+
...(response.location !== undefined ? { location: response.location } : {}),
|
|
217
|
+
});
|
|
218
|
+
const result = await sql.exec(IDEM_COMPLETE_SQL, [
|
|
219
|
+
response.status,
|
|
220
|
+
headers,
|
|
221
|
+
response.body,
|
|
222
|
+
runId ?? null,
|
|
223
|
+
...scopeParams(scope),
|
|
224
|
+
token,
|
|
225
|
+
]);
|
|
226
|
+
if (result.changes > 0) held.delete(token);
|
|
227
|
+
return result.changes > 0;
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
remove(scope, token) {
|
|
231
|
+
return exclusive(async () => {
|
|
232
|
+
const result = await sql.exec(IDEM_REMOVE_SQL, [...scopeParams(scope), token]);
|
|
233
|
+
if (result.changes > 0) held.delete(token);
|
|
234
|
+
return result.changes > 0;
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
renew(scope, token, leaseExpiresAt) {
|
|
238
|
+
return exclusive(async () => {
|
|
239
|
+
if (!held.has(token)) return false;
|
|
240
|
+
const result = await sql.exec(IDEM_RENEW_SQL, [
|
|
241
|
+
leaseExpiresAt,
|
|
242
|
+
...scopeParams(scope),
|
|
243
|
+
token,
|
|
244
|
+
]);
|
|
245
|
+
return result.changes > 0;
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
attachRun(scope, token, runId) {
|
|
249
|
+
return exclusive(async () => {
|
|
250
|
+
const result = await sql.exec(IDEM_ATTACH_SQL, [runId, ...scopeParams(scope), token]);
|
|
251
|
+
return result.changes > 0;
|
|
252
|
+
});
|
|
253
|
+
},
|
|
254
|
+
forfeit(scope, token) {
|
|
255
|
+
return exclusive(async () => {
|
|
256
|
+
held.delete(token);
|
|
257
|
+
const result = await sql.exec(IDEM_FORFEIT_SQL, [
|
|
258
|
+
crypto.randomUUID(),
|
|
259
|
+
...scopeParams(scope),
|
|
260
|
+
token,
|
|
261
|
+
]);
|
|
262
|
+
return result.changes > 0;
|
|
263
|
+
});
|
|
264
|
+
},
|
|
265
|
+
read(scope) {
|
|
266
|
+
return exclusive(async () => {
|
|
267
|
+
const found = await sql.query(IDEM_SELECT_SQL, scopeParams(scope));
|
|
268
|
+
return found[0] ? postgresRow(found[0]) : undefined;
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
purgeExpired(now) {
|
|
272
|
+
return exclusive(async () => {
|
|
273
|
+
lastPurge = now;
|
|
274
|
+
await sql.exec(IDEM_PURGE_SQL, [now]);
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Decide what a conflicting row means. Does not mutate.
|
|
282
|
+
*
|
|
283
|
+
* @param existing - Current row, if the key is present and unexpired
|
|
284
|
+
* @param input - This attempt
|
|
285
|
+
* @param held - Claim tokens this process is still inside `do` for
|
|
286
|
+
*/
|
|
287
|
+
export function decideClaim(
|
|
288
|
+
existing: IdempotencyRow | undefined,
|
|
289
|
+
input: ClaimInput,
|
|
290
|
+
held: ReadonlySet<string>,
|
|
291
|
+
):
|
|
292
|
+
| { readonly op: "insert" }
|
|
293
|
+
| { readonly op: "mismatch" }
|
|
294
|
+
| { readonly op: "replay"; readonly row: IdempotencyRow }
|
|
295
|
+
| { readonly op: "busy"; readonly retryAfterSeconds: number }
|
|
296
|
+
| { readonly op: "reclaim"; readonly row: IdempotencyRow } {
|
|
297
|
+
if (existing === undefined || existing.expiresAt <= input.now) return { op: "insert" };
|
|
298
|
+
if (existing.fingerprint !== input.fingerprint) return { op: "mismatch" };
|
|
299
|
+
if (existing.status === "completed") return { op: "replay", row: existing };
|
|
300
|
+
if (existing.leaseExpiresAt > input.now || held.has(existing.claimToken)) {
|
|
301
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((existing.leaseExpiresAt - input.now) / 1000));
|
|
302
|
+
return { op: "busy", retryAfterSeconds };
|
|
303
|
+
}
|
|
304
|
+
return { op: "reclaim", row: existing };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function createMapStore(
|
|
308
|
+
rows: Map<string, IdempotencyRow>,
|
|
309
|
+
flush: (map: Map<string, IdempotencyRow>) => Promise<void>,
|
|
310
|
+
): IdempotencyStore {
|
|
311
|
+
return createMapStoreAsync(async () => rows, flush);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function createMapStoreAsync(
|
|
315
|
+
load: () => Promise<Map<string, IdempotencyRow>>,
|
|
316
|
+
flush: (map: Map<string, IdempotencyRow>) => Promise<void>,
|
|
317
|
+
): IdempotencyStore {
|
|
318
|
+
const held = new Set<string>();
|
|
319
|
+
let lastPurge = 0;
|
|
320
|
+
let tail: Promise<void> = Promise.resolve();
|
|
321
|
+
const exclusive = <T>(fn: () => Promise<T>): Promise<T> => {
|
|
322
|
+
const run = tail.then(fn, fn);
|
|
323
|
+
tail = run.then(
|
|
324
|
+
() => undefined,
|
|
325
|
+
() => undefined,
|
|
326
|
+
);
|
|
327
|
+
return run;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
claim(input) {
|
|
332
|
+
return exclusive(async () => {
|
|
333
|
+
const map = await load();
|
|
334
|
+
if (input.now - lastPurge >= PURGE_INTERVAL_MS) {
|
|
335
|
+
lastPurge = input.now;
|
|
336
|
+
purgeMap(map, input.now);
|
|
337
|
+
}
|
|
338
|
+
const id = scopeKey(input.scope);
|
|
339
|
+
const current = map.get(id);
|
|
340
|
+
if (current !== undefined && current.expiresAt <= input.now) map.delete(id);
|
|
341
|
+
const existing = map.get(id);
|
|
342
|
+
const decision = decideClaim(existing, input, held);
|
|
343
|
+
if (decision.op === "insert") {
|
|
344
|
+
const row = freshRow(input);
|
|
345
|
+
map.set(id, row);
|
|
346
|
+
held.add(input.claimToken);
|
|
347
|
+
await flush(map);
|
|
348
|
+
return { kind: "claimed" as const, row: cloneRow(row) };
|
|
349
|
+
}
|
|
350
|
+
if (decision.op === "mismatch") return { kind: "mismatch" as const };
|
|
351
|
+
if (decision.op === "replay")
|
|
352
|
+
return { kind: "replay" as const, row: cloneRow(decision.row) };
|
|
353
|
+
if (decision.op === "busy") {
|
|
354
|
+
return { kind: "in_progress" as const, retryAfterSeconds: decision.retryAfterSeconds };
|
|
355
|
+
}
|
|
356
|
+
const row = decision.row;
|
|
357
|
+
held.delete(row.claimToken);
|
|
358
|
+
row.claimToken = input.claimToken;
|
|
359
|
+
row.leaseExpiresAt = input.now + input.leaseMs;
|
|
360
|
+
held.add(input.claimToken);
|
|
361
|
+
await flush(map);
|
|
362
|
+
return { kind: "reclaimed" as const, row: cloneRow(row) };
|
|
363
|
+
});
|
|
364
|
+
},
|
|
365
|
+
complete(scope, token, response, runId) {
|
|
366
|
+
return exclusive(async () => {
|
|
367
|
+
const map = await load();
|
|
368
|
+
const row = map.get(scopeKey(scope));
|
|
369
|
+
if (row === undefined || row.claimToken !== token) return false;
|
|
370
|
+
row.status = "completed";
|
|
371
|
+
row.responseStatus = response.status;
|
|
372
|
+
row.responseBody = response.body;
|
|
373
|
+
row.responseHeaders = {
|
|
374
|
+
...(response.contentType !== undefined ? { contentType: response.contentType } : {}),
|
|
375
|
+
...(response.location !== undefined ? { location: response.location } : {}),
|
|
376
|
+
};
|
|
377
|
+
if (runId !== undefined) row.runId = runId;
|
|
378
|
+
held.delete(token);
|
|
379
|
+
await flush(map);
|
|
380
|
+
return true;
|
|
381
|
+
});
|
|
382
|
+
},
|
|
383
|
+
remove(scope, token) {
|
|
384
|
+
return exclusive(async () => {
|
|
385
|
+
const map = await load();
|
|
386
|
+
const id = scopeKey(scope);
|
|
387
|
+
const row = map.get(id);
|
|
388
|
+
if (row === undefined || row.claimToken !== token) return false;
|
|
389
|
+
map.delete(id);
|
|
390
|
+
held.delete(token);
|
|
391
|
+
await flush(map);
|
|
392
|
+
return true;
|
|
393
|
+
});
|
|
394
|
+
},
|
|
395
|
+
renew(scope, token, leaseExpiresAt) {
|
|
396
|
+
return exclusive(async () => {
|
|
397
|
+
if (!held.has(token)) return false;
|
|
398
|
+
const map = await load();
|
|
399
|
+
const row = map.get(scopeKey(scope));
|
|
400
|
+
if (row === undefined || row.claimToken !== token || row.status !== "in_progress")
|
|
401
|
+
return false;
|
|
402
|
+
row.leaseExpiresAt = leaseExpiresAt;
|
|
403
|
+
await flush(map);
|
|
404
|
+
return true;
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
attachRun(scope, token, runId) {
|
|
408
|
+
return exclusive(async () => {
|
|
409
|
+
const map = await load();
|
|
410
|
+
const row = map.get(scopeKey(scope));
|
|
411
|
+
if (row === undefined || row.claimToken !== token) return false;
|
|
412
|
+
row.runId = runId;
|
|
413
|
+
await flush(map);
|
|
414
|
+
return true;
|
|
415
|
+
});
|
|
416
|
+
},
|
|
417
|
+
forfeit(scope, token) {
|
|
418
|
+
return exclusive(async () => {
|
|
419
|
+
const map = await load();
|
|
420
|
+
const row = map.get(scopeKey(scope));
|
|
421
|
+
held.delete(token);
|
|
422
|
+
if (row === undefined || row.claimToken !== token || row.status !== "in_progress")
|
|
423
|
+
return false;
|
|
424
|
+
row.claimToken = crypto.randomUUID();
|
|
425
|
+
row.leaseExpiresAt = 0;
|
|
426
|
+
await flush(map);
|
|
427
|
+
return true;
|
|
428
|
+
});
|
|
429
|
+
},
|
|
430
|
+
read(scope) {
|
|
431
|
+
return exclusive(async () => {
|
|
432
|
+
const map = await load();
|
|
433
|
+
const row = map.get(scopeKey(scope));
|
|
434
|
+
return row === undefined ? undefined : cloneRow(row);
|
|
435
|
+
});
|
|
436
|
+
},
|
|
437
|
+
purgeExpired(now) {
|
|
438
|
+
return exclusive(async () => {
|
|
439
|
+
const map = await load();
|
|
440
|
+
lastPurge = now;
|
|
441
|
+
purgeMap(map, now);
|
|
442
|
+
await flush(map);
|
|
443
|
+
});
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function applyDecision(
|
|
449
|
+
sql: IdempotencySql,
|
|
450
|
+
held: Set<string>,
|
|
451
|
+
existing: IdempotencyRow | undefined,
|
|
452
|
+
input: ClaimInput,
|
|
453
|
+
): Promise<ClaimResult> {
|
|
454
|
+
const decision = decideClaim(existing, input, held);
|
|
455
|
+
if (decision.op === "insert") {
|
|
456
|
+
const again = await sql.exec(IDEM_INSERT_SQL, insertParams(input));
|
|
457
|
+
if (again.changes > 0) {
|
|
458
|
+
held.add(input.claimToken);
|
|
459
|
+
return { kind: "claimed", row: freshRow(input) };
|
|
460
|
+
}
|
|
461
|
+
return { kind: "in_progress", retryAfterSeconds: 1 };
|
|
462
|
+
}
|
|
463
|
+
if (decision.op === "mismatch") return { kind: "mismatch" };
|
|
464
|
+
if (decision.op === "replay") return { kind: "replay", row: decision.row };
|
|
465
|
+
if (decision.op === "busy") {
|
|
466
|
+
return { kind: "in_progress", retryAfterSeconds: decision.retryAfterSeconds };
|
|
467
|
+
}
|
|
468
|
+
const updated = await sql.exec(IDEM_RECLAIM_SQL, [
|
|
469
|
+
input.claimToken,
|
|
470
|
+
input.now + input.leaseMs,
|
|
471
|
+
input.scope.tenant,
|
|
472
|
+
input.scope.principal,
|
|
473
|
+
input.scope.flow,
|
|
474
|
+
input.scope.key,
|
|
475
|
+
decision.row.claimToken,
|
|
476
|
+
]);
|
|
477
|
+
if (updated.changes < 1) return { kind: "in_progress", retryAfterSeconds: 1 };
|
|
478
|
+
held.delete(decision.row.claimToken);
|
|
479
|
+
held.add(input.claimToken);
|
|
480
|
+
return {
|
|
481
|
+
kind: "reclaimed",
|
|
482
|
+
row: {
|
|
483
|
+
...decision.row,
|
|
484
|
+
claimToken: input.claimToken,
|
|
485
|
+
leaseExpiresAt: input.now + input.leaseMs,
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function freshRow(input: ClaimInput): IdempotencyRow {
|
|
491
|
+
return {
|
|
492
|
+
tenant: input.scope.tenant,
|
|
493
|
+
principal: input.scope.principal,
|
|
494
|
+
flow: input.scope.flow,
|
|
495
|
+
key: input.scope.key,
|
|
496
|
+
fingerprint: input.fingerprint,
|
|
497
|
+
status: "in_progress",
|
|
498
|
+
claimToken: input.claimToken,
|
|
499
|
+
leaseExpiresAt: input.now + input.leaseMs,
|
|
500
|
+
createdAt: input.now,
|
|
501
|
+
expiresAt: input.now + input.ttlMs,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function insertParams(input: ClaimInput): readonly unknown[] {
|
|
506
|
+
const row = freshRow(input);
|
|
507
|
+
return [
|
|
508
|
+
row.tenant,
|
|
509
|
+
row.principal,
|
|
510
|
+
row.flow,
|
|
511
|
+
row.key,
|
|
512
|
+
row.fingerprint,
|
|
513
|
+
row.claimToken,
|
|
514
|
+
row.leaseExpiresAt,
|
|
515
|
+
row.createdAt,
|
|
516
|
+
row.expiresAt,
|
|
517
|
+
];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function postgresRow(raw: Record<string, unknown>): IdempotencyRow {
|
|
521
|
+
const headers = parseHeaders(raw.response_headers);
|
|
522
|
+
return {
|
|
523
|
+
tenant: String(raw.tenant ?? ""),
|
|
524
|
+
principal: String(raw.principal ?? ""),
|
|
525
|
+
flow: String(raw.flow ?? ""),
|
|
526
|
+
key: String(raw.key ?? ""),
|
|
527
|
+
fingerprint: String(raw.fingerprint ?? ""),
|
|
528
|
+
status: raw.status === "completed" ? "completed" : "in_progress",
|
|
529
|
+
claimToken: String(raw.claim_token ?? ""),
|
|
530
|
+
leaseExpiresAt: Number(raw.lease_expires_at ?? 0),
|
|
531
|
+
...(raw.run_id !== null && raw.run_id !== undefined ? { runId: String(raw.run_id) } : {}),
|
|
532
|
+
...(raw.response_status !== null && raw.response_status !== undefined
|
|
533
|
+
? { responseStatus: Number(raw.response_status) }
|
|
534
|
+
: {}),
|
|
535
|
+
...(headers !== undefined ? { responseHeaders: headers } : {}),
|
|
536
|
+
...(raw.response_body !== null && raw.response_body !== undefined
|
|
537
|
+
? { responseBody: String(raw.response_body) }
|
|
538
|
+
: {}),
|
|
539
|
+
createdAt: Number(raw.created_at ?? 0),
|
|
540
|
+
expiresAt: Number(raw.expires_at ?? 0),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function parseHeaders(
|
|
545
|
+
value: unknown,
|
|
546
|
+
): { readonly contentType?: string; readonly location?: string } | undefined {
|
|
547
|
+
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
548
|
+
try {
|
|
549
|
+
const parsed = JSON.parse(value) as { contentType?: unknown; location?: unknown };
|
|
550
|
+
return {
|
|
551
|
+
...(typeof parsed.contentType === "string" ? { contentType: parsed.contentType } : {}),
|
|
552
|
+
...(typeof parsed.location === "string" ? { location: parsed.location } : {}),
|
|
553
|
+
};
|
|
554
|
+
} catch {
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function scopeKey(scope: IdempotencyScope): string {
|
|
560
|
+
return `${scope.tenant}\0${scope.principal}\0${scope.flow}\0${scope.key}`;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function rowKey(row: IdempotencyScope): string {
|
|
564
|
+
return scopeKey(row);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function cloneRow(row: IdempotencyRow): IdempotencyRow {
|
|
568
|
+
return {
|
|
569
|
+
...row,
|
|
570
|
+
...(row.responseHeaders ? { responseHeaders: { ...row.responseHeaders } } : {}),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function purgeMap(map: Map<string, IdempotencyRow>, now: number): void {
|
|
575
|
+
for (const [id, row] of map) {
|
|
576
|
+
if (row.expiresAt <= now) map.delete(id);
|
|
577
|
+
}
|
|
578
|
+
}
|