okengine 0.16.0 → 0.17.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/manifest.v1.schema.json +21 -2
- package/package.json +1 -1
- package/site/content/docs/elements/clock.mdx +7 -0
- package/site/content/docs/elements/flow.mdx +14 -12
- package/site/content/docs/elements/gate.mdx +58 -8
- package/site/content/docs/elements/signal.mdx +46 -17
- package/site/content/docs/elements/store.mdx +23 -17
- package/site/content/docs/elements/vault.mdx +23 -0
- package/site/content/docs/get-started/project-structure.mdx +4 -4
- package/site/content/docs/reference/cli.md +1 -1
- package/site/content/docs/reference/client.mdx +72 -17
- package/site/content/docs/reference/configuration.mdx +7 -4
- package/site/content/docs/reference/errors.mdx +24 -17
- package/site/content/docs/reference/fx.mdx +15 -9
- package/src/auth/api-key-sql.ts +11 -4
- package/src/auth/api-keys.ts +3 -0
- package/src/auth/config.ts +19 -0
- package/src/auth/index.ts +37 -0
- package/src/auth/plugin.ts +6 -1
- package/src/auth/sessions.ts +18 -0
- package/src/auth/tables.ts +32 -0
- package/src/auth/tenant-config.ts +74 -0
- package/src/auth/tenant-tables.ts +11 -0
- package/src/auth/tenants.test.ts +63 -0
- package/src/auth/tenants.ts +360 -0
- package/src/cli/build.ts +2 -2
- package/src/client/budget.test.ts +1 -1
- package/src/client/create.ts +75 -5
- package/src/client/index.ts +13 -0
- package/src/client/live.test.ts +422 -0
- package/src/client/live.ts +389 -0
- package/src/client/notes-contract.test.ts +41 -0
- package/src/client/types.ts +85 -6
- package/src/client-react/index.ts +85 -1
- package/src/client-react/use-live.test.ts +129 -0
- package/src/compiler/effects-infer.ts +22 -2
- package/src/compiler/extract.test.ts +143 -11
- package/src/compiler/extract.ts +210 -30
- package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
- package/src/compiler/fixtures/skyport.expected.json +2 -3
- package/src/compiler/response.ts +41 -11
- package/src/console/server/app.ts +44 -11
- package/src/console/server/console.test.ts +6 -8
- package/src/console/server/gates.ts +2 -9
- package/src/console/server/store.test.ts +17 -0
- package/src/console/server/store.ts +43 -1
- package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-CXVWWMmD.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-XDpAJO8f.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-BBj2QJCu.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-BFaay44m.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
- package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-Ca2Z-E52.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-BmOeAFwg.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
- package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
- package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
- package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
- package/src/drivers/index.ts +2 -0
- package/src/drivers/journal-postgres.ts +12 -3
- package/src/drivers/pg-rls.ts +26 -2
- package/src/drivers/pg-vault-rls.ts +68 -0
- package/src/drivers/signal-engine.ts +69 -15
- package/src/drivers/signal-live-iter.ts +65 -0
- package/src/drivers/signal-nats.ts +2 -1
- package/src/drivers/signal-postgres.ts +95 -12
- package/src/drivers/signal-redis.ts +2 -1
- package/src/drivers/signal-retention.ts +64 -0
- package/src/drivers/signal-types.ts +35 -5
- package/src/elements/clock/declare.ts +64 -2
- package/src/elements/clock/reconcile.ts +98 -25
- package/src/elements/clock/runtime.ts +13 -2
- package/src/elements/clock.test.ts +28 -0
- package/src/elements/clock.ts +9 -1
- package/src/elements/gate/permissions.ts +12 -0
- package/src/elements/gate.ts +6 -1
- package/src/elements/signal/declare.ts +44 -10
- package/src/elements/signal/delivery-modes.test.ts +90 -4
- package/src/elements/signal/order-lifecycle.test.ts +20 -4
- package/src/elements/signal/runtime.ts +42 -0
- package/src/elements/signal.test.ts +21 -8
- package/src/elements/signal.ts +1 -1
- package/src/elements/store/declare.ts +7 -0
- package/src/elements/store/rls-identity.test.ts +29 -0
- package/src/elements/store/rls-identity.ts +3 -0
- package/src/elements/store/schema-decl.ts +63 -3
- package/src/elements/store/schema-tenant.ts +41 -0
- package/src/elements/store/sql-rls-isolation.test.ts +39 -0
- package/src/elements/store.ts +2 -0
- package/src/elements/vault/builtin-adapter.ts +15 -2
- package/src/elements/vault/declare.ts +8 -0
- package/src/elements/vault/runtime.ts +7 -0
- package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
- package/src/elements/vault/storage.ts +9 -0
- package/src/elements/vault/test-helpers.ts +2 -1
- package/src/i18n/catalogs/ar.ts +19 -0
- package/src/i18n/catalogs/en.ts +19 -0
- package/src/index.ts +1 -0
- package/src/kernel/adopt-routes.ts +56 -8
- package/src/kernel/app-tenant.ts +122 -0
- package/src/kernel/app.ts +183 -56
- package/src/kernel/auth-resolve.ts +3 -0
- package/src/kernel/boot-bind/clock.ts +9 -3
- package/src/kernel/boot.ts +2 -0
- package/src/kernel/budget.test.ts +1 -1
- package/src/kernel/clock-durable.ts +8 -0
- package/src/kernel/clock-per-tenant-name.ts +5 -0
- package/src/kernel/clock-reconcile.ts +8 -0
- package/src/kernel/errors-live-resume.ts +15 -0
- package/src/kernel/errors-tenant.ts +29 -0
- package/src/kernel/errors.registry.test.ts +31 -3
- package/src/kernel/errors.ts +43 -3
- package/src/kernel/flow.ts +26 -5
- package/src/kernel/fx-auth-keys.ts +6 -1
- package/src/kernel/fx-auth-tenants.test.ts +87 -0
- package/src/kernel/fx-auth-tenants.ts +286 -0
- package/src/kernel/fx-live-stream.ts +149 -0
- package/src/kernel/fx-live.test.ts +157 -0
- package/src/kernel/fx-runtime.ts +15 -0
- package/src/kernel/fx-tenant-store.ts +213 -0
- package/src/kernel/fx.test.ts +91 -0
- package/src/kernel/fx.ts +173 -13
- package/src/kernel/hooks.ts +2 -2
- package/src/kernel/http-resource.ts +9 -18
- package/src/kernel/index.ts +2 -0
- package/src/kernel/journal.ts +12 -0
- package/src/kernel/live-http.test.ts +78 -0
- package/src/kernel/live-http.ts +114 -0
- package/src/kernel/live-resume.test.ts +125 -0
- package/src/kernel/on.ts +51 -0
- package/src/kernel/pipeline-tenant.ts +49 -0
- package/src/kernel/pipeline.test.ts +1 -1
- package/src/kernel/pipeline.ts +37 -2
- package/src/kernel/resource-mount.test.ts +10 -27
- package/src/kernel/tenant-resolve.test.ts +101 -0
- package/src/kernel/tenant-resolve.ts +124 -0
- package/src/kernel/tenant-roles.test.ts +87 -0
- package/src/kernel/triggers.ts +59 -21
- package/src/manifest/diff.test.ts +11 -2
- package/src/manifest/diff.ts +53 -11
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +0 -1
- package/src/manifest/types.ts +52 -6
- package/src/release/build-lib.ts +13 -1
- package/src/release/limits.ts +2 -2
- package/src/release/measure.ts +55 -1
- package/src/client/live-gap.test.ts +0 -35
|
@@ -9,10 +9,12 @@ import type { SignalDecl } from "../elements/signal/declare.ts";
|
|
|
9
9
|
import type { SignalDelivery } from "../manifest/types.ts";
|
|
10
10
|
import { DryRunWriteIsolationError, setDryRunMessageId, withDryRun } from "../kernel/dry-run.ts";
|
|
11
11
|
import { OkeError, OKE_ERRORS } from "../kernel/errors.ts";
|
|
12
|
+
import { LIVE_RESUME_GAP } from "../kernel/errors-live-resume.ts";
|
|
12
13
|
import {
|
|
13
14
|
SIGNAL_DEFAULT_LEASE_MS,
|
|
14
15
|
validateSignalEmitPayload,
|
|
15
16
|
type DeadLetter,
|
|
17
|
+
type LiveEvent,
|
|
16
18
|
type LiveHandler,
|
|
17
19
|
type SignalBus,
|
|
18
20
|
type SignalDiscardOptions,
|
|
@@ -27,7 +29,10 @@ import {
|
|
|
27
29
|
type SignalStats,
|
|
28
30
|
type SignalTransaction,
|
|
29
31
|
type SignalUnsubscribe,
|
|
32
|
+
type LiveSubscribeOptions,
|
|
30
33
|
} from "./signal-types.ts";
|
|
34
|
+
import { createLiveIterable } from "./signal-live-iter.ts";
|
|
35
|
+
import { liveIdsToPrune, skipAfterId } from "./signal-retention.ts";
|
|
31
36
|
|
|
32
37
|
/** Row shape in `oke_signal_messages`. */
|
|
33
38
|
interface MsgRow {
|
|
@@ -261,6 +266,23 @@ export function createPostgresSignalFake(options?: {
|
|
|
261
266
|
const state = view();
|
|
262
267
|
|
|
263
268
|
if (/^CREATE\s+TABLE/i.test(text)) return { changes: 0 };
|
|
269
|
+
if (/^CREATE\s+INDEX/i.test(text)) return { changes: 0 };
|
|
270
|
+
|
|
271
|
+
const delLive =
|
|
272
|
+
/^DELETE\s+FROM\s+oke_signal_messages\s+WHERE\s+id\s*=\s*\?\s+AND\s+delivery\s*=\s*'live'\s*$/i.exec(
|
|
273
|
+
text,
|
|
274
|
+
);
|
|
275
|
+
if (delLive) {
|
|
276
|
+
let changes = 0;
|
|
277
|
+
for (let i = state.messages.length - 1; i >= 0; i--) {
|
|
278
|
+
const m = state.messages[i]!;
|
|
279
|
+
if (m.id === params[0] && m.delivery === "live") {
|
|
280
|
+
state.messages.splice(i, 1);
|
|
281
|
+
changes += 1;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { changes };
|
|
285
|
+
}
|
|
264
286
|
|
|
265
287
|
const delDead =
|
|
266
288
|
/^DELETE\s+FROM\s+oke_signal_messages\s+WHERE\s+id\s*=\s*\?\s+AND\s+signal\s*=\s*\?\s+AND\s+status\s*=\s*'dead'\s*$/i.exec(
|
|
@@ -440,6 +462,13 @@ async function ensureSchema(sql: PostgresSignalSql): Promise<void> {
|
|
|
440
462
|
key TEXT PRIMARY KEY,
|
|
441
463
|
value TEXT
|
|
442
464
|
)`);
|
|
465
|
+
try {
|
|
466
|
+
await sql.exec(
|
|
467
|
+
`CREATE INDEX IF NOT EXISTS oke_signal_messages_live_created ON oke_signal_messages (signal, delivery, created_at)`,
|
|
468
|
+
);
|
|
469
|
+
} catch {
|
|
470
|
+
/* fake / older engines without IF NOT EXISTS — ignore */
|
|
471
|
+
}
|
|
443
472
|
}
|
|
444
473
|
|
|
445
474
|
function rowToMessage(row: Record<string, unknown>): SignalMessage {
|
|
@@ -687,27 +716,71 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
687
716
|
};
|
|
688
717
|
}
|
|
689
718
|
|
|
690
|
-
|
|
719
|
+
function live(signal: string, opts?: LiveSubscribeOptions): AsyncIterable<LiveEvent> {
|
|
691
720
|
const decl = requireDecl(signal);
|
|
692
721
|
if (decl.delivery !== "live") {
|
|
693
722
|
throw new Error(`signal "${signal}" is not delivery: "live"`);
|
|
694
723
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
724
|
+
const afterId = opts?.afterId;
|
|
725
|
+
return createLiveIterable(async (emit) => {
|
|
726
|
+
await pruneLive(signal);
|
|
727
|
+
const historyRows = await sql.query(
|
|
728
|
+
`SELECT * FROM oke_signal_messages WHERE signal = ? AND delivery = 'live' ORDER BY created_at ASC`,
|
|
729
|
+
[signal],
|
|
730
|
+
);
|
|
731
|
+
const history: LiveEvent[] = historyRows.map((row) => ({
|
|
732
|
+
id: String(row.id),
|
|
733
|
+
payload: JSON.parse(String(row.payload)),
|
|
734
|
+
}));
|
|
735
|
+
const skipped = skipAfterId(history, afterId);
|
|
736
|
+
if (afterId !== undefined && afterId.length > 0 && !skipped.found) {
|
|
737
|
+
throw new OkeError(LIVE_RESUME_GAP, { signal, afterId });
|
|
738
|
+
}
|
|
739
|
+
let set = liveHandlers.get(signal);
|
|
740
|
+
if (!set) {
|
|
741
|
+
set = new Set();
|
|
742
|
+
liveHandlers.set(signal, set);
|
|
743
|
+
}
|
|
744
|
+
const handler: LiveHandler = (event) => {
|
|
745
|
+
emit(event);
|
|
746
|
+
};
|
|
747
|
+
set.add(handler);
|
|
748
|
+
for (const event of skipped.rest) {
|
|
749
|
+
emit(event);
|
|
750
|
+
}
|
|
751
|
+
return () => {
|
|
752
|
+
set.delete(handler);
|
|
753
|
+
};
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function checkLiveResume(signal: string, afterId: string): Promise<void> {
|
|
758
|
+
requireDecl(signal);
|
|
759
|
+
await pruneLive(signal);
|
|
760
|
+
const history = await sql.query(
|
|
761
|
+
`SELECT * FROM oke_signal_messages WHERE signal = ? AND delivery = 'live' ORDER BY created_at ASC`,
|
|
762
|
+
[signal],
|
|
763
|
+
);
|
|
764
|
+
if (!history.some((row) => String(row.id) === afterId)) {
|
|
765
|
+
throw new OkeError(LIVE_RESUME_GAP, { signal, afterId });
|
|
699
766
|
}
|
|
700
|
-
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function pruneLive(signal: string): Promise<void> {
|
|
770
|
+
const decl = signals.get(signal);
|
|
771
|
+
if (decl?.retention === undefined) return;
|
|
701
772
|
const history = await sql.query(
|
|
702
773
|
`SELECT * FROM oke_signal_messages WHERE signal = ? AND delivery = 'live' ORDER BY created_at ASC`,
|
|
703
774
|
[signal],
|
|
704
775
|
);
|
|
705
|
-
|
|
706
|
-
|
|
776
|
+
const drop = liveIdsToPrune(
|
|
777
|
+
history.map((row) => ({ id: String(row.id), createdAt: Number(row.created_at) })),
|
|
778
|
+
decl.retention,
|
|
779
|
+
now(),
|
|
780
|
+
);
|
|
781
|
+
for (const id of drop) {
|
|
782
|
+
await sql.exec(`DELETE FROM oke_signal_messages WHERE id = ? AND delivery = 'live'`, [id]);
|
|
707
783
|
}
|
|
708
|
-
return () => {
|
|
709
|
-
set!.delete(handler);
|
|
710
|
-
};
|
|
711
784
|
}
|
|
712
785
|
|
|
713
786
|
async function deliverOnce(
|
|
@@ -830,7 +903,8 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
830
903
|
const handlers = liveHandlers.get(String(row.signal));
|
|
831
904
|
const payload = JSON.parse(String(row.payload));
|
|
832
905
|
if (handlers) {
|
|
833
|
-
|
|
906
|
+
const event: LiveEvent = { id: String(row.id), payload };
|
|
907
|
+
for (const h of handlers) await h(event);
|
|
834
908
|
}
|
|
835
909
|
const sig = String(row.signal);
|
|
836
910
|
let list = recentLive.get(sig);
|
|
@@ -846,6 +920,14 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
846
920
|
[row.id],
|
|
847
921
|
);
|
|
848
922
|
}
|
|
923
|
+
const pruned = new Set<string>();
|
|
924
|
+
for (const row of pending) {
|
|
925
|
+
if (row.delivery !== "live") continue;
|
|
926
|
+
const sig = String(row.signal);
|
|
927
|
+
if (pruned.has(sig)) continue;
|
|
928
|
+
pruned.add(sig);
|
|
929
|
+
await pruneLive(sig);
|
|
930
|
+
}
|
|
849
931
|
return progress;
|
|
850
932
|
}
|
|
851
933
|
|
|
@@ -1114,6 +1196,7 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
1114
1196
|
begin,
|
|
1115
1197
|
subscribe,
|
|
1116
1198
|
live,
|
|
1199
|
+
checkLiveResume,
|
|
1117
1200
|
drain,
|
|
1118
1201
|
deadLetters,
|
|
1119
1202
|
inspect,
|
|
@@ -253,7 +253,8 @@ export async function openRedisSignal(options: SignalOpenOptions): Promise<Signa
|
|
|
253
253
|
};
|
|
254
254
|
},
|
|
255
255
|
subscribe: (signal, subscriberId, handler) => outbox.subscribe(signal, subscriberId, handler),
|
|
256
|
-
live: (signal,
|
|
256
|
+
live: (signal, opts) => outbox.live(signal, opts),
|
|
257
|
+
checkLiveResume: (signal, afterId) => outbox.checkLiveResume(signal, afterId),
|
|
257
258
|
drain: () => outbox.drain(),
|
|
258
259
|
deadLetters: (s) => outbox.deadLetters(s),
|
|
259
260
|
inspect: (s) => outbox.inspect(s),
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-tape retention — which history rows survive `maxAge` / `maxCount`.
|
|
3
|
+
*
|
|
4
|
+
* AND-combined when both are set. Omitted retention is unbounded.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { parseDurationMs } from "../elements/clock/duration.ts";
|
|
8
|
+
import type { SignalRetention } from "../elements/signal/declare.ts";
|
|
9
|
+
|
|
10
|
+
/** One live history row used to decide prune vs keep. */
|
|
11
|
+
export interface LiveRetentionRow {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly createdAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Ids that fall outside {@link SignalRetention} (oldest first among drops).
|
|
18
|
+
*
|
|
19
|
+
* @param rows - Live rows for one signal
|
|
20
|
+
* @param retention - Declared cap; omit or empty = keep all
|
|
21
|
+
* @param now - Clock ms
|
|
22
|
+
*/
|
|
23
|
+
export function liveIdsToPrune(
|
|
24
|
+
rows: readonly LiveRetentionRow[],
|
|
25
|
+
retention: SignalRetention | undefined,
|
|
26
|
+
now: number,
|
|
27
|
+
): string[] {
|
|
28
|
+
if (retention === undefined) return [];
|
|
29
|
+
const maxAge = retention.maxAge;
|
|
30
|
+
const maxCount = retention.maxCount;
|
|
31
|
+
if (maxAge === undefined && maxCount === undefined) return [];
|
|
32
|
+
|
|
33
|
+
const indexed = rows.map((row, index) => ({ ...row, index }));
|
|
34
|
+
const sorted = indexed.toSorted((a, b) => a.createdAt - b.createdAt || a.index - b.index);
|
|
35
|
+
let keep = sorted;
|
|
36
|
+
if (maxAge !== undefined) {
|
|
37
|
+
const windowMs = parseDurationMs(maxAge);
|
|
38
|
+
const cutoff = now - windowMs;
|
|
39
|
+
keep = keep.filter((row) => row.createdAt >= cutoff);
|
|
40
|
+
}
|
|
41
|
+
if (maxCount !== undefined) {
|
|
42
|
+
keep = keep.slice(-maxCount);
|
|
43
|
+
}
|
|
44
|
+
const keepIds = new Set(keep.map((row) => row.id));
|
|
45
|
+
return sorted.filter((row) => !keepIds.has(row.id)).map((row) => row.id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Exclusive skip through `afterId` on a snapshot already ordered oldest-first.
|
|
50
|
+
*
|
|
51
|
+
* @param rows - Tape snapshot
|
|
52
|
+
* @param afterId - SSE cursor; omit = full tape
|
|
53
|
+
*/
|
|
54
|
+
export function skipAfterId<T extends { readonly id: string }>(
|
|
55
|
+
rows: readonly T[],
|
|
56
|
+
afterId: string | undefined,
|
|
57
|
+
): { readonly found: boolean; readonly rest: readonly T[] } {
|
|
58
|
+
if (afterId === undefined || afterId.length === 0) {
|
|
59
|
+
return { found: true, rest: rows };
|
|
60
|
+
}
|
|
61
|
+
const idx = rows.findIndex((row) => row.id === afterId);
|
|
62
|
+
if (idx < 0) return { found: false, rest: rows };
|
|
63
|
+
return { found: true, rest: rows.slice(idx + 1) };
|
|
64
|
+
}
|
|
@@ -189,8 +189,25 @@ export type SignalUnsubscribe = () => void | Promise<void>;
|
|
|
189
189
|
/** Consumer handler for once / broadcast. */
|
|
190
190
|
export type SignalHandler = (message: SignalMessage) => void | Promise<void>;
|
|
191
191
|
|
|
192
|
-
/**
|
|
193
|
-
export
|
|
192
|
+
/** One retained or live-pushed event from {@link SignalBus.live}. */
|
|
193
|
+
export interface LiveEvent {
|
|
194
|
+
/** Durable message id (SSE `id:`). */
|
|
195
|
+
readonly id: string;
|
|
196
|
+
/** Signal payload. */
|
|
197
|
+
readonly payload: unknown;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Options for {@link SignalBus.live}. */
|
|
201
|
+
export interface LiveSubscribeOptions {
|
|
202
|
+
/**
|
|
203
|
+
* Exclusive SSE cursor — replay events after this id, then continue.
|
|
204
|
+
* Omit for the full retained tape. Unknown / pruned ids throw OKE1014.
|
|
205
|
+
*/
|
|
206
|
+
readonly afterId?: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Live subscriber (payload + id). */
|
|
210
|
+
export type LiveHandler = (event: LiveEvent) => void | Promise<void>;
|
|
194
211
|
|
|
195
212
|
/**
|
|
196
213
|
* Transaction that enrols store writes and signal emits atomically.
|
|
@@ -313,10 +330,23 @@ export interface SignalBus {
|
|
|
313
330
|
/**
|
|
314
331
|
* Client-subscribable live feed.
|
|
315
332
|
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
333
|
+
* Replays retained history, then yields new events until the iterator
|
|
334
|
+
* returns. `delivery` must be `"live"`. `afterId` skips through that
|
|
335
|
+
* id (exclusive); unknown or pruned ids throw OKE1014.
|
|
336
|
+
*
|
|
337
|
+
* @param signal - Signal name
|
|
338
|
+
* @param opts - Optional resume cursor
|
|
339
|
+
*/
|
|
340
|
+
live(signal: string, opts?: LiveSubscribeOptions): AsyncIterable<LiveEvent>;
|
|
341
|
+
/**
|
|
342
|
+
* Validate a live resume cursor without waiting for new events.
|
|
343
|
+
*
|
|
344
|
+
* Prunes the tape first. Throws OKE1014 when `afterId` is absent.
|
|
345
|
+
*
|
|
346
|
+
* @param signal - Signal name
|
|
347
|
+
* @param afterId - SSE cursor
|
|
318
348
|
*/
|
|
319
|
-
|
|
349
|
+
checkLiveResume(signal: string, afterId: string): Promise<void>;
|
|
320
350
|
/**
|
|
321
351
|
* Process pending work until idle (deterministic tests).
|
|
322
352
|
*/
|
|
@@ -22,6 +22,11 @@ export interface ClockOptions {
|
|
|
22
22
|
readonly overridable?: boolean;
|
|
23
23
|
/** Optional human description for Console / docs (falls back to the clock name). */
|
|
24
24
|
readonly description?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Expand one `oke_crons` row per tenant (`{name}#{tenantId}`).
|
|
27
|
+
* The bare template name is never ticked.
|
|
28
|
+
*/
|
|
29
|
+
readonly perTenant?: boolean;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/**
|
|
@@ -40,6 +45,8 @@ export interface ClockDecl {
|
|
|
40
45
|
readonly overridable: boolean;
|
|
41
46
|
/** Optional human description. */
|
|
42
47
|
readonly description?: string;
|
|
48
|
+
/** When true, reconcile expands `{name}#{tenantId}` rows. */
|
|
49
|
+
readonly perTenant?: boolean;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
/**
|
|
@@ -67,9 +74,9 @@ export function resetClocks(): void {
|
|
|
67
74
|
* Declare a named clock / cron schedule.
|
|
68
75
|
*
|
|
69
76
|
* @param name - Schedule name
|
|
70
|
-
* @param options - `cron` and/or `every`, timezone, overridable
|
|
77
|
+
* @param options - `cron` and/or `every`, timezone, overridable, perTenant
|
|
71
78
|
*/
|
|
72
|
-
|
|
79
|
+
function declareClock(name: string, options: ClockOptions = {}): ClockDecl {
|
|
73
80
|
if (!options.cron && !options.every) {
|
|
74
81
|
throw new TypeError(`clock("${name}"): require cron or every`);
|
|
75
82
|
}
|
|
@@ -80,7 +87,62 @@ export function clock(name: string, options: ClockOptions = {}): ClockDecl {
|
|
|
80
87
|
timezone: options.timezone ?? "UTC",
|
|
81
88
|
overridable: options.overridable ?? false,
|
|
82
89
|
...(options.description !== undefined ? { description: options.description } : {}),
|
|
90
|
+
...(options.perTenant === true ? { perTenant: true } : {}),
|
|
83
91
|
};
|
|
84
92
|
clockRegistry.push(decl);
|
|
85
93
|
return decl;
|
|
86
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Per-tenant schedule template — one `oke_crons` row per tenant.
|
|
98
|
+
*
|
|
99
|
+
* @param name - Template name
|
|
100
|
+
* @param options - Same as {@link clock}
|
|
101
|
+
*/
|
|
102
|
+
function declarePerTenantClock(name: string, options: ClockOptions = {}): ClockDecl {
|
|
103
|
+
return declareClock(name, { ...options, perTenant: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Separator between a per-tenant template name and the tenant id.
|
|
108
|
+
*/
|
|
109
|
+
export const PER_TENANT_CRON_SEP = "#";
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Physical cron row name for a per-tenant template.
|
|
113
|
+
*
|
|
114
|
+
* @param template - Declared clock name
|
|
115
|
+
* @param tenantId - Tenant id
|
|
116
|
+
*/
|
|
117
|
+
export function perTenantCronName(template: string, tenantId: string): string {
|
|
118
|
+
return `${template}${PER_TENANT_CRON_SEP}${tenantId}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parse `{template}#{tenantId}` when `template` is a known per-tenant clock.
|
|
123
|
+
*
|
|
124
|
+
* @param name - Store row name
|
|
125
|
+
* @param templates - Per-tenant template names
|
|
126
|
+
*/
|
|
127
|
+
export function parsePerTenantCronName(
|
|
128
|
+
name: string,
|
|
129
|
+
templates?: ReadonlySet<string>,
|
|
130
|
+
): { readonly template: string; readonly tenantId: string } | null {
|
|
131
|
+
const i = name.lastIndexOf(PER_TENANT_CRON_SEP);
|
|
132
|
+
if (i <= 0) return null;
|
|
133
|
+
const template = name.slice(0, i);
|
|
134
|
+
const tenantId = name.slice(i + 1);
|
|
135
|
+
if (!tenantId) return null;
|
|
136
|
+
if (templates && !templates.has(template)) return null;
|
|
137
|
+
return { template, tenantId };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Declare a named clock / cron schedule.
|
|
142
|
+
*
|
|
143
|
+
* @param name - Schedule name
|
|
144
|
+
* @param options - `cron` and/or `every`, timezone, overridable
|
|
145
|
+
*/
|
|
146
|
+
export const clock: ((name: string, options?: ClockOptions) => ClockDecl) & {
|
|
147
|
+
readonly perTenant: typeof declarePerTenantClock;
|
|
148
|
+
} = Object.assign(declareClock, { perTenant: declarePerTenantClock });
|
|
@@ -15,6 +15,7 @@ import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
16
|
|
|
17
17
|
import type { ClockDecl } from "./declare.ts";
|
|
18
|
+
import { parsePerTenantCronName, perTenantCronName } from "./declare.ts";
|
|
18
19
|
|
|
19
20
|
/** Cron lifecycle status in `oke_crons`. */
|
|
20
21
|
export type CronStatus = "active" | "paused" | "orphaned";
|
|
@@ -86,54 +87,60 @@ export interface ReconcileResult {
|
|
|
86
87
|
readonly rows: readonly CronRow[];
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
/** Extra inputs for {@link reconcileClocks}. */
|
|
91
|
+
export interface ReconcileClocksOptions {
|
|
92
|
+
/** Tenant ids used to expand {@link ClockDecl.perTenant} templates. */
|
|
93
|
+
readonly tenantIds?: readonly string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
89
96
|
/**
|
|
90
97
|
* Reconcile declared clocks into the Store.
|
|
91
98
|
*
|
|
92
99
|
* - Declared schedules are upserted (`status: "active"`).
|
|
100
|
+
* - Per-tenant templates expand to `{template}#{tenantId}` before the orphan pass.
|
|
101
|
+
* - The bare template name is never put (never ticked).
|
|
93
102
|
* - Overrides are preserved when `overridable`.
|
|
94
103
|
* - Store rows missing from declarations are marked `orphaned` (not deleted).
|
|
95
104
|
*
|
|
96
105
|
* @param declared - Clocks from the Manifest / code
|
|
97
106
|
* @param store - Cron store (`oke_crons`)
|
|
107
|
+
* @param options - Tenant expansion
|
|
98
108
|
*/
|
|
99
109
|
export async function reconcileClocks(
|
|
100
110
|
declared: readonly ClockDecl[],
|
|
101
111
|
store: CronStore,
|
|
112
|
+
options: ReconcileClocksOptions = {},
|
|
102
113
|
): Promise<ReconcileResult> {
|
|
103
|
-
const
|
|
114
|
+
const tenantIds = options.tenantIds ?? [];
|
|
115
|
+
const declaredByName = new Map<string, ClockDecl>();
|
|
104
116
|
const active: string[] = [];
|
|
105
117
|
const orphaned: string[] = [];
|
|
106
118
|
|
|
107
119
|
for (const decl of declared) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
overrideEvery,
|
|
120
|
-
effectiveCron: overrideCron ?? decl.cron,
|
|
121
|
-
effectiveEvery: overrideEvery ?? decl.every,
|
|
122
|
-
timezone: decl.timezone,
|
|
123
|
-
overridable: decl.overridable,
|
|
124
|
-
status: "active",
|
|
125
|
-
leaderInstanceId: prev?.leaderInstanceId,
|
|
126
|
-
leaderLeaseUntil: prev?.leaderLeaseUntil,
|
|
127
|
-
lastRunAt: prev?.lastRunAt,
|
|
128
|
-
nextRunAt: prev?.nextRunAt,
|
|
129
|
-
dstAmbiguity: prev?.dstAmbiguity,
|
|
130
|
-
};
|
|
131
|
-
await store.put(row);
|
|
120
|
+
if (decl.perTenant === true) {
|
|
121
|
+
for (const tenantId of tenantIds) {
|
|
122
|
+
const name = perTenantCronName(decl.name, tenantId);
|
|
123
|
+
declaredByName.set(name, decl);
|
|
124
|
+
await putActiveRow(store, decl, name);
|
|
125
|
+
active.push(name);
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
declaredByName.set(decl.name, decl);
|
|
130
|
+
await putActiveRow(store, decl, decl.name);
|
|
132
131
|
active.push(decl.name);
|
|
133
132
|
}
|
|
134
133
|
|
|
134
|
+
const perTenantTemplates = new Set(
|
|
135
|
+
declared.filter((d) => d.perTenant === true).map((d) => d.name),
|
|
136
|
+
);
|
|
137
|
+
|
|
135
138
|
for (const existing of await store.list()) {
|
|
136
139
|
if (declaredByName.has(existing.name)) continue;
|
|
140
|
+
const parsed = parsePerTenantCronName(existing.name, perTenantTemplates);
|
|
141
|
+
if (parsed && existing.status === "active") {
|
|
142
|
+
// Expansion already listed current tenants; leftover ids orphan.
|
|
143
|
+
}
|
|
137
144
|
if (existing.status === "orphaned") {
|
|
138
145
|
orphaned.push(existing.name);
|
|
139
146
|
continue;
|
|
@@ -153,6 +160,72 @@ export async function reconcileClocks(
|
|
|
153
160
|
};
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Expand per-tenant templates for a newly created tenant (preserve lease).
|
|
165
|
+
*
|
|
166
|
+
* @param store - Cron store
|
|
167
|
+
* @param templates - Clock decls (`perTenant` only applied)
|
|
168
|
+
* @param tenantId - Tenant id
|
|
169
|
+
*/
|
|
170
|
+
export async function putPerTenantCronRows(
|
|
171
|
+
store: CronStore,
|
|
172
|
+
templates: readonly ClockDecl[],
|
|
173
|
+
tenantId: string,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
for (const decl of templates) {
|
|
176
|
+
if (decl.perTenant !== true) continue;
|
|
177
|
+
await putActiveRow(store, decl, perTenantCronName(decl.name, tenantId));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Mark per-tenant rows orphaned when a tenant is deleted.
|
|
183
|
+
*
|
|
184
|
+
* @param store - Cron store
|
|
185
|
+
* @param templates - Clock decls
|
|
186
|
+
* @param tenantId - Tenant id
|
|
187
|
+
*/
|
|
188
|
+
export async function orphanPerTenantCronRows(
|
|
189
|
+
store: CronStore,
|
|
190
|
+
templates: readonly ClockDecl[],
|
|
191
|
+
tenantId: string,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
for (const decl of templates) {
|
|
194
|
+
if (decl.perTenant !== true) continue;
|
|
195
|
+
const name = perTenantCronName(decl.name, tenantId);
|
|
196
|
+
const row = await store.get(name);
|
|
197
|
+
if (!row || row.status === "orphaned") continue;
|
|
198
|
+
await store.put({ ...row, status: "orphaned" });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function putActiveRow(store: CronStore, decl: ClockDecl, name: string): Promise<void> {
|
|
203
|
+
const prev = await store.get(name);
|
|
204
|
+
const overrideCron =
|
|
205
|
+
decl.overridable && prev?.overrideCron !== undefined ? prev.overrideCron : undefined;
|
|
206
|
+
const overrideEvery =
|
|
207
|
+
decl.overridable && prev?.overrideEvery !== undefined ? prev.overrideEvery : undefined;
|
|
208
|
+
|
|
209
|
+
const row: CronRow = {
|
|
210
|
+
name,
|
|
211
|
+
declaredCron: decl.cron,
|
|
212
|
+
declaredEvery: decl.every,
|
|
213
|
+
overrideCron,
|
|
214
|
+
overrideEvery,
|
|
215
|
+
effectiveCron: overrideCron ?? decl.cron,
|
|
216
|
+
effectiveEvery: overrideEvery ?? decl.every,
|
|
217
|
+
timezone: decl.timezone,
|
|
218
|
+
overridable: decl.overridable,
|
|
219
|
+
status: "active",
|
|
220
|
+
leaderInstanceId: prev?.leaderInstanceId,
|
|
221
|
+
leaderLeaseUntil: prev?.leaderLeaseUntil,
|
|
222
|
+
lastRunAt: prev?.lastRunAt,
|
|
223
|
+
nextRunAt: prev?.nextRunAt,
|
|
224
|
+
dstAmbiguity: prev?.dstAmbiguity,
|
|
225
|
+
};
|
|
226
|
+
await store.put(row);
|
|
227
|
+
}
|
|
228
|
+
|
|
156
229
|
/**
|
|
157
230
|
* Effective schedule the scheduler must read — from the Store, not code.
|
|
158
231
|
*
|
|
@@ -40,6 +40,8 @@ export interface CreateClockRuntimeOptions {
|
|
|
40
40
|
* and {@link ClockRuntime.now} reads it.
|
|
41
41
|
*/
|
|
42
42
|
readonly timeTravel?: TimeTravel;
|
|
43
|
+
/** Tenant ids for {@link ClockDecl.perTenant} expansion at reconcile. */
|
|
44
|
+
readonly tenantIds?: () => readonly string[] | Promise<readonly string[]>;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/** Clock runtime. */
|
|
@@ -178,11 +180,19 @@ export function createClockRuntime(options: CreateClockRuntimeOptions = {}): Clo
|
|
|
178
180
|
nextRunAt,
|
|
179
181
|
});
|
|
180
182
|
|
|
181
|
-
const
|
|
183
|
+
const template = templateNameOf(name);
|
|
184
|
+
const handler = handlers.get(name) ?? (template ? handlers.get(template) : undefined);
|
|
182
185
|
if (handler) await handler(fresh);
|
|
183
186
|
return true;
|
|
184
187
|
}
|
|
185
188
|
|
|
189
|
+
function templateNameOf(rowName: string): string | undefined {
|
|
190
|
+
const i = rowName.lastIndexOf("#");
|
|
191
|
+
if (i <= 0) return undefined;
|
|
192
|
+
const template = rowName.slice(0, i);
|
|
193
|
+
return declarations.has(template) ? template : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
186
196
|
function isDue(row: CronRow, until: number): boolean {
|
|
187
197
|
if (row.status !== "active") return false;
|
|
188
198
|
const sched = effectiveSchedule(row);
|
|
@@ -217,7 +227,8 @@ export function createClockRuntime(options: CreateClockRuntimeOptions = {}): Clo
|
|
|
217
227
|
declarations.set(decl.name, decl);
|
|
218
228
|
},
|
|
219
229
|
async reconcile() {
|
|
220
|
-
const
|
|
230
|
+
const tenantIds = (await options.tenantIds?.()) ?? [];
|
|
231
|
+
const result = await reconcileClocks([...declarations.values()], store, { tenantIds });
|
|
221
232
|
// Attach DST warnings for active cron expressions.
|
|
222
233
|
for (const row of result.rows) {
|
|
223
234
|
if (row.status !== "active") continue;
|
|
@@ -299,6 +299,34 @@ describe("reconciliation", () => {
|
|
|
299
299
|
expect(ranOrphan).toBe(false);
|
|
300
300
|
});
|
|
301
301
|
|
|
302
|
+
test("per-tenant templates expand to {name}#{id} and never put the bare name", async () => {
|
|
303
|
+
const store = createMemoryCronStore();
|
|
304
|
+
const invoices = clock.perTenant("invoices", { every: "1h" });
|
|
305
|
+
const result = await reconcileClocks([invoices], store, { tenantIds: ["acme", "globex"] });
|
|
306
|
+
expect([...result.active].sort()).toEqual(["invoices#acme", "invoices#globex"]);
|
|
307
|
+
expect(await store.get("invoices")).toBeUndefined();
|
|
308
|
+
expect((await store.get("invoices#acme"))?.status).toBe("active");
|
|
309
|
+
|
|
310
|
+
const rt = createTestClockRuntime(0, {
|
|
311
|
+
store,
|
|
312
|
+
instanceId: "solo",
|
|
313
|
+
tenantIds: () => ["acme", "globex"],
|
|
314
|
+
});
|
|
315
|
+
rt.register(invoices);
|
|
316
|
+
let bare = 0;
|
|
317
|
+
let expanded = 0;
|
|
318
|
+
rt.onCron("invoices", () => {
|
|
319
|
+
bare += 1;
|
|
320
|
+
});
|
|
321
|
+
rt.onCron("invoices#acme", () => {
|
|
322
|
+
expanded += 1;
|
|
323
|
+
});
|
|
324
|
+
expect(await rt.runNow("invoices")).toBe(false);
|
|
325
|
+
expect(bare).toBe(0);
|
|
326
|
+
expect(await rt.runNow("invoices#acme")).toBe(true);
|
|
327
|
+
expect(expanded).toBe(1);
|
|
328
|
+
});
|
|
329
|
+
|
|
302
330
|
test("scheduler reads effective state from the store", async () => {
|
|
303
331
|
const store = createMemoryCronStore();
|
|
304
332
|
const rt = createTestClockRuntime(0, { store, instanceId: "s1" });
|
package/src/elements/clock.ts
CHANGED
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
* @module
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
export {
|
|
13
|
+
export {
|
|
14
|
+
clock,
|
|
15
|
+
perTenantCronName,
|
|
16
|
+
parsePerTenantCronName,
|
|
17
|
+
PER_TENANT_CRON_SEP,
|
|
18
|
+
} from "./clock/declare.ts";
|
|
14
19
|
export type { ClockDecl, ClockOptions } from "./clock/declare.ts";
|
|
15
20
|
|
|
16
21
|
export { parseDurationMs } from "./clock/duration.ts";
|
|
@@ -27,6 +32,8 @@ export {
|
|
|
27
32
|
|
|
28
33
|
export {
|
|
29
34
|
reconcileClocks,
|
|
35
|
+
putPerTenantCronRows,
|
|
36
|
+
orphanPerTenantCronRows,
|
|
30
37
|
effectiveSchedule,
|
|
31
38
|
createMemoryCronStore,
|
|
32
39
|
createFileCronStore,
|
|
@@ -34,6 +41,7 @@ export {
|
|
|
34
41
|
type CronStatus,
|
|
35
42
|
type CronStore,
|
|
36
43
|
type ReconcileResult,
|
|
44
|
+
type ReconcileClocksOptions,
|
|
37
45
|
} from "./clock/reconcile.ts";
|
|
38
46
|
|
|
39
47
|
export { createTimeTravel, type TimeTravel } from "./clock/time-travel.ts";
|