okengine 0.15.2 → 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.
Files changed (256) hide show
  1. package/manifest.v1.schema.json +21 -2
  2. package/package.json +1 -1
  3. package/site/content/docs/elements/clock.mdx +7 -0
  4. package/site/content/docs/elements/flow.mdx +20 -12
  5. package/site/content/docs/elements/gate.mdx +108 -3
  6. package/site/content/docs/elements/signal.mdx +46 -17
  7. package/site/content/docs/elements/store.mdx +36 -32
  8. package/site/content/docs/elements/vault.mdx +26 -0
  9. package/site/content/docs/get-started/project-structure.mdx +4 -4
  10. package/site/content/docs/reference/cli.md +1 -1
  11. package/site/content/docs/reference/client.mdx +75 -17
  12. package/site/content/docs/reference/configuration.mdx +7 -4
  13. package/site/content/docs/reference/errors.mdx +24 -17
  14. package/site/content/docs/reference/fx.mdx +39 -22
  15. package/site/content/docs/reference/security.md +1 -1
  16. package/src/auth/api-key-sql.test.ts +47 -0
  17. package/src/auth/api-key-sql.ts +261 -0
  18. package/src/auth/api-keys.ts +276 -14
  19. package/src/auth/auth.test.ts +56 -0
  20. package/src/auth/config.ts +24 -5
  21. package/src/auth/index.ts +62 -1
  22. package/src/auth/plugin.ts +6 -1
  23. package/src/auth/schema.ts +8 -0
  24. package/src/auth/sessions.ts +18 -0
  25. package/src/auth/tables.ts +32 -0
  26. package/src/auth/tenant-config.ts +74 -0
  27. package/src/auth/tenant-tables.ts +11 -0
  28. package/src/auth/tenants.test.ts +63 -0
  29. package/src/auth/tenants.ts +360 -0
  30. package/src/cli/attach-host-console.test.ts +37 -0
  31. package/src/cli/attach-host-console.ts +6 -0
  32. package/src/cli/build.ts +2 -2
  33. package/src/client/budget.test.ts +1 -1
  34. package/src/client/create.ts +75 -5
  35. package/src/client/index.ts +13 -0
  36. package/src/client/live.test.ts +422 -0
  37. package/src/client/live.ts +389 -0
  38. package/src/client/notes-contract.test.ts +41 -0
  39. package/src/client/types.ts +85 -6
  40. package/src/client-react/index.ts +85 -1
  41. package/src/client-react/use-live.test.ts +129 -0
  42. package/src/compiler/effects-infer.ts +37 -2
  43. package/src/compiler/extract.test.ts +143 -11
  44. package/src/compiler/extract.ts +210 -30
  45. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  46. package/src/compiler/fixtures/skyport.expected.json +2 -3
  47. package/src/compiler/response.ts +41 -11
  48. package/src/console/server/access.test.ts +116 -8
  49. package/src/console/server/access.ts +70 -23
  50. package/src/console/server/app.ts +45 -11
  51. package/src/console/server/console.test.ts +6 -8
  52. package/src/console/server/dev-identities.test.ts +127 -0
  53. package/src/console/server/dev-identities.ts +229 -0
  54. package/src/console/server/flows-invoke.test.ts +21 -4
  55. package/src/console/server/flows.ts +113 -58
  56. package/src/console/server/gates.test.ts +24 -0
  57. package/src/console/server/gates.ts +10 -30
  58. package/src/console/server/index.ts +1 -0
  59. package/src/console/server/live.test.ts +26 -1
  60. package/src/console/server/live.ts +4 -2
  61. package/src/console/server/operator-db.ts +2 -0
  62. package/src/console/server/state.ts +29 -55
  63. package/src/console/server/store.test.ts +17 -0
  64. package/src/console/server/store.ts +43 -1
  65. package/src/console/ui-next/dist/assets/access-page-CXVWWMmD.js +4 -0
  66. package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
  67. package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
  68. package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
  69. package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
  70. package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
  71. package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
  72. package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
  73. package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
  74. package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
  75. package/src/console/ui-next/dist/assets/flows-page-XDpAJO8f.js +1 -0
  76. package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
  77. package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
  78. package/src/console/ui-next/dist/assets/index-BBj2QJCu.js +66 -0
  79. package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
  80. package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
  81. package/src/console/ui-next/dist/assets/observability-page-BFaay44m.js +4 -0
  82. package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
  83. package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
  84. package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
  85. package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
  86. package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
  87. package/src/console/ui-next/dist/assets/store-page-CS5-aETQ.js +41 -0
  88. package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
  89. package/src/console/ui-next/dist/assets/tree-expand-toggle-BtyhmWb4.js +55 -0
  90. package/src/console/ui-next/dist/assets/units-page-Ca2Z-E52.js +1 -0
  91. package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
  92. package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
  93. package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
  94. package/src/console/ui-next/dist/assets/vault-page-BmOeAFwg.js +2 -0
  95. package/src/console/ui-next/dist/index.html +11 -9
  96. package/src/console/ui-next/index.html +3 -3
  97. package/src/console/ui-next/src/client.ts +167 -0
  98. package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
  99. package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
  100. package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
  101. package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
  102. package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
  103. package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
  104. package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
  105. package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
  106. package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
  107. package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
  108. package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
  109. package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
  110. package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
  111. package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
  112. package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
  113. package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
  114. package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
  115. package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
  116. package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
  117. package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
  118. package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
  119. package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
  120. package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
  121. package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
  122. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  123. package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
  124. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  125. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  126. package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
  127. package/src/console/ui-next/src/lib/document-meta.ts +2 -1
  128. package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
  129. package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
  130. package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
  131. package/src/console/ui-next/src/lib/shortcut.ts +3 -0
  132. package/src/console/ui-next/src/router.tsx +10 -1
  133. package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
  134. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  135. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  136. package/src/console/ui-next/ui-next-seed-store.ts +43 -15
  137. package/src/console/xss.gate.test.ts +1 -0
  138. package/src/drivers/index.ts +2 -0
  139. package/src/drivers/journal-postgres.ts +12 -3
  140. package/src/drivers/pg-rls.ts +26 -2
  141. package/src/drivers/pg-vault-rls.ts +68 -0
  142. package/src/drivers/signal-engine.ts +69 -15
  143. package/src/drivers/signal-live-iter.ts +65 -0
  144. package/src/drivers/signal-nats.ts +2 -1
  145. package/src/drivers/signal-postgres.ts +95 -12
  146. package/src/drivers/signal-redis.ts +2 -1
  147. package/src/drivers/signal-retention.ts +64 -0
  148. package/src/drivers/signal-types.ts +35 -5
  149. package/src/elements/clock/declare.ts +64 -2
  150. package/src/elements/clock/reconcile.ts +98 -25
  151. package/src/elements/clock/runtime.ts +13 -2
  152. package/src/elements/clock.test.ts +28 -0
  153. package/src/elements/clock.ts +9 -1
  154. package/src/elements/gate/declare.ts +2 -0
  155. package/src/elements/gate/permissions.ts +12 -0
  156. package/src/elements/gate.ts +6 -1
  157. package/src/elements/signal/declare.ts +44 -10
  158. package/src/elements/signal/delivery-modes.test.ts +90 -4
  159. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  160. package/src/elements/signal/runtime.ts +42 -0
  161. package/src/elements/signal.test.ts +21 -8
  162. package/src/elements/signal.ts +1 -1
  163. package/src/elements/store/cache.ts +1 -1
  164. package/src/elements/store/declare.ts +7 -0
  165. package/src/elements/store/rls-identity.test.ts +29 -0
  166. package/src/elements/store/rls-identity.ts +3 -0
  167. package/src/elements/store/runtime.ts +14 -0
  168. package/src/elements/store/schema-decl.ts +162 -44
  169. package/src/elements/store/schema-tenant.ts +41 -0
  170. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  171. package/src/elements/store/sql-select.types.test.ts +71 -0
  172. package/src/elements/store/sql-session.ts +54 -32
  173. package/src/elements/store.ts +5 -0
  174. package/src/elements/vault/builtin-adapter.ts +15 -2
  175. package/src/elements/vault/declare.ts +8 -0
  176. package/src/elements/vault/runtime.ts +7 -0
  177. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  178. package/src/elements/vault/storage.ts +9 -0
  179. package/src/elements/vault/test-helpers.ts +2 -1
  180. package/src/i18n/catalogs/ar.ts +19 -0
  181. package/src/i18n/catalogs/en.ts +19 -0
  182. package/src/index.ts +2 -0
  183. package/src/kernel/adopt-routes.ts +56 -8
  184. package/src/kernel/api-key-host-persist.test.ts +143 -0
  185. package/src/kernel/app-tenant.ts +122 -0
  186. package/src/kernel/app.ts +196 -57
  187. package/src/kernel/auth-resolve.ts +13 -5
  188. package/src/kernel/boot-bind/clock.ts +9 -3
  189. package/src/kernel/boot.test.ts +4 -4
  190. package/src/kernel/boot.ts +2 -0
  191. package/src/kernel/budget.test.ts +1 -1
  192. package/src/kernel/clock-durable.ts +8 -0
  193. package/src/kernel/clock-per-tenant-name.ts +5 -0
  194. package/src/kernel/clock-reconcile.ts +8 -0
  195. package/src/kernel/errors-live-resume.ts +15 -0
  196. package/src/kernel/errors-tenant.ts +29 -0
  197. package/src/kernel/errors.registry.test.ts +31 -3
  198. package/src/kernel/errors.ts +43 -3
  199. package/src/kernel/flow.ts +26 -5
  200. package/src/kernel/fx-auth-keys.test.ts +88 -0
  201. package/src/kernel/fx-auth-keys.ts +203 -0
  202. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  203. package/src/kernel/fx-auth-tenants.ts +286 -0
  204. package/src/kernel/fx-live-stream.ts +149 -0
  205. package/src/kernel/fx-live.test.ts +157 -0
  206. package/src/kernel/fx-runtime.ts +15 -0
  207. package/src/kernel/fx-tenant-store.ts +213 -0
  208. package/src/kernel/fx.test.ts +108 -1
  209. package/src/kernel/fx.ts +206 -28
  210. package/src/kernel/hooks.ts +2 -2
  211. package/src/kernel/http-resource.ts +9 -18
  212. package/src/kernel/index.ts +2 -0
  213. package/src/kernel/journal.ts +12 -0
  214. package/src/kernel/live-http.test.ts +78 -0
  215. package/src/kernel/live-http.ts +114 -0
  216. package/src/kernel/live-resume.test.ts +125 -0
  217. package/src/kernel/on.ts +51 -0
  218. package/src/kernel/pipeline-api-key.test.ts +192 -7
  219. package/src/kernel/pipeline-tenant.ts +49 -0
  220. package/src/kernel/pipeline.test.ts +1 -1
  221. package/src/kernel/pipeline.ts +41 -4
  222. package/src/kernel/resource-mount.test.ts +10 -27
  223. package/src/kernel/tenant-resolve.test.ts +101 -0
  224. package/src/kernel/tenant-resolve.ts +124 -0
  225. package/src/kernel/tenant-roles.test.ts +87 -0
  226. package/src/kernel/triggers.ts +59 -21
  227. package/src/manifest/diff.test.ts +11 -2
  228. package/src/manifest/diff.ts +53 -11
  229. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  230. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  231. package/src/manifest/types.ts +58 -6
  232. package/src/release/build-lib.ts +14 -1
  233. package/src/release/limits.ts +2 -2
  234. package/src/release/measure.ts +55 -1
  235. package/src/client/live-gap.test.ts +0 -35
  236. package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
  237. package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
  238. package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
  239. package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
  240. package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
  241. package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
  242. package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
  243. package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
  244. package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
  245. package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
  246. package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
  247. package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
  248. package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
  249. package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
  250. package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
  251. package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
  252. package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
  253. package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
  254. package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
  255. package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
  256. package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
@@ -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
- async function live(signal: string, handler: LiveHandler): Promise<SignalUnsubscribe> {
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
- let set = liveHandlers.get(signal);
696
- if (!set) {
697
- set = new Set();
698
- liveHandlers.set(signal, set);
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
- set.add(handler);
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
- for (const row of history) {
706
- await handler(JSON.parse(String(row.payload)));
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
- for (const h of handlers) await h(payload);
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, handler) => outbox.live(signal, handler),
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
- /** Live client handler (payload only). */
193
- export type LiveHandler = (payload: unknown) => void | Promise<void>;
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
- * @param signal - Signal name (`delivery: "live"`)
317
- * @param handler - Payload handler
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
- live(signal: string, handler: LiveHandler): Promise<SignalUnsubscribe>;
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
- export function clock(name: string, options: ClockOptions = {}): ClockDecl {
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 declaredByName = new Map(declared.map((d) => [d.name, d]));
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
- const prev = await store.get(decl.name);
109
- const overrideCron =
110
- decl.overridable && prev?.overrideCron !== undefined ? prev.overrideCron : undefined;
111
- const overrideEvery =
112
- decl.overridable && prev?.overrideEvery !== undefined ? prev.overrideEvery : undefined;
113
-
114
- const row: CronRow = {
115
- name: decl.name,
116
- declaredCron: decl.cron,
117
- declaredEvery: decl.every,
118
- overrideCron,
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 handler = handlers.get(name);
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 result = await reconcileClocks([...declarations.values()], store);
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" });
@@ -10,7 +10,12 @@
10
10
  * @module
11
11
  */
12
12
 
13
- export { clock } from "./clock/declare.ts";
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";
@@ -19,6 +19,8 @@ export interface GatePolicyContext {
19
19
  readonly userId: string | null;
20
20
  readonly scopes: ReadonlySet<string>;
21
21
  readonly verified?: boolean;
22
+ /** Authenticating API key id when the principal is a key; otherwise omitted. */
23
+ readonly apiKeyId?: string | null;
22
24
  };
23
25
  /** Operator-plane principal (`fx.operator`). */
24
26
  readonly operator: {