okengine 0.23.0 → 0.23.2
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/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +4 -4
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- package/site/content/docs/reference/fx.mdx +12 -4
- package/src/auth/tenants.ts +17 -0
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/manifest-pr-diff.ts +16 -0
- package/src/compiler/aot.test.ts +3 -2
- package/src/compiler/aot.ts +45 -1
- package/src/compiler/dynamic.ts +1 -1
- package/src/compiler/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +87 -16
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +11 -1
- package/src/compiler/fx-index.ts +58 -4
- package/src/compiler/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-parked-approval.ts +5 -1
- package/src/drivers/journal-postgres.test.ts +106 -2
- package/src/drivers/journal-postgres.ts +475 -14
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +238 -0
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +486 -213
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +934 -93
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +42 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +395 -0
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache-bus.test.ts +121 -0
- package/src/elements/store/cache-bus.ts +278 -0
- package/src/elements/store/cache.test.ts +179 -15
- package/src/elements/store/cache.ts +357 -51
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/runtime.ts +44 -2
- package/src/elements/store/sql-errors.test.ts +131 -0
- package/src/elements/store/sql-errors.ts +39 -16
- package/src/elements/store/sql-nested-tx.test.ts +344 -0
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +275 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +207 -43
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +72 -25
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +54 -10
- package/src/kernel/boundary-contract.ts +7 -0
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/call.test.ts +1 -0
- package/src/kernel/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +16 -0
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +11 -7
- package/src/kernel/fx-sql-handle.ts +52 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +4 -4
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +158 -0
- package/src/kernel/journal.ts +515 -54
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +26 -4
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +404 -33
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +31 -5
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/runtime/bun.ts +5 -0
- package/src/runtime/types.ts +5 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres-backed consent, suppression, and receipt ledger.
|
|
3
|
+
*
|
|
4
|
+
* Reads are synchronous against a cache. Writes hit SQL and the cache.
|
|
5
|
+
* Call {@link PostgresChannelLedger.reload} on another instance to see them.
|
|
6
|
+
* Receipt reloads merge rows touched since the last read and keep the rest.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ChannelMedium } from "../../manifest/types.ts";
|
|
10
|
+
import type { ConsentStore, OptOut } from "./consent.ts";
|
|
11
|
+
import type { DeliveryReceipt, ReceiptLedger } from "./receipts.ts";
|
|
12
|
+
import type { SuppressionEntry, SuppressionReason, SuppressionStore } from "./suppression.ts";
|
|
13
|
+
|
|
14
|
+
/** SQL surface the channel ledger needs (`?` placeholders). */
|
|
15
|
+
export interface ChannelLedgerSql {
|
|
16
|
+
/**
|
|
17
|
+
* Run a query and return rows.
|
|
18
|
+
*
|
|
19
|
+
* @param sql - Statement
|
|
20
|
+
* @param params - Bindings
|
|
21
|
+
*/
|
|
22
|
+
query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Run a statement.
|
|
25
|
+
*
|
|
26
|
+
* @param sql - Statement
|
|
27
|
+
* @param params - Bindings
|
|
28
|
+
*/
|
|
29
|
+
exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Durable receipt read used when the in-memory cache misses.
|
|
34
|
+
*/
|
|
35
|
+
export interface ChannelReceiptLookup {
|
|
36
|
+
/**
|
|
37
|
+
* Load one receipt by receipt id or provider message id.
|
|
38
|
+
*
|
|
39
|
+
* @param messageId - Receipt id or provider message id
|
|
40
|
+
*/
|
|
41
|
+
lookup(messageId: string): Promise<DeliveryReceipt | undefined>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Shared channel ledger (consent + suppression + receipts). */
|
|
45
|
+
export interface PostgresChannelLedger {
|
|
46
|
+
readonly consent: ConsentStore;
|
|
47
|
+
readonly suppression: SuppressionStore;
|
|
48
|
+
readonly receipts: ReceiptLedger & ChannelReceiptLookup;
|
|
49
|
+
/** Wait for queued writes. */
|
|
50
|
+
flush(): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Wait for queued writes, then refresh the cache from SQL.
|
|
53
|
+
* Receipts merge by id. Rows absent from the delta stay cached.
|
|
54
|
+
*/
|
|
55
|
+
reload(): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const CONSENT = "oke_channel_consent";
|
|
59
|
+
const BOUNCE = "oke_channel_bounce";
|
|
60
|
+
const RECEIPT = "oke_channel_receipt";
|
|
61
|
+
/** Postgres `clock_timestamp()` in milliseconds. Writes use the database clock. */
|
|
62
|
+
const DB_NOW_MS = "(extract(epoch from clock_timestamp())*1000)::bigint";
|
|
63
|
+
/**
|
|
64
|
+
* Reload overlap. A commit that lands while a read is in flight still
|
|
65
|
+
* appears on the next pass because the cursor moves backward by this much.
|
|
66
|
+
*/
|
|
67
|
+
const RECEIPT_RELOAD_LAG_MS = 30_000;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Open the ledger tables and load the current rows.
|
|
71
|
+
*
|
|
72
|
+
* @param sql - App SQL client
|
|
73
|
+
*/
|
|
74
|
+
export async function openPostgresChannelLedger(
|
|
75
|
+
sql: ChannelLedgerSql,
|
|
76
|
+
): Promise<PostgresChannelLedger> {
|
|
77
|
+
await sql.exec(
|
|
78
|
+
`CREATE TABLE IF NOT EXISTS ${CONSENT} (subject TEXT NOT NULL, medium TEXT NOT NULL, at BIGINT NOT NULL, PRIMARY KEY (subject, medium))`,
|
|
79
|
+
);
|
|
80
|
+
await sql.exec(
|
|
81
|
+
`CREATE TABLE IF NOT EXISTS ${BOUNCE} (subject TEXT NOT NULL, medium TEXT NOT NULL, at BIGINT NOT NULL, PRIMARY KEY (subject, medium))`,
|
|
82
|
+
);
|
|
83
|
+
await sql.exec(`CREATE TABLE IF NOT EXISTS ${RECEIPT} (id TEXT PRIMARY KEY, body TEXT NOT NULL)`);
|
|
84
|
+
await sql.exec(
|
|
85
|
+
`ALTER TABLE ${RECEIPT} ADD COLUMN IF NOT EXISTS updated_at BIGINT NOT NULL DEFAULT 0`,
|
|
86
|
+
);
|
|
87
|
+
await sql.exec(`ALTER TABLE ${RECEIPT} ADD COLUMN IF NOT EXISTS message_id TEXT`);
|
|
88
|
+
await sql.exec(
|
|
89
|
+
`CREATE INDEX IF NOT EXISTS oke_channel_receipt_message_id_idx ON ${RECEIPT} (message_id)`,
|
|
90
|
+
);
|
|
91
|
+
await sql.exec(
|
|
92
|
+
`CREATE INDEX IF NOT EXISTS oke_channel_receipt_updated_at_idx ON ${RECEIPT} (updated_at)`,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const consents: OptOut[] = [];
|
|
96
|
+
const bounces: OptOut[] = [];
|
|
97
|
+
let chain: Promise<void> = Promise.resolve();
|
|
98
|
+
|
|
99
|
+
function enqueue(work: () => Promise<void>): void {
|
|
100
|
+
chain = chain.then(work);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function flush(): Promise<void> {
|
|
104
|
+
await chain;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function matches(rows: readonly OptOut[], subject: string, medium: ChannelMedium): boolean {
|
|
108
|
+
return rows.some(
|
|
109
|
+
(row) => row.subject === subject && (row.medium === "all" || row.medium === medium),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function remember(
|
|
114
|
+
rows: OptOut[],
|
|
115
|
+
subject: string,
|
|
116
|
+
medium: ChannelMedium | "all",
|
|
117
|
+
at: number,
|
|
118
|
+
): void {
|
|
119
|
+
const idx = rows.findIndex((row) => row.subject === subject && row.medium === medium);
|
|
120
|
+
const next: OptOut = { subject, medium, at };
|
|
121
|
+
if (idx >= 0) rows[idx] = next;
|
|
122
|
+
else rows.push(next);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function forget(rows: OptOut[], subject: string, medium: ChannelMedium | "all"): void {
|
|
126
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
127
|
+
const row = rows[i]!;
|
|
128
|
+
if (row.subject === subject && row.medium === medium) rows.splice(i, 1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const consent: ConsentStore = {
|
|
133
|
+
isOptedOut(subject, medium) {
|
|
134
|
+
return matches(consents, subject, medium);
|
|
135
|
+
},
|
|
136
|
+
optOut(subject, medium) {
|
|
137
|
+
const at = Date.now();
|
|
138
|
+
remember(consents, subject, medium, at);
|
|
139
|
+
enqueue(() =>
|
|
140
|
+
sql
|
|
141
|
+
.exec(
|
|
142
|
+
`INSERT INTO ${CONSENT} (subject, medium, at) VALUES (?, ?, ?) ON CONFLICT (subject, medium) DO UPDATE SET at = excluded.at`,
|
|
143
|
+
[subject, medium, at],
|
|
144
|
+
)
|
|
145
|
+
.then(() => undefined),
|
|
146
|
+
);
|
|
147
|
+
},
|
|
148
|
+
optIn(subject, medium) {
|
|
149
|
+
forget(consents, subject, medium);
|
|
150
|
+
enqueue(() =>
|
|
151
|
+
sql
|
|
152
|
+
.exec(`DELETE FROM ${CONSENT} WHERE subject = ? AND medium = ?`, [subject, medium])
|
|
153
|
+
.then(() => undefined),
|
|
154
|
+
);
|
|
155
|
+
},
|
|
156
|
+
list() {
|
|
157
|
+
return [...consents];
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function reasonFor(
|
|
162
|
+
subject: string,
|
|
163
|
+
medium: ChannelMedium,
|
|
164
|
+
):
|
|
165
|
+
| { readonly suppressed: true; readonly reason: SuppressionReason }
|
|
166
|
+
| { readonly suppressed: false } {
|
|
167
|
+
if (consent.isOptedOut(subject, medium)) return { suppressed: true, reason: "opted-out" };
|
|
168
|
+
if (matches(bounces, subject, medium)) return { suppressed: true, reason: "prior-bounce" };
|
|
169
|
+
return { suppressed: false };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const suppression: SuppressionStore = {
|
|
173
|
+
consent,
|
|
174
|
+
isSuppressed: reasonFor,
|
|
175
|
+
optOut(subject, medium) {
|
|
176
|
+
consent.optOut(subject, medium);
|
|
177
|
+
},
|
|
178
|
+
addPriorBounce(subject, medium) {
|
|
179
|
+
const at = Date.now();
|
|
180
|
+
remember(bounces, subject, medium, at);
|
|
181
|
+
enqueue(() =>
|
|
182
|
+
sql
|
|
183
|
+
.exec(
|
|
184
|
+
`INSERT INTO ${BOUNCE} (subject, medium, at) VALUES (?, ?, ?) ON CONFLICT (subject, medium) DO UPDATE SET at = excluded.at`,
|
|
185
|
+
[subject, medium, at],
|
|
186
|
+
)
|
|
187
|
+
.then(() => undefined),
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
clear(subject, medium) {
|
|
191
|
+
consent.optIn(subject, medium);
|
|
192
|
+
forget(bounces, subject, medium);
|
|
193
|
+
enqueue(() =>
|
|
194
|
+
sql
|
|
195
|
+
.exec(`DELETE FROM ${BOUNCE} WHERE subject = ? AND medium = ?`, [subject, medium])
|
|
196
|
+
.then(() => undefined),
|
|
197
|
+
);
|
|
198
|
+
},
|
|
199
|
+
list() {
|
|
200
|
+
const rows: SuppressionEntry[] = [];
|
|
201
|
+
for (const row of consents) {
|
|
202
|
+
rows.push({ subject: row.subject, medium: row.medium, reason: "opted-out", at: row.at });
|
|
203
|
+
}
|
|
204
|
+
for (const row of bounces) {
|
|
205
|
+
rows.push({ subject: row.subject, medium: row.medium, reason: "prior-bounce", at: row.at });
|
|
206
|
+
}
|
|
207
|
+
return rows;
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const byId = new Map<string, DeliveryReceipt>();
|
|
212
|
+
const byMessage = new Map<string, string>();
|
|
213
|
+
/** `updated_at` cursor. Starts at 0 so legacy rows (`updated_at = 0`) load. */
|
|
214
|
+
let cursor = 0;
|
|
215
|
+
let reloading: Promise<void> | undefined;
|
|
216
|
+
let messageIdBackfill: Promise<void> | undefined;
|
|
217
|
+
|
|
218
|
+
function rememberReceipt(receipt: DeliveryReceipt): void {
|
|
219
|
+
const prev = byId.get(receipt.id);
|
|
220
|
+
if (prev?.messageId && prev.messageId !== receipt.messageId) {
|
|
221
|
+
if (byMessage.get(prev.messageId) === receipt.id) byMessage.delete(prev.messageId);
|
|
222
|
+
}
|
|
223
|
+
byId.set(receipt.id, receipt);
|
|
224
|
+
byMessage.set(receipt.id, receipt.id);
|
|
225
|
+
if (receipt.messageId) byMessage.set(receipt.messageId, receipt.id);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function findByMessageId(messageId: string): DeliveryReceipt | undefined {
|
|
229
|
+
const id = byMessage.get(messageId);
|
|
230
|
+
if (id === undefined) return undefined;
|
|
231
|
+
return byId.get(id);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parseReceipt(body: unknown): DeliveryReceipt | undefined {
|
|
235
|
+
if (typeof body !== "string") return undefined;
|
|
236
|
+
let parsed: unknown;
|
|
237
|
+
try {
|
|
238
|
+
parsed = JSON.parse(body);
|
|
239
|
+
} catch {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
!parsed ||
|
|
244
|
+
typeof parsed !== "object" ||
|
|
245
|
+
!("id" in parsed) ||
|
|
246
|
+
typeof parsed.id !== "string"
|
|
247
|
+
) {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
return parsed as DeliveryReceipt;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function readMillis(value: unknown): number | undefined {
|
|
254
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
255
|
+
if (typeof value === "bigint") return Number(value);
|
|
256
|
+
if (typeof value === "string" && value !== "") {
|
|
257
|
+
const parsed = Number(value);
|
|
258
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
259
|
+
}
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const receiptLedger = {
|
|
264
|
+
record(receipt: DeliveryReceipt) {
|
|
265
|
+
rememberReceipt(receipt);
|
|
266
|
+
enqueue(() =>
|
|
267
|
+
sql
|
|
268
|
+
.exec(
|
|
269
|
+
`INSERT INTO ${RECEIPT} (id, body, message_id, updated_at) VALUES (?, ?, ?, ${DB_NOW_MS})`,
|
|
270
|
+
[receipt.id, JSON.stringify(receipt), receipt.messageId ?? null],
|
|
271
|
+
)
|
|
272
|
+
.then(() => undefined),
|
|
273
|
+
);
|
|
274
|
+
},
|
|
275
|
+
all() {
|
|
276
|
+
return [...byId.values()];
|
|
277
|
+
},
|
|
278
|
+
forTemplate(template: string) {
|
|
279
|
+
return [...byId.values()].filter((row) => row.template === template);
|
|
280
|
+
},
|
|
281
|
+
byMessageId(messageId: string) {
|
|
282
|
+
return findByMessageId(messageId);
|
|
283
|
+
},
|
|
284
|
+
updateStatus(
|
|
285
|
+
messageId: string,
|
|
286
|
+
patch: {
|
|
287
|
+
readonly status: DeliveryReceipt["status"];
|
|
288
|
+
readonly at?: number;
|
|
289
|
+
readonly error?: string;
|
|
290
|
+
},
|
|
291
|
+
) {
|
|
292
|
+
const prev = findByMessageId(messageId);
|
|
293
|
+
if (!prev) return undefined;
|
|
294
|
+
const next: DeliveryReceipt = {
|
|
295
|
+
...prev,
|
|
296
|
+
status: patch.status,
|
|
297
|
+
at: patch.at ?? prev.at,
|
|
298
|
+
...(patch.error !== undefined ? { error: patch.error } : {}),
|
|
299
|
+
};
|
|
300
|
+
rememberReceipt(next);
|
|
301
|
+
enqueue(() =>
|
|
302
|
+
sql
|
|
303
|
+
.exec(
|
|
304
|
+
`UPDATE ${RECEIPT} SET body = ?, message_id = ?, updated_at = ${DB_NOW_MS} WHERE id = ?`,
|
|
305
|
+
[JSON.stringify(next), next.messageId ?? null, next.id],
|
|
306
|
+
)
|
|
307
|
+
.then(() => undefined),
|
|
308
|
+
);
|
|
309
|
+
return next;
|
|
310
|
+
},
|
|
311
|
+
async lookup(messageId: string): Promise<DeliveryReceipt | undefined> {
|
|
312
|
+
const rows = await sql.query(
|
|
313
|
+
`SELECT id, body, message_id, updated_at FROM ${RECEIPT} WHERE id = ? OR message_id = ? LIMIT 1`,
|
|
314
|
+
[messageId, messageId],
|
|
315
|
+
);
|
|
316
|
+
const row = rows[0];
|
|
317
|
+
if (!row) return undefined;
|
|
318
|
+
const receipt = parseReceipt(row.body);
|
|
319
|
+
if (!receipt) return undefined;
|
|
320
|
+
rememberReceipt(receipt);
|
|
321
|
+
return receipt;
|
|
322
|
+
},
|
|
323
|
+
} satisfies ReceiptLedger & ChannelReceiptLookup;
|
|
324
|
+
|
|
325
|
+
async function loadOptOuts(table: string, into: OptOut[]): Promise<void> {
|
|
326
|
+
const rows = await sql.query(`SELECT subject, medium, at FROM ${table}`);
|
|
327
|
+
into.length = 0;
|
|
328
|
+
for (const row of rows) {
|
|
329
|
+
into.push({
|
|
330
|
+
subject: String(row.subject),
|
|
331
|
+
medium: String(row.medium) as ChannelMedium | "all",
|
|
332
|
+
at: Number(row.at),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function backfillMessageIds(): Promise<void> {
|
|
338
|
+
if (messageIdBackfill) return messageIdBackfill;
|
|
339
|
+
const run = (async () => {
|
|
340
|
+
const rows = await sql.query(`SELECT id, body FROM ${RECEIPT} WHERE message_id IS NULL`);
|
|
341
|
+
for (const row of rows) {
|
|
342
|
+
const receipt = parseReceipt(row.body);
|
|
343
|
+
const messageId = receipt?.messageId;
|
|
344
|
+
if (!messageId) continue;
|
|
345
|
+
await sql.exec(`UPDATE ${RECEIPT} SET message_id = ? WHERE id = ? AND message_id IS NULL`, [
|
|
346
|
+
messageId,
|
|
347
|
+
String(row.id),
|
|
348
|
+
]);
|
|
349
|
+
}
|
|
350
|
+
})().catch((err: unknown) => {
|
|
351
|
+
messageIdBackfill = undefined;
|
|
352
|
+
throw err;
|
|
353
|
+
});
|
|
354
|
+
messageIdBackfill = run;
|
|
355
|
+
return run;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function loadReceiptDelta(): Promise<void> {
|
|
359
|
+
const since = cursor - RECEIPT_RELOAD_LAG_MS;
|
|
360
|
+
const rows = await sql.query(
|
|
361
|
+
`SELECT r.id, r.body, r.message_id, r.updated_at, now_ms.db_now FROM (SELECT ${DB_NOW_MS} AS db_now) AS now_ms LEFT JOIN ${RECEIPT} r ON r.updated_at >= ?`,
|
|
362
|
+
[since],
|
|
363
|
+
);
|
|
364
|
+
let dbNow: number | undefined;
|
|
365
|
+
for (const row of rows) {
|
|
366
|
+
const read = readMillis(row.db_now);
|
|
367
|
+
if (read !== undefined) dbNow = read;
|
|
368
|
+
if (row.id == null || row.body == null) continue;
|
|
369
|
+
const receipt = parseReceipt(row.body);
|
|
370
|
+
if (receipt) rememberReceipt(receipt);
|
|
371
|
+
}
|
|
372
|
+
if (dbNow !== undefined) cursor = dbNow;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function reloadFromSql(): Promise<void> {
|
|
376
|
+
await flush();
|
|
377
|
+
await loadOptOuts(CONSENT, consents);
|
|
378
|
+
await loadOptOuts(BOUNCE, bounces);
|
|
379
|
+
await backfillMessageIds();
|
|
380
|
+
await loadReceiptDelta();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function reload(): Promise<void> {
|
|
384
|
+
if (reloading) return reloading;
|
|
385
|
+
const run = reloadFromSql().finally(() => {
|
|
386
|
+
if (reloading === run) reloading = undefined;
|
|
387
|
+
});
|
|
388
|
+
reloading = run;
|
|
389
|
+
return run;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
await reload();
|
|
393
|
+
|
|
394
|
+
return { consent, suppression, receipts: receiptLedger, flush, reload };
|
|
395
|
+
}
|
|
@@ -206,7 +206,13 @@ if (mode === "tick-loop") {
|
|
|
206
206
|
leaseMs,
|
|
207
207
|
flow: chargeFlow(instanceId!, stepLogPath!, blockMs),
|
|
208
208
|
});
|
|
209
|
-
void app.fetch(
|
|
209
|
+
void app.fetch(
|
|
210
|
+
new Request("http://localhost/charge", {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: { "content-type": "application/json" },
|
|
213
|
+
body: "{}",
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
210
216
|
// Marker once step 1 is journaled — then hang; the parent SIGKILLs mid-run.
|
|
211
217
|
const deadline = Date.now() + 15_000;
|
|
212
218
|
for (;;) {
|
|
@@ -269,7 +275,11 @@ if (mode === "tick-loop") {
|
|
|
269
275
|
flow: sleeperFlow(instanceId!, stepLogPath!, wakeMs),
|
|
270
276
|
});
|
|
271
277
|
const res = await app.fetch(
|
|
272
|
-
new Request("http://localhost/sleep", {
|
|
278
|
+
new Request("http://localhost/sleep", {
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers: { "content-type": "application/json" },
|
|
281
|
+
body: "{}",
|
|
282
|
+
}),
|
|
273
283
|
);
|
|
274
284
|
if (res.status !== 204) {
|
|
275
285
|
console.error(`journal-pg-park: expected 204 park, got ${res.status}`);
|
|
@@ -14,7 +14,10 @@ import { isFlowFailure } from "../../kernel/hooks.ts";
|
|
|
14
14
|
import { isJsonStreamResult } from "../../kernel/json-result.ts";
|
|
15
15
|
import {
|
|
16
16
|
createJournal,
|
|
17
|
+
isJournalLeaseBusy,
|
|
17
18
|
isJournalSuspend,
|
|
19
|
+
isLostJournalLease,
|
|
20
|
+
JournalLeaseBusy,
|
|
18
21
|
type Journal,
|
|
19
22
|
type JournalLeaseOptions,
|
|
20
23
|
type JournalSession,
|
|
@@ -55,6 +58,8 @@ export interface RunDurableOptions {
|
|
|
55
58
|
* Resume throws `JournalLeaseBusy` when another live instance holds the run.
|
|
56
59
|
*/
|
|
57
60
|
readonly lease?: JournalLeaseOptions;
|
|
61
|
+
/** Opt-in app code version. Unset skips the stamp and the check. */
|
|
62
|
+
readonly codeVersion?: string;
|
|
58
63
|
/** Injectable clock. */
|
|
59
64
|
readonly now?: () => number;
|
|
60
65
|
/** Extra fx options (secrets, store runtime, …). */
|
|
@@ -74,6 +79,7 @@ export async function runDurable<O = unknown>(
|
|
|
74
79
|
store: options.journalStore,
|
|
75
80
|
now,
|
|
76
81
|
...(options.lease ? { lease: options.lease } : {}),
|
|
82
|
+
...(options.codeVersion ? { codeVersion: options.codeVersion } : {}),
|
|
77
83
|
});
|
|
78
84
|
|
|
79
85
|
if (options.runId) {
|
|
@@ -121,6 +127,7 @@ export async function runDurable<O = unknown>(
|
|
|
121
127
|
});
|
|
122
128
|
|
|
123
129
|
if (session.run.status === "compensating") {
|
|
130
|
+
if (isLostJournalLease(session.run.error)) throw new JournalLeaseBusy(session.runId);
|
|
124
131
|
await rebindUndosFromDo(options.flow, options.input, fx, session);
|
|
125
132
|
const priorError = session.run.error ?? "Error";
|
|
126
133
|
await runCompensationPhase({
|
|
@@ -170,6 +177,7 @@ export async function runDurable<O = unknown>(
|
|
|
170
177
|
}
|
|
171
178
|
const output = produced;
|
|
172
179
|
if (isFlowFailure(output)) {
|
|
180
|
+
if (isLostJournalLease(output)) throw new JournalLeaseBusy(session.runId);
|
|
173
181
|
await runCompensationPhase({
|
|
174
182
|
flow: options.flow,
|
|
175
183
|
input: options.input,
|
|
@@ -198,6 +206,9 @@ export async function runDurable<O = unknown>(
|
|
|
198
206
|
label: err.label,
|
|
199
207
|
};
|
|
200
208
|
}
|
|
209
|
+
if (isLostJournalLease(err) || isJournalLeaseBusy(err)) {
|
|
210
|
+
throw isJournalLeaseBusy(err) ? err : new JournalLeaseBusy(session.runId);
|
|
211
|
+
}
|
|
201
212
|
await runCompensationPhase({
|
|
202
213
|
flow: options.flow,
|
|
203
214
|
input: options.input,
|
|
@@ -26,6 +26,12 @@ export interface CreateSignalRuntimeOptions {
|
|
|
26
26
|
readonly sql?: unknown;
|
|
27
27
|
readonly redis?: import("../../drivers/signal-types.ts").SignalRedisClientLike;
|
|
28
28
|
readonly nats?: import("../../drivers/signal-types.ts").SignalNatsClientLike;
|
|
29
|
+
/** Forwarded to the driver. Postgres poll when `listen` is absent. */
|
|
30
|
+
readonly pollMs?: number;
|
|
31
|
+
/** Redis consumer-group consume for `once`. */
|
|
32
|
+
readonly compete?: boolean;
|
|
33
|
+
/** Redis consumer name. */
|
|
34
|
+
readonly consumerId?: string;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/** Signal runtime. */
|
|
@@ -106,6 +112,9 @@ export function createSignalRuntime(options: CreateSignalRuntimeOptions): Signal
|
|
|
106
112
|
sql: options.sql,
|
|
107
113
|
redis: options.redis,
|
|
108
114
|
nats: options.nats,
|
|
115
|
+
pollMs: options.pollMs,
|
|
116
|
+
compete: options.compete,
|
|
117
|
+
consumerId: options.consumerId,
|
|
109
118
|
});
|
|
110
119
|
return bus;
|
|
111
120
|
},
|
|
@@ -267,6 +267,7 @@ describe("postgres transactional emit (dual-write fix)", () => {
|
|
|
267
267
|
});
|
|
268
268
|
|
|
269
269
|
const notified: string[] = [];
|
|
270
|
+
if (sql.listen === undefined) throw new Error("postgres fake has no listen");
|
|
270
271
|
await sql.listen("oke_signal", (p) => {
|
|
271
272
|
notified.push(p);
|
|
272
273
|
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { computedCacheKey, createStoreCache, type StoreCache } from "./cache.ts";
|
|
4
|
+
import {
|
|
5
|
+
CACHE_INVALIDATE_CHANNEL,
|
|
6
|
+
openCacheInvalidation,
|
|
7
|
+
type CacheBusRedis,
|
|
8
|
+
type CacheBusSql,
|
|
9
|
+
} from "./cache-bus.ts";
|
|
10
|
+
|
|
11
|
+
function hub(): { client: () => CacheBusRedis } {
|
|
12
|
+
const subs = new Map<string, Set<(message: string) => void>>();
|
|
13
|
+
return {
|
|
14
|
+
client: () => ({
|
|
15
|
+
async publish(channel, message) {
|
|
16
|
+
const set = subs.get(channel);
|
|
17
|
+
if (!set) return 0;
|
|
18
|
+
for (const listener of [...set]) listener(message);
|
|
19
|
+
return set.size;
|
|
20
|
+
},
|
|
21
|
+
async subscribe(channel, listener) {
|
|
22
|
+
const set = subs.get(channel) ?? new Set<(message: string) => void>();
|
|
23
|
+
subs.set(channel, set);
|
|
24
|
+
set.add(listener);
|
|
25
|
+
return () => {
|
|
26
|
+
set.delete(listener);
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function seed(cache: StoreCache, resource: "sql:notes"): string {
|
|
34
|
+
const key = computedCacheKey(resource);
|
|
35
|
+
cache.set({ tier: 1, key, value: { n: 1 }, resources: [resource], expiresAt: null });
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("redis invalidation drops the other instance and does not republish", async () => {
|
|
40
|
+
const bus = hub();
|
|
41
|
+
let publishA = 0;
|
|
42
|
+
const redisA = bus.client();
|
|
43
|
+
const publish = redisA.publish.bind(redisA);
|
|
44
|
+
redisA.publish = async (channel, message) => {
|
|
45
|
+
publishA += 1;
|
|
46
|
+
return publish(channel, message);
|
|
47
|
+
};
|
|
48
|
+
const cacheA = createStoreCache();
|
|
49
|
+
const cacheB = createStoreCache();
|
|
50
|
+
const a = openCacheInvalidation(cacheA, { kind: "redis", redis: redisA, origin: "a" });
|
|
51
|
+
const b = openCacheInvalidation(cacheB, { kind: "redis", redis: bus.client(), origin: "b" });
|
|
52
|
+
await Promise.resolve();
|
|
53
|
+
const key = seed(cacheA, "sql:notes");
|
|
54
|
+
seed(cacheB, "sql:notes");
|
|
55
|
+
b.publish(["sql:notes"]);
|
|
56
|
+
await Promise.resolve();
|
|
57
|
+
expect(cacheA.get(key)).toBeUndefined();
|
|
58
|
+
expect(cacheB.get<{ n: number }>(key)).toEqual({ n: 1 });
|
|
59
|
+
expect(publishA).toBe(0);
|
|
60
|
+
expect(CACHE_INVALIDATE_CHANNEL).toBe("oke:cache:invalidate");
|
|
61
|
+
await a.stop();
|
|
62
|
+
await b.stop();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("postgres invalidation is polled once and skips the sender", async () => {
|
|
66
|
+
const rows: Array<{ id: string; resource: string; origin: string; created_at: number }> = [];
|
|
67
|
+
let now = 1_000_000;
|
|
68
|
+
const sql = (): CacheBusSql => ({
|
|
69
|
+
async exec(statement, params) {
|
|
70
|
+
if (statement.startsWith("CREATE")) return { changes: 0 };
|
|
71
|
+
if (statement.startsWith("INSERT")) {
|
|
72
|
+
rows.push({
|
|
73
|
+
id: String(params?.[0]),
|
|
74
|
+
resource: String(params?.[1]),
|
|
75
|
+
origin: String(params?.[2]),
|
|
76
|
+
created_at: now,
|
|
77
|
+
});
|
|
78
|
+
return { changes: 1 };
|
|
79
|
+
}
|
|
80
|
+
if (statement.startsWith("DELETE")) {
|
|
81
|
+
const cutoff = Number(params?.[0]);
|
|
82
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
83
|
+
if (rows[i]!.created_at < cutoff) rows.splice(i, 1);
|
|
84
|
+
}
|
|
85
|
+
return { changes: 0 };
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`unmatched ${statement}`);
|
|
88
|
+
},
|
|
89
|
+
async query(statement) {
|
|
90
|
+
if (statement.includes("db_now")) return [{ db_now: now }];
|
|
91
|
+
if (statement.includes("FROM oke_cache_invalidations"))
|
|
92
|
+
return rows.map((row) => ({ ...row }));
|
|
93
|
+
throw new Error(`unmatched ${statement}`);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const cacheA = createStoreCache();
|
|
97
|
+
const cacheB = createStoreCache();
|
|
98
|
+
const a = openCacheInvalidation(cacheA, {
|
|
99
|
+
kind: "postgres",
|
|
100
|
+
origin: "a",
|
|
101
|
+
sql: async () => sql(),
|
|
102
|
+
});
|
|
103
|
+
const b = openCacheInvalidation(cacheB, {
|
|
104
|
+
kind: "postgres",
|
|
105
|
+
origin: "b",
|
|
106
|
+
sql: async () => sql(),
|
|
107
|
+
});
|
|
108
|
+
await a.poll();
|
|
109
|
+
await b.poll();
|
|
110
|
+
const key = seed(cacheA, "sql:notes");
|
|
111
|
+
seed(cacheB, "sql:notes");
|
|
112
|
+
now += 5;
|
|
113
|
+
b.publish(["sql:notes"]);
|
|
114
|
+
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
115
|
+
await a.poll();
|
|
116
|
+
await b.poll();
|
|
117
|
+
expect(cacheA.get(key)).toBeUndefined();
|
|
118
|
+
expect(cacheB.get<{ n: number }>(key)).toEqual({ n: 1 });
|
|
119
|
+
await a.stop();
|
|
120
|
+
await b.stop();
|
|
121
|
+
});
|