okengine 0.23.0 → 0.23.1
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 +1 -1
- 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/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- 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/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/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-C_N4qvai.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-Cq1TxniW.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-BaI4uxZz.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-DNyK7IIY.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-CRhBJMZR.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-D9twj8Xy.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-CsHWlVPU.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-ZaTJAhR9.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DQ-EMAfz.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-Cn_Fl79s.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-CmRFeWdT.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-Cn6R_dXz.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-Bx6EZhSe.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-DmdiUrfh.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-8P6vGXnn.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-BfGvpFSv.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-CWdUHbc0.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-CZOFZzay.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-ki_ishmX.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/drivers/journal-postgres.test.ts +22 -1
- package/src/drivers/journal-postgres.ts +223 -12
- package/src/drivers/signal-compete.test.ts +234 -0
- package/src/drivers/signal-postgres.ts +39 -19
- package/src/drivers/signal-redis.ts +167 -11
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +11 -0
- package/src/elements/channel/sql-ledger.ts +251 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache.test.ts +120 -15
- package/src/elements/store/cache.ts +200 -39
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +95 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +32 -16
- package/src/kernel/auto-cache.test.ts +228 -0
- 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 +61 -25
- package/src/kernel/boot.ts +16 -2
- 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 +46 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/idempotency.test.ts +2 -1
- package/src/kernel/journal.test.ts +114 -0
- package/src/kernel/journal.ts +369 -53
- 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/docs-server.ts +19 -2
- package/src/mcp/mcp.test.ts +83 -0
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +23 -2
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
|
@@ -0,0 +1,251 @@
|
|
|
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
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ChannelMedium } from "../../manifest/types.ts";
|
|
9
|
+
import type { ConsentStore, OptOut } from "./consent.ts";
|
|
10
|
+
import type { DeliveryReceipt, ReceiptLedger } from "./receipts.ts";
|
|
11
|
+
import type { SuppressionEntry, SuppressionReason, SuppressionStore } from "./suppression.ts";
|
|
12
|
+
|
|
13
|
+
/** SQL surface the channel ledger needs (`?` placeholders). */
|
|
14
|
+
export interface ChannelLedgerSql {
|
|
15
|
+
/**
|
|
16
|
+
* Run a query and return rows.
|
|
17
|
+
*
|
|
18
|
+
* @param sql - Statement
|
|
19
|
+
* @param params - Bindings
|
|
20
|
+
*/
|
|
21
|
+
query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
|
|
22
|
+
/**
|
|
23
|
+
* Run a statement.
|
|
24
|
+
*
|
|
25
|
+
* @param sql - Statement
|
|
26
|
+
* @param params - Bindings
|
|
27
|
+
*/
|
|
28
|
+
exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Shared channel ledger (consent + suppression + receipts). */
|
|
32
|
+
export interface PostgresChannelLedger {
|
|
33
|
+
readonly consent: ConsentStore;
|
|
34
|
+
readonly suppression: SuppressionStore;
|
|
35
|
+
readonly receipts: ReceiptLedger;
|
|
36
|
+
/** Wait for queued writes. */
|
|
37
|
+
flush(): Promise<void>;
|
|
38
|
+
/** Wait for queued writes, then replace the cache from SQL. */
|
|
39
|
+
reload(): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const CONSENT = "oke_channel_consent";
|
|
43
|
+
const BOUNCE = "oke_channel_bounce";
|
|
44
|
+
const RECEIPT = "oke_channel_receipt";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Open the ledger tables and load the current rows.
|
|
48
|
+
*
|
|
49
|
+
* @param sql - App SQL client
|
|
50
|
+
*/
|
|
51
|
+
export async function openPostgresChannelLedger(
|
|
52
|
+
sql: ChannelLedgerSql,
|
|
53
|
+
): Promise<PostgresChannelLedger> {
|
|
54
|
+
await sql.exec(
|
|
55
|
+
`CREATE TABLE IF NOT EXISTS ${CONSENT} (subject TEXT NOT NULL, medium TEXT NOT NULL, at BIGINT NOT NULL, PRIMARY KEY (subject, medium))`,
|
|
56
|
+
);
|
|
57
|
+
await sql.exec(
|
|
58
|
+
`CREATE TABLE IF NOT EXISTS ${BOUNCE} (subject TEXT NOT NULL, medium TEXT NOT NULL, at BIGINT NOT NULL, PRIMARY KEY (subject, medium))`,
|
|
59
|
+
);
|
|
60
|
+
await sql.exec(`CREATE TABLE IF NOT EXISTS ${RECEIPT} (id TEXT PRIMARY KEY, body TEXT NOT NULL)`);
|
|
61
|
+
|
|
62
|
+
const consents: OptOut[] = [];
|
|
63
|
+
const bounces: OptOut[] = [];
|
|
64
|
+
const receipts: DeliveryReceipt[] = [];
|
|
65
|
+
let chain: Promise<void> = Promise.resolve();
|
|
66
|
+
|
|
67
|
+
function enqueue(work: () => Promise<void>): void {
|
|
68
|
+
chain = chain.then(work);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function flush(): Promise<void> {
|
|
72
|
+
await chain;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function matches(rows: readonly OptOut[], subject: string, medium: ChannelMedium): boolean {
|
|
76
|
+
return rows.some(
|
|
77
|
+
(row) => row.subject === subject && (row.medium === "all" || row.medium === medium),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function remember(
|
|
82
|
+
rows: OptOut[],
|
|
83
|
+
subject: string,
|
|
84
|
+
medium: ChannelMedium | "all",
|
|
85
|
+
at: number,
|
|
86
|
+
): void {
|
|
87
|
+
const idx = rows.findIndex((row) => row.subject === subject && row.medium === medium);
|
|
88
|
+
const next: OptOut = { subject, medium, at };
|
|
89
|
+
if (idx >= 0) rows[idx] = next;
|
|
90
|
+
else rows.push(next);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function forget(rows: OptOut[], subject: string, medium: ChannelMedium | "all"): void {
|
|
94
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
95
|
+
const row = rows[i]!;
|
|
96
|
+
if (row.subject === subject && row.medium === medium) rows.splice(i, 1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const consent: ConsentStore = {
|
|
101
|
+
isOptedOut(subject, medium) {
|
|
102
|
+
return matches(consents, subject, medium);
|
|
103
|
+
},
|
|
104
|
+
optOut(subject, medium) {
|
|
105
|
+
const at = Date.now();
|
|
106
|
+
remember(consents, subject, medium, at);
|
|
107
|
+
enqueue(() =>
|
|
108
|
+
sql
|
|
109
|
+
.exec(
|
|
110
|
+
`INSERT INTO ${CONSENT} (subject, medium, at) VALUES (?, ?, ?) ON CONFLICT (subject, medium) DO UPDATE SET at = excluded.at`,
|
|
111
|
+
[subject, medium, at],
|
|
112
|
+
)
|
|
113
|
+
.then(() => undefined),
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
optIn(subject, medium) {
|
|
117
|
+
forget(consents, subject, medium);
|
|
118
|
+
enqueue(() =>
|
|
119
|
+
sql
|
|
120
|
+
.exec(`DELETE FROM ${CONSENT} WHERE subject = ? AND medium = ?`, [subject, medium])
|
|
121
|
+
.then(() => undefined),
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
list() {
|
|
125
|
+
return [...consents];
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
function reasonFor(
|
|
130
|
+
subject: string,
|
|
131
|
+
medium: ChannelMedium,
|
|
132
|
+
):
|
|
133
|
+
| { readonly suppressed: true; readonly reason: SuppressionReason }
|
|
134
|
+
| { readonly suppressed: false } {
|
|
135
|
+
if (consent.isOptedOut(subject, medium)) return { suppressed: true, reason: "opted-out" };
|
|
136
|
+
if (matches(bounces, subject, medium)) return { suppressed: true, reason: "prior-bounce" };
|
|
137
|
+
return { suppressed: false };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const suppression: SuppressionStore = {
|
|
141
|
+
consent,
|
|
142
|
+
isSuppressed: reasonFor,
|
|
143
|
+
optOut(subject, medium) {
|
|
144
|
+
consent.optOut(subject, medium);
|
|
145
|
+
},
|
|
146
|
+
addPriorBounce(subject, medium) {
|
|
147
|
+
const at = Date.now();
|
|
148
|
+
remember(bounces, subject, medium, at);
|
|
149
|
+
enqueue(() =>
|
|
150
|
+
sql
|
|
151
|
+
.exec(
|
|
152
|
+
`INSERT INTO ${BOUNCE} (subject, medium, at) VALUES (?, ?, ?) ON CONFLICT (subject, medium) DO UPDATE SET at = excluded.at`,
|
|
153
|
+
[subject, medium, at],
|
|
154
|
+
)
|
|
155
|
+
.then(() => undefined),
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
clear(subject, medium) {
|
|
159
|
+
consent.optIn(subject, medium);
|
|
160
|
+
forget(bounces, subject, medium);
|
|
161
|
+
enqueue(() =>
|
|
162
|
+
sql
|
|
163
|
+
.exec(`DELETE FROM ${BOUNCE} WHERE subject = ? AND medium = ?`, [subject, medium])
|
|
164
|
+
.then(() => undefined),
|
|
165
|
+
);
|
|
166
|
+
},
|
|
167
|
+
list() {
|
|
168
|
+
const rows: SuppressionEntry[] = [];
|
|
169
|
+
for (const row of consents) {
|
|
170
|
+
rows.push({ subject: row.subject, medium: row.medium, reason: "opted-out", at: row.at });
|
|
171
|
+
}
|
|
172
|
+
for (const row of bounces) {
|
|
173
|
+
rows.push({ subject: row.subject, medium: row.medium, reason: "prior-bounce", at: row.at });
|
|
174
|
+
}
|
|
175
|
+
return rows;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const receiptLedger: ReceiptLedger = {
|
|
180
|
+
record(receipt) {
|
|
181
|
+
receipts.push(receipt);
|
|
182
|
+
enqueue(() =>
|
|
183
|
+
sql
|
|
184
|
+
.exec(`INSERT INTO ${RECEIPT} (id, body) VALUES (?, ?)`, [
|
|
185
|
+
receipt.id,
|
|
186
|
+
JSON.stringify(receipt),
|
|
187
|
+
])
|
|
188
|
+
.then(() => undefined),
|
|
189
|
+
);
|
|
190
|
+
},
|
|
191
|
+
all() {
|
|
192
|
+
return [...receipts];
|
|
193
|
+
},
|
|
194
|
+
forTemplate(template) {
|
|
195
|
+
return receipts.filter((row) => row.template === template);
|
|
196
|
+
},
|
|
197
|
+
byMessageId(messageId) {
|
|
198
|
+
for (let i = receipts.length - 1; i >= 0; i--) {
|
|
199
|
+
const row = receipts[i]!;
|
|
200
|
+
if (row.messageId === messageId || row.id === messageId) return row;
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
},
|
|
204
|
+
updateStatus(messageId, patch) {
|
|
205
|
+
const idx = receipts.findIndex((row) => row.messageId === messageId || row.id === messageId);
|
|
206
|
+
if (idx < 0) return undefined;
|
|
207
|
+
const prev = receipts[idx]!;
|
|
208
|
+
const next: DeliveryReceipt = {
|
|
209
|
+
...prev,
|
|
210
|
+
status: patch.status,
|
|
211
|
+
at: patch.at ?? prev.at,
|
|
212
|
+
...(patch.error !== undefined ? { error: patch.error } : {}),
|
|
213
|
+
};
|
|
214
|
+
receipts[idx] = next;
|
|
215
|
+
enqueue(() =>
|
|
216
|
+
sql
|
|
217
|
+
.exec(`UPDATE ${RECEIPT} SET body = ? WHERE id = ?`, [JSON.stringify(next), next.id])
|
|
218
|
+
.then(() => undefined),
|
|
219
|
+
);
|
|
220
|
+
return next;
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
async function loadOptOuts(table: string, into: OptOut[]): Promise<void> {
|
|
225
|
+
const rows = await sql.query(`SELECT subject, medium, at FROM ${table}`);
|
|
226
|
+
into.length = 0;
|
|
227
|
+
for (const row of rows) {
|
|
228
|
+
into.push({
|
|
229
|
+
subject: String(row.subject),
|
|
230
|
+
medium: String(row.medium) as ChannelMedium | "all",
|
|
231
|
+
at: Number(row.at),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function reload(): Promise<void> {
|
|
237
|
+
await flush();
|
|
238
|
+
await loadOptOuts(CONSENT, consents);
|
|
239
|
+
await loadOptOuts(BOUNCE, bounces);
|
|
240
|
+
const rows = await sql.query(`SELECT id, body FROM ${RECEIPT}`);
|
|
241
|
+
receipts.length = 0;
|
|
242
|
+
for (const row of rows) {
|
|
243
|
+
const parsed: unknown = JSON.parse(String(row.body));
|
|
244
|
+
if (parsed && typeof parsed === "object") receipts.push(parsed as DeliveryReceipt);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await reload();
|
|
249
|
+
|
|
250
|
+
return { consent, suppression, receipts: receiptLedger, flush, reload };
|
|
251
|
+
}
|
|
@@ -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
|
});
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
isInvalidatedByWrite,
|
|
18
18
|
resolveCacheEffects,
|
|
19
19
|
tier1DimsByResource,
|
|
20
|
+
tier1FlowDims,
|
|
20
21
|
tier1KeysForReads,
|
|
21
22
|
tier1Lookup,
|
|
22
23
|
} from "./cache.ts";
|
|
@@ -79,21 +80,41 @@ describe("tier-1 cache — exact per-resource invalidation (path b)", () => {
|
|
|
79
80
|
});
|
|
80
81
|
|
|
81
82
|
describe("autoCacheEligible", () => {
|
|
82
|
-
test("
|
|
83
|
-
|
|
84
|
-
expect(autoCacheEligible({
|
|
85
|
-
expect(autoCacheEligible({
|
|
86
|
-
expect(autoCacheEligible({
|
|
87
|
-
expect(autoCacheEligible({
|
|
88
|
-
expect(autoCacheEligible({
|
|
89
|
-
|
|
90
|
-
);
|
|
91
|
-
expect(autoCacheEligible({
|
|
83
|
+
test("pure store reads cache by default; side effects never cache", () => {
|
|
84
|
+
const reads: Effects = { reads: ["sql:notes"] };
|
|
85
|
+
expect(autoCacheEligible({ effects: reads })).toBe(true);
|
|
86
|
+
expect(autoCacheEligible({ auto: false, effects: reads })).toBe(false);
|
|
87
|
+
expect(autoCacheEligible({ auto: false, cache: true, effects: reads })).toBe(true);
|
|
88
|
+
expect(autoCacheEligible({ auto: true, effects: reads })).toBe(true);
|
|
89
|
+
expect(autoCacheEligible({ cache: true, effects: reads })).toBe(true);
|
|
90
|
+
expect(autoCacheEligible({ cache: "30s", effects: reads })).toBe(true);
|
|
91
|
+
expect(autoCacheEligible({ auto: true, cache: false, effects: reads })).toBe(false);
|
|
92
|
+
expect(autoCacheEligible({ cache: true, durable: true, effects: reads })).toBe(false);
|
|
93
|
+
expect(
|
|
94
|
+
autoCacheEligible({ cache: true, effects: { reads: ["sql:notes"], writes: ["sql:notes"] } }),
|
|
95
|
+
).toBe(false);
|
|
96
|
+
expect(
|
|
97
|
+
autoCacheEligible({ cache: true, effects: { reads: ["sql:notes"], asks: ["task-suggest"] } }),
|
|
98
|
+
).toBe(false);
|
|
99
|
+
for (const extra of [
|
|
100
|
+
{ emits: ["note.created"] },
|
|
101
|
+
{ sends: ["welcome"] },
|
|
102
|
+
{ fetches: ["api.example.com"] },
|
|
103
|
+
{ secrets: ["stripe"] },
|
|
104
|
+
{ calls: ["notes.create"] },
|
|
105
|
+
{ embeds: ["embed-small"] },
|
|
106
|
+
{ decides: ["route"] },
|
|
107
|
+
]) {
|
|
108
|
+
expect(autoCacheEligible({ auto: true, effects: { reads: ["sql:notes"], ...extra } })).toBe(
|
|
109
|
+
false,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
expect(autoCacheEligible({ auto: true, effects: { reads: ["runs"] } })).toBe(false);
|
|
113
|
+
expect(autoCacheEligible({ auto: true, effects: { reads: ["signal:notify"] } })).toBe(false);
|
|
114
|
+
expect(autoCacheEligible({ auto: true, effects: { reads: ["sql:notes", "runs"] } })).toBe(
|
|
92
115
|
false,
|
|
93
116
|
);
|
|
94
|
-
expect(autoCacheEligible({
|
|
95
|
-
expect(autoCacheEligible({ effects: { reads: ["signal:notify"] } })).toBe(false);
|
|
96
|
-
expect(autoCacheEligible({ effects: {} })).toBe(false);
|
|
117
|
+
expect(autoCacheEligible({ auto: true, effects: {} })).toBe(false);
|
|
97
118
|
});
|
|
98
119
|
|
|
99
120
|
test("flow+input dims keep list and get from colliding; invalidation still uses the resource", () => {
|
|
@@ -107,7 +128,7 @@ describe("autoCacheEligible", () => {
|
|
|
107
128
|
expect(isInvalidatedByWrite(getKeys[0]!, { writes: ["sql:views"] })).toBe(true);
|
|
108
129
|
});
|
|
109
130
|
|
|
110
|
-
test("effectsFromLedger keeps store
|
|
131
|
+
test("effectsFromLedger keeps store effects and side effects, including non-store reads", () => {
|
|
111
132
|
expect(
|
|
112
133
|
effectsFromLedger([
|
|
113
134
|
{ kind: "read", resource: "sql:views" },
|
|
@@ -117,14 +138,98 @@ describe("autoCacheEligible", () => {
|
|
|
117
138
|
{ kind: "write", resource: "sql:views" },
|
|
118
139
|
{ kind: "ask", resource: "summarize" },
|
|
119
140
|
{ kind: "emit", resource: "view-changed" },
|
|
141
|
+
{ kind: "send", resource: "welcome" },
|
|
142
|
+
{ kind: "fetch", resource: "api.example.com" },
|
|
143
|
+
{ kind: "secret", resource: "stripe" },
|
|
144
|
+
{ kind: "call", resource: "notes.create" },
|
|
145
|
+
{ kind: "embed", resource: "embed-small" },
|
|
146
|
+
{ kind: "decide", resource: "route" },
|
|
120
147
|
]),
|
|
121
148
|
).toEqual({
|
|
122
|
-
reads: ["sql:views"],
|
|
149
|
+
reads: ["sql:views", "runs", "signal:notify"],
|
|
123
150
|
writes: ["sql:views"],
|
|
124
151
|
asks: ["summarize"],
|
|
152
|
+
emits: ["view-changed"],
|
|
153
|
+
sends: ["welcome"],
|
|
154
|
+
fetches: ["api.example.com"],
|
|
155
|
+
secrets: ["stripe"],
|
|
156
|
+
calls: ["notes.create"],
|
|
157
|
+
embeds: ["embed-small"],
|
|
158
|
+
decides: ["route"],
|
|
125
159
|
});
|
|
126
160
|
});
|
|
127
161
|
|
|
162
|
+
test("tier-1 dims always include tenant, locale, scopes, and roles", () => {
|
|
163
|
+
const effects: Effects = { reads: ["sql:notes"] };
|
|
164
|
+
const a = tier1FlowDims(
|
|
165
|
+
"notes.list",
|
|
166
|
+
{},
|
|
167
|
+
{
|
|
168
|
+
userId: "u1",
|
|
169
|
+
tenantId: "ta",
|
|
170
|
+
locale: "en",
|
|
171
|
+
scopes: ["b", "a"],
|
|
172
|
+
roles: ["admin"],
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
const b = tier1FlowDims(
|
|
176
|
+
"notes.list",
|
|
177
|
+
{},
|
|
178
|
+
{
|
|
179
|
+
userId: "u1",
|
|
180
|
+
tenantId: "tb",
|
|
181
|
+
locale: "en",
|
|
182
|
+
scopes: ["a", "b"],
|
|
183
|
+
roles: ["admin"],
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
const locale = tier1FlowDims(
|
|
187
|
+
"notes.list",
|
|
188
|
+
{},
|
|
189
|
+
{
|
|
190
|
+
userId: "u1",
|
|
191
|
+
tenantId: "ta",
|
|
192
|
+
locale: "ar",
|
|
193
|
+
scopes: ["a", "b"],
|
|
194
|
+
roles: ["member"],
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
expect(a).toContain("t:ta");
|
|
198
|
+
expect(a).toContain("l:en");
|
|
199
|
+
expect(a).toContain("s:a,b");
|
|
200
|
+
expect(a).toContain("r:admin");
|
|
201
|
+
expect(a).not.toEqual(b);
|
|
202
|
+
expect(
|
|
203
|
+
tier1DimsByResource(
|
|
204
|
+
effects,
|
|
205
|
+
"notes.list",
|
|
206
|
+
{},
|
|
207
|
+
{
|
|
208
|
+
userId: "u1",
|
|
209
|
+
tenantId: "ta",
|
|
210
|
+
locale: "en",
|
|
211
|
+
scopes: ["a"],
|
|
212
|
+
roles: ["admin"],
|
|
213
|
+
},
|
|
214
|
+
)["sql:notes"],
|
|
215
|
+
).not.toEqual(
|
|
216
|
+
tier1DimsByResource(
|
|
217
|
+
effects,
|
|
218
|
+
"notes.list",
|
|
219
|
+
{},
|
|
220
|
+
{
|
|
221
|
+
userId: "u1",
|
|
222
|
+
tenantId: "ta",
|
|
223
|
+
locale: "ar",
|
|
224
|
+
scopes: ["a"],
|
|
225
|
+
roles: ["member"],
|
|
226
|
+
},
|
|
227
|
+
)["sql:notes"],
|
|
228
|
+
);
|
|
229
|
+
expect(locale).toContain("l:ar");
|
|
230
|
+
expect(locale).toContain("r:member");
|
|
231
|
+
});
|
|
232
|
+
|
|
128
233
|
test("resolveCacheEffects prefers stamped reads, then learned reads", () => {
|
|
129
234
|
expect(resolveCacheEffects({ reads: ["sql:views"] }, ["sql:tasks"])).toEqual({
|
|
130
235
|
reads: ["sql:views"],
|