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.
Files changed (149) 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 +14 -12
  5. package/site/content/docs/elements/gate.mdx +58 -8
  6. package/site/content/docs/elements/signal.mdx +46 -17
  7. package/site/content/docs/elements/store.mdx +23 -17
  8. package/site/content/docs/elements/vault.mdx +23 -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 +72 -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 +15 -9
  15. package/src/auth/api-key-sql.ts +11 -4
  16. package/src/auth/api-keys.ts +3 -0
  17. package/src/auth/config.ts +19 -0
  18. package/src/auth/index.ts +37 -0
  19. package/src/auth/plugin.ts +6 -1
  20. package/src/auth/sessions.ts +18 -0
  21. package/src/auth/tables.ts +32 -0
  22. package/src/auth/tenant-config.ts +74 -0
  23. package/src/auth/tenant-tables.ts +11 -0
  24. package/src/auth/tenants.test.ts +63 -0
  25. package/src/auth/tenants.ts +360 -0
  26. package/src/cli/build.ts +2 -2
  27. package/src/client/budget.test.ts +1 -1
  28. package/src/client/create.ts +75 -5
  29. package/src/client/index.ts +13 -0
  30. package/src/client/live.test.ts +422 -0
  31. package/src/client/live.ts +389 -0
  32. package/src/client/notes-contract.test.ts +41 -0
  33. package/src/client/types.ts +85 -6
  34. package/src/client-react/index.ts +85 -1
  35. package/src/client-react/use-live.test.ts +129 -0
  36. package/src/compiler/effects-infer.ts +22 -2
  37. package/src/compiler/extract.test.ts +143 -11
  38. package/src/compiler/extract.ts +210 -30
  39. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  40. package/src/compiler/fixtures/skyport.expected.json +2 -3
  41. package/src/compiler/response.ts +41 -11
  42. package/src/console/server/app.ts +44 -11
  43. package/src/console/server/console.test.ts +6 -8
  44. package/src/console/server/gates.ts +2 -9
  45. package/src/console/server/store.test.ts +17 -0
  46. package/src/console/server/store.ts +43 -1
  47. package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-CXVWWMmD.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-XDpAJO8f.js} +1 -1
  49. package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-BBj2QJCu.js} +3 -3
  50. package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-BFaay44m.js} +1 -1
  51. package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
  53. package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-Ca2Z-E52.js} +1 -1
  54. package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-BmOeAFwg.js} +1 -1
  55. package/src/console/ui-next/dist/index.html +1 -1
  56. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  57. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  58. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  59. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  60. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  61. package/src/drivers/index.ts +2 -0
  62. package/src/drivers/journal-postgres.ts +12 -3
  63. package/src/drivers/pg-rls.ts +26 -2
  64. package/src/drivers/pg-vault-rls.ts +68 -0
  65. package/src/drivers/signal-engine.ts +69 -15
  66. package/src/drivers/signal-live-iter.ts +65 -0
  67. package/src/drivers/signal-nats.ts +2 -1
  68. package/src/drivers/signal-postgres.ts +95 -12
  69. package/src/drivers/signal-redis.ts +2 -1
  70. package/src/drivers/signal-retention.ts +64 -0
  71. package/src/drivers/signal-types.ts +35 -5
  72. package/src/elements/clock/declare.ts +64 -2
  73. package/src/elements/clock/reconcile.ts +98 -25
  74. package/src/elements/clock/runtime.ts +13 -2
  75. package/src/elements/clock.test.ts +28 -0
  76. package/src/elements/clock.ts +9 -1
  77. package/src/elements/gate/permissions.ts +12 -0
  78. package/src/elements/gate.ts +6 -1
  79. package/src/elements/signal/declare.ts +44 -10
  80. package/src/elements/signal/delivery-modes.test.ts +90 -4
  81. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  82. package/src/elements/signal/runtime.ts +42 -0
  83. package/src/elements/signal.test.ts +21 -8
  84. package/src/elements/signal.ts +1 -1
  85. package/src/elements/store/declare.ts +7 -0
  86. package/src/elements/store/rls-identity.test.ts +29 -0
  87. package/src/elements/store/rls-identity.ts +3 -0
  88. package/src/elements/store/schema-decl.ts +63 -3
  89. package/src/elements/store/schema-tenant.ts +41 -0
  90. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  91. package/src/elements/store.ts +2 -0
  92. package/src/elements/vault/builtin-adapter.ts +15 -2
  93. package/src/elements/vault/declare.ts +8 -0
  94. package/src/elements/vault/runtime.ts +7 -0
  95. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  96. package/src/elements/vault/storage.ts +9 -0
  97. package/src/elements/vault/test-helpers.ts +2 -1
  98. package/src/i18n/catalogs/ar.ts +19 -0
  99. package/src/i18n/catalogs/en.ts +19 -0
  100. package/src/index.ts +1 -0
  101. package/src/kernel/adopt-routes.ts +56 -8
  102. package/src/kernel/app-tenant.ts +122 -0
  103. package/src/kernel/app.ts +183 -56
  104. package/src/kernel/auth-resolve.ts +3 -0
  105. package/src/kernel/boot-bind/clock.ts +9 -3
  106. package/src/kernel/boot.ts +2 -0
  107. package/src/kernel/budget.test.ts +1 -1
  108. package/src/kernel/clock-durable.ts +8 -0
  109. package/src/kernel/clock-per-tenant-name.ts +5 -0
  110. package/src/kernel/clock-reconcile.ts +8 -0
  111. package/src/kernel/errors-live-resume.ts +15 -0
  112. package/src/kernel/errors-tenant.ts +29 -0
  113. package/src/kernel/errors.registry.test.ts +31 -3
  114. package/src/kernel/errors.ts +43 -3
  115. package/src/kernel/flow.ts +26 -5
  116. package/src/kernel/fx-auth-keys.ts +6 -1
  117. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  118. package/src/kernel/fx-auth-tenants.ts +286 -0
  119. package/src/kernel/fx-live-stream.ts +149 -0
  120. package/src/kernel/fx-live.test.ts +157 -0
  121. package/src/kernel/fx-runtime.ts +15 -0
  122. package/src/kernel/fx-tenant-store.ts +213 -0
  123. package/src/kernel/fx.test.ts +91 -0
  124. package/src/kernel/fx.ts +173 -13
  125. package/src/kernel/hooks.ts +2 -2
  126. package/src/kernel/http-resource.ts +9 -18
  127. package/src/kernel/index.ts +2 -0
  128. package/src/kernel/journal.ts +12 -0
  129. package/src/kernel/live-http.test.ts +78 -0
  130. package/src/kernel/live-http.ts +114 -0
  131. package/src/kernel/live-resume.test.ts +125 -0
  132. package/src/kernel/on.ts +51 -0
  133. package/src/kernel/pipeline-tenant.ts +49 -0
  134. package/src/kernel/pipeline.test.ts +1 -1
  135. package/src/kernel/pipeline.ts +37 -2
  136. package/src/kernel/resource-mount.test.ts +10 -27
  137. package/src/kernel/tenant-resolve.test.ts +101 -0
  138. package/src/kernel/tenant-resolve.ts +124 -0
  139. package/src/kernel/tenant-roles.test.ts +87 -0
  140. package/src/kernel/triggers.ts +59 -21
  141. package/src/manifest/diff.test.ts +11 -2
  142. package/src/manifest/diff.ts +53 -11
  143. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  144. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  145. package/src/manifest/types.ts +52 -6
  146. package/src/release/build-lib.ts +13 -1
  147. package/src/release/limits.ts +2 -2
  148. package/src/release/measure.ts +55 -1
  149. package/src/client/live-gap.test.ts +0 -35
@@ -63,7 +63,11 @@ const TREE: readonly UnitGroup[] = [
63
63
  }),
64
64
  ]),
65
65
  group("orders", [
66
- row("orders.onPlaced", { trigger: { signal: "order-placed" }, live: true }, "once"),
66
+ row("orders.onPlaced", { trigger: { signal: "order-placed" } }, "once"),
67
+ row("orders.events", {
68
+ trigger: { http: { method: "GET", path: "/orders/:orderId/events" } },
69
+ live: "order-status",
70
+ }),
67
71
  row("orders.sync", { trigger: { cdc: { table: "orders" } }, plane: "operator" }),
68
72
  row("orders.helper", {}),
69
73
  ]),
@@ -100,14 +104,19 @@ describe("filterUnitsAdvanced", () => {
100
104
  const operators = filterUnitsAdvanced(TREE, { planes: ["operator"] });
101
105
  expect(flowIds(operators)).toEqual(["billing.reconcile", "orders.sync"]);
102
106
  const users = filterUnitsAdvanced(TREE, { planes: ["user"] });
103
- expect(flowIds(users)).toEqual(["billing.charge", "orders.onPlaced", "orders.helper"]);
107
+ expect(flowIds(users)).toEqual([
108
+ "billing.charge",
109
+ "orders.onPlaced",
110
+ "orders.events",
111
+ "orders.helper",
112
+ ]);
104
113
  });
105
114
 
106
115
  test("durable / live flags keep only flagged flows", () => {
107
116
  expect(flowIds(filterUnitsAdvanced(TREE, { durableOnly: true }))).toEqual([
108
117
  "billing.reconcile",
109
118
  ]);
110
- expect(flowIds(filterUnitsAdvanced(TREE, { liveOnly: true }))).toEqual(["orders.onPlaced"]);
119
+ expect(flowIds(filterUnitsAdvanced(TREE, { liveOnly: true }))).toEqual(["orders.events"]);
111
120
  });
112
121
 
113
122
  test("facet dimensions AND together", () => {
@@ -218,8 +227,9 @@ describe("bandUnitTree", () => {
218
227
  test("returns only populated trigger kind bands", () => {
219
228
  const bands = bandUnitTree(TREE);
220
229
  expect(bands.map((b) => b.id)).toEqual(["http", "signal", "cron", "cdc", "internal"]);
221
- expect(bands[0]!.groups.map((g) => g.unit)).toEqual(["billing"]);
230
+ expect(bands[0]!.groups.map((g) => g.unit)).toEqual(["billing", "orders"]);
222
231
  expect(bands[0]!.groups[0]!.flows.map((f) => f.id)).toEqual(["billing.charge"]);
232
+ expect(bands[0]!.groups[1]!.flows.map((f) => f.id)).toEqual(["orders.events"]);
223
233
  expect(bands[1]!.groups.map((g) => g.unit)).toEqual(["orders"]);
224
234
  expect(bands[1]!.groups[0]!.flows.map((f) => f.id)).toEqual(["orders.onPlaced"]);
225
235
  expect(bands[2]!.groups.map((g) => g.unit)).toEqual(["billing"]);
@@ -254,6 +264,7 @@ describe("bandUnitTree", () => {
254
264
  expect(unitTreeOpenKeys(bands)).toEqual([
255
265
  "band:http",
256
266
  "unit:http:billing",
267
+ "unit:http:orders",
257
268
  "band:signal",
258
269
  "unit:signal:orders",
259
270
  "band:cron",
@@ -81,7 +81,7 @@ function httpFlow(spec: {
81
81
  path: string;
82
82
  gates?: readonly string[];
83
83
  plane?: "user" | "operator";
84
- live?: boolean;
84
+ live?: string;
85
85
  durable?: boolean;
86
86
  cache?: boolean | string;
87
87
  in?: JsonSchema;
@@ -94,7 +94,7 @@ function httpFlow(spec: {
94
94
  trigger: { http: { method: spec.method, path: spec.path } },
95
95
  plane: spec.plane ?? "user",
96
96
  ...(spec.gates ? { gates: [...spec.gates] } : {}),
97
- ...(spec.live ? { live: true } : {}),
97
+ ...(spec.live ? { live: spec.live } : {}),
98
98
  ...(spec.durable ? { durable: true } : {}),
99
99
  ...(spec.cache !== undefined ? { cache: spec.cache } : {}),
100
100
  ...(spec.in ? { in: spec.in } : {}),
@@ -118,7 +118,6 @@ function crud(spec: {
118
118
  readGates?: readonly string[];
119
119
  writeGates?: readonly string[];
120
120
  skip?: readonly ("list" | "get" | "create" | "update" | "delete")[];
121
- liveList?: boolean;
122
121
  createIn?: JsonSchema;
123
122
  updateIn?: JsonSchema;
124
123
  createEmits?: readonly string[];
@@ -133,7 +132,6 @@ function crud(spec: {
133
132
  method: "GET",
134
133
  path: spec.collection,
135
134
  gates: read,
136
- live: spec.liveList,
137
135
  in: KEEL_LIST_IN,
138
136
  out: KEEL_LIST_OUT,
139
137
  effects: { reads: [spec.table] },
@@ -365,7 +363,6 @@ export const KEEL_SURFACE_FLOWS: Record<string, Flow> = {
365
363
  readGates: MEMBER,
366
364
  writeGates: COMMENT_WRITE,
367
365
  skip: ["create"],
368
- liveList: true,
369
366
  updateIn: COMMENT_IN,
370
367
  sourceDir: "src/flows/comments",
371
368
  }),
@@ -386,7 +383,6 @@ export const KEEL_SURFACE_FLOWS: Record<string, Flow> = {
386
383
  readGates: MEMBER,
387
384
  writeGates: PROJECT_ADMIN,
388
385
  skip: ["create"],
389
- liveList: true,
390
386
  sourceDir: "src/flows/projects",
391
387
  }),
392
388
  "projects.archive": httpFlow({
@@ -491,7 +487,6 @@ export const KEEL_SURFACE_FLOWS: Record<string, Flow> = {
491
487
  item: "/spaces/:id",
492
488
  readGates: MEMBER,
493
489
  writeGates: PROJECT_ADMIN,
494
- liveList: true,
495
490
  createIn: {
496
491
  type: "object",
497
492
  required: ["key", "name"],
@@ -610,7 +605,6 @@ export const KEEL_SURFACE_FLOWS: Record<string, Flow> = {
610
605
  readGates: MEMBER,
611
606
  writeGates: MEMBER,
612
607
  skip: ["create"],
613
- liveList: true,
614
608
  sourceDir: "src/flows/inbox",
615
609
  }),
616
610
 
@@ -618,7 +612,6 @@ export const KEEL_SURFACE_FLOWS: Record<string, Flow> = {
618
612
  method: "GET",
619
613
  path: "/me/tasks",
620
614
  gates: MEMBER,
621
- live: true,
622
615
  in: KEEL_LIST_IN,
623
616
  out: KEEL_LIST_OUT,
624
617
  effects: { reads: ["sql:tasks", "sql:task_assignees"] },
@@ -114,7 +114,6 @@ export const UI_NEXT_SEEDED_MANIFEST: Manifest = {
114
114
  },
115
115
  "tasks.list": {
116
116
  trigger: { http: { method: "GET", path: "/tasks" } },
117
- live: true,
118
117
  plane: "user",
119
118
  gates: ["member"],
120
119
  in: KEEL_LIST_IN,
@@ -40,6 +40,8 @@ export type {
40
40
  SignalDriverId,
41
41
  SignalDriver,
42
42
  SignalBus,
43
+ LiveEvent,
44
+ LiveSubscribeOptions,
43
45
  SignalMessage,
44
46
  SignalEmitOptions,
45
47
  SignalFailureReason,
@@ -36,6 +36,7 @@ interface JournalDbRow {
36
36
  lease_expires_at: number | null;
37
37
  created_at: number;
38
38
  updated_at: number;
39
+ tenant: string | null;
39
40
  }
40
41
 
41
42
  /** Minimal SQL + transaction surface for the postgres journal store. */
@@ -151,6 +152,9 @@ function rowToRun(row: Record<string, unknown>): JournalRun {
151
152
  if (r.locked_by !== null && r.locked_by !== undefined) run.lockedBy = String(r.locked_by);
152
153
  const leaseExpiresAt = numOrNull(r.lease_expires_at);
153
154
  if (leaseExpiresAt !== null) run.leaseExpiresAt = leaseExpiresAt;
155
+ if (r.tenant !== null && r.tenant !== undefined && String(r.tenant).length > 0) {
156
+ run.tenant = String(r.tenant);
157
+ }
154
158
  return run;
155
159
  }
156
160
 
@@ -168,6 +172,7 @@ function runToParams(run: JournalRun): unknown[] {
168
172
  run.leaseExpiresAt ?? null,
169
173
  run.createdAt,
170
174
  run.updatedAt,
175
+ run.tenant ?? null,
171
176
  ];
172
177
  }
173
178
 
@@ -184,8 +189,10 @@ async function ensureSchema(sql: PostgresJournalSql): Promise<void> {
184
189
  locked_by TEXT,
185
190
  lease_expires_at BIGINT,
186
191
  created_at BIGINT NOT NULL,
187
- updated_at BIGINT NOT NULL
192
+ updated_at BIGINT NOT NULL,
193
+ tenant TEXT
188
194
  )`);
195
+ await sql.exec(`ALTER TABLE oke_journal_runs ADD COLUMN IF NOT EXISTS tenant TEXT`);
189
196
  await sql.exec(
190
197
  `CREATE INDEX IF NOT EXISTS oke_journal_runs_wake ON oke_journal_runs (status, wake_at)`,
191
198
  );
@@ -308,7 +315,8 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
308
315
  const text = sql.trim();
309
316
  const state = view();
310
317
 
311
- if (/^CREATE\s+(TABLE|INDEX)/i.test(text)) return { changes: 0 };
318
+ if (/^CREATE\s+(TABLE|INDEX)/i.test(text) || /^ALTER\s+TABLE/i.test(text))
319
+ return { changes: 0 };
312
320
 
313
321
  const upsert =
314
322
  /^INSERT\s+INTO\s+oke_journal_runs\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)\s*ON\s+CONFLICT\s*\(\s*id\s*\)\s*DO\s+UPDATE\s+SET\s+.+$/i.exec(
@@ -337,6 +345,7 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
337
345
  : Number(record.lease_expires_at),
338
346
  created_at: Number(record.created_at ?? 0),
339
347
  updated_at: Number(record.updated_at ?? 0),
348
+ tenant: (record.tenant as string | null) ?? null,
340
349
  };
341
350
  const idx = state.rows.findIndex((r) => r.id === next.id);
342
351
  if (idx >= 0) state.rows[idx] = next;
@@ -423,7 +432,7 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
423
432
  return api;
424
433
  }
425
434
 
426
- const UPSERT_SQL = `INSERT INTO oke_journal_runs (id, flow, input, status, entries, wake_at, error, output, locked_by, lease_expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET flow = EXCLUDED.flow, input = EXCLUDED.input, status = EXCLUDED.status, entries = EXCLUDED.entries, wake_at = EXCLUDED.wake_at, error = EXCLUDED.error, output = EXCLUDED.output, locked_by = EXCLUDED.locked_by, lease_expires_at = EXCLUDED.lease_expires_at, created_at = EXCLUDED.created_at, updated_at = EXCLUDED.updated_at`;
435
+ const UPSERT_SQL = `INSERT INTO oke_journal_runs (id, flow, input, status, entries, wake_at, error, output, locked_by, lease_expires_at, created_at, updated_at, tenant) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET flow = EXCLUDED.flow, input = EXCLUDED.input, status = EXCLUDED.status, entries = EXCLUDED.entries, wake_at = EXCLUDED.wake_at, error = EXCLUDED.error, output = EXCLUDED.output, locked_by = EXCLUDED.locked_by, lease_expires_at = EXCLUDED.lease_expires_at, created_at = EXCLUDED.created_at, updated_at = EXCLUDED.updated_at, tenant = EXCLUDED.tenant`;
427
436
 
428
437
  /** Postgres journal store with run-level lease coordination. */
429
438
  export type PostgresJournalStore = JournalStore &
@@ -3,6 +3,8 @@
3
3
  * Drizzle emit, and Gate identity helpers (`oke.gate` / `oke.user` / `oke.has_scope`).
4
4
  */
5
5
 
6
+ import { lazyRequire } from "../kernel/lazy-require.ts";
7
+
6
8
  /** Policy command (`FOR`). */
7
9
  export type SqlPolicyCommand = "ALL" | "SELECT" | "INSERT" | "UPDATE" | "DELETE";
8
10
 
@@ -49,6 +51,11 @@ export type RlsIdentity = {
49
51
  readonly gate: string;
50
52
  readonly userId: string;
51
53
  readonly scopes: readonly string[];
54
+ /**
55
+ * When set, the prelude stamps `oke.tenant` (fourth GUC).
56
+ * Omit entirely when tenancy is off so the prelude stays three params.
57
+ */
58
+ readonly tenantId?: string;
52
59
  };
53
60
 
54
61
  /**
@@ -98,6 +105,8 @@ LANGUAGE sql STABLE AS $$
98
105
  ELSE jsonb_exists(current_setting('oke.scopes', true)::jsonb, p_scope)
99
106
  END
100
107
  $$`,
108
+ `CREATE OR REPLACE FUNCTION oke.tenant() RETURNS text
109
+ LANGUAGE sql STABLE AS $$ SELECT current_setting('oke.tenant', true) $$`,
101
110
  `DO $oke_rls_role$
102
111
  BEGIN
103
112
  CREATE ROLE ${OKE_RLS_ROLE} NOSUPERUSER NOBYPASSRLS NOLOGIN;
@@ -122,6 +131,7 @@ $oke_app_search_path$`,
122
131
 
123
132
  /**
124
133
  * All {@link OKE_RLS_HELPER_STATEMENTS} as one script (PGlite `exec` / `oke db push`).
134
+ * Vault table RLS is attached by {@link installOkeRlsHelpers} after helpers exist.
125
135
  */
126
136
  export const OKE_RLS_HELPER_SQL = `${OKE_RLS_HELPER_STATEMENTS.join(";\n")};`;
127
137
 
@@ -134,6 +144,10 @@ export async function installOkeRlsHelpers(exec: (sql: string) => Promise<unknow
134
144
  for (const stmt of OKE_RLS_HELPER_STATEMENTS) {
135
145
  await exec(stmt);
136
146
  }
147
+ const { attach } = lazyRequire<{
148
+ attach: (exec: (sql: string) => Promise<unknown>) => Promise<void>;
149
+ }>(import.meta.dir, ["pg", "vault", "rls"].join("-"));
150
+ await attach(exec);
137
151
  }
138
152
 
139
153
  /**
@@ -449,12 +463,22 @@ export type RlsPreludeStatement = {
449
463
  * @param identity - Gate principal
450
464
  */
451
465
  export function buildRlsIdentityPreludeSql(identity: RlsIdentity): readonly RlsPreludeStatement[] {
466
+ const configs = [
467
+ "set_config('oke.gate', ?, true)",
468
+ "set_config('oke.user', ?, true)",
469
+ "set_config('oke.scopes', ?, true)",
470
+ ];
471
+ const params: string[] = [identity.gate, identity.userId, rlsScopesJson(identity.scopes)];
472
+ if (identity.tenantId !== undefined) {
473
+ configs.push("set_config('oke.tenant', ?, true)");
474
+ params.push(identity.tenantId);
475
+ }
452
476
  return [
453
477
  { sql: `SET LOCAL ROLE ${OKE_RLS_ROLE}` },
454
478
  { sql: "SET LOCAL row_security = on" },
455
479
  {
456
- sql: `SELECT set_config('oke.gate', ?, true), set_config('oke.user', ?, true), set_config('oke.scopes', ?, true)`,
457
- params: [identity.gate, identity.userId, rlsScopesJson(identity.scopes)],
480
+ sql: `SELECT ${configs.join(", ")}`,
481
+ params,
458
482
  },
459
483
  ];
460
484
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tenant RLS on `oke_vault_secrets` — kept off the Store-only `oke()` graph.
3
+ *
4
+ * Same `oke.tenant()` helper as domain tables. Loaded from helper install
5
+ * and Vault DDL via a computed require.
6
+ */
7
+
8
+ /** Same role as `OKE_RLS_ROLE` (`oke_app`) — duplicated so this module stays off `pg-rls`. */
9
+ const VAULT_RLS_ROLE = "oke_app";
10
+
11
+ /**
12
+ * Built-in Vault ciphertext table. Global rows (`tenant_id IS NULL`) stay
13
+ * visible; per-tenant rows are isolated. No-op until `oke_vault_secrets`
14
+ * and `oke.tenant()` exist.
15
+ *
16
+ * ENABLE without FORCE: the Vault adapter (table owner) still sees every
17
+ * row; Console / `oke_app` stamped SQL cannot list another tenant's
18
+ * ciphertext.
19
+ */
20
+ export const OKE_VAULT_SECRETS_TENANT_RLS_SQL = `DO $oke_vault_rls$
21
+ BEGIN
22
+ IF to_regclass('public.oke_vault_secrets') IS NULL THEN
23
+ RETURN;
24
+ END IF;
25
+ IF NOT EXISTS (
26
+ SELECT 1 FROM pg_proc p
27
+ JOIN pg_namespace n ON n.oid = p.pronamespace
28
+ WHERE n.nspname = 'oke' AND p.proname = 'tenant' AND p.pronargs = 0
29
+ ) THEN
30
+ RETURN;
31
+ END IF;
32
+ EXECUTE 'ALTER TABLE oke_vault_secrets ENABLE ROW LEVEL SECURITY';
33
+ BEGIN
34
+ EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON oke_vault_secrets TO ${VAULT_RLS_ROLE}';
35
+ EXCEPTION
36
+ WHEN undefined_object THEN NULL;
37
+ END;
38
+ IF NOT EXISTS (
39
+ SELECT 1 FROM pg_policies
40
+ WHERE schemaname = 'public'
41
+ AND tablename = 'oke_vault_secrets'
42
+ AND policyname = 'oke_vault_secrets_tenant'
43
+ ) THEN
44
+ EXECUTE $p$
45
+ CREATE POLICY oke_vault_secrets_tenant ON oke_vault_secrets
46
+ AS PERMISSIVE FOR ALL TO public
47
+ USING (tenant_id = oke.tenant() OR tenant_id IS NULL)
48
+ WITH CHECK (tenant_id = oke.tenant() OR tenant_id IS NULL)
49
+ $p$;
50
+ END IF;
51
+ END
52
+ $oke_vault_rls$`;
53
+
54
+ /**
55
+ * Attach tenant RLS to `oke_vault_secrets` when the table and `oke.tenant()`
56
+ * are both present. Safe to call from Vault DDL and from helper install
57
+ * (either boot order).
58
+ *
59
+ * @param exec - Statement runner
60
+ */
61
+ export async function ensureVaultSecretsTenantRls(
62
+ exec: (sql: string) => Promise<unknown>,
63
+ ): Promise<void> {
64
+ await exec(OKE_VAULT_SECRETS_TENANT_RLS_SQL);
65
+ }
66
+
67
+ /** Short name for the Store-only `oke()` helper-install path. */
68
+ export { ensureVaultSecretsTenantRls as attach };
@@ -12,10 +12,12 @@ import type { SignalDecl } from "../elements/signal/declare.ts";
12
12
  import type { SignalDelivery } from "../manifest/types.ts";
13
13
  import { DryRunWriteIsolationError, setDryRunMessageId, withDryRun } from "../kernel/dry-run.ts";
14
14
  import { OkeError, OKE_ERRORS } from "../kernel/errors.ts";
15
+ import { LIVE_RESUME_GAP } from "../kernel/errors-live-resume.ts";
15
16
  import {
16
17
  SIGNAL_DEFAULT_LEASE_MS,
17
18
  validateSignalEmitPayload,
18
19
  type DeadLetter,
20
+ type LiveEvent,
19
21
  type LiveHandler,
20
22
  type SignalBus,
21
23
  type SignalDiscardOptions,
@@ -30,7 +32,10 @@ import {
30
32
  type SignalStats,
31
33
  type SignalTransaction,
32
34
  type SignalUnsubscribe,
35
+ type LiveSubscribeOptions,
33
36
  } from "./signal-types.ts";
37
+ import { createLiveIterable } from "./signal-live-iter.ts";
38
+ import { liveIdsToPrune, skipAfterId } from "./signal-retention.ts";
34
39
 
35
40
  /** Internal mutable message. */
36
41
  interface MutMessage {
@@ -313,26 +318,68 @@ export async function createSignalEngine(
313
318
  };
314
319
  }
315
320
 
316
- async function live(signal: string, handler: LiveHandler): Promise<SignalUnsubscribe> {
321
+ function live(signal: string, opts?: LiveSubscribeOptions): AsyncIterable<LiveEvent> {
317
322
  const decl = requireDecl(signal);
318
323
  if (decl.delivery !== "live") {
319
324
  throw new Error(`signal "${signal}" is not delivery: "live"`);
320
325
  }
321
- let set = liveHandlers.get(signal);
322
- if (!set) {
323
- set = new Set();
324
- liveHandlers.set(signal, set);
325
- }
326
- set.add(handler);
327
- // Replay retained live messages to late subscribers.
328
- for (const m of messages) {
329
- if (m.signal === signal && m.delivery === "live") {
330
- await handler(m.payload);
326
+ const afterId = opts?.afterId;
327
+ return createLiveIterable((emit) => {
328
+ pruneLive(signal);
329
+ const history: LiveEvent[] = [];
330
+ for (const m of messages) {
331
+ if (m.signal === signal && m.delivery === "live") {
332
+ history.push({ id: m.id, payload: m.payload });
333
+ }
334
+ }
335
+ const skipped = skipAfterId(history, afterId);
336
+ if (afterId !== undefined && afterId.length > 0 && !skipped.found) {
337
+ throw new OkeError(LIVE_RESUME_GAP, { signal, afterId });
338
+ }
339
+ let set = liveHandlers.get(signal);
340
+ if (!set) {
341
+ set = new Set();
342
+ liveHandlers.set(signal, set);
331
343
  }
344
+ const handler: LiveHandler = (event) => {
345
+ emit(event);
346
+ };
347
+ set.add(handler);
348
+ for (const event of skipped.rest) {
349
+ emit(event);
350
+ }
351
+ return () => {
352
+ set.delete(handler);
353
+ };
354
+ });
355
+ }
356
+
357
+ async function checkLiveResume(signal: string, afterId: string): Promise<void> {
358
+ requireDecl(signal);
359
+ pruneLive(signal);
360
+ const found = messages.some(
361
+ (m) => m.signal === signal && m.delivery === "live" && m.id === afterId,
362
+ );
363
+ if (!found) {
364
+ throw new OkeError(LIVE_RESUME_GAP, { signal, afterId });
365
+ }
366
+ }
367
+
368
+ function pruneLive(signal: string): void {
369
+ const decl = signals.get(signal);
370
+ const drop = liveIdsToPrune(
371
+ messages
372
+ .filter((m) => m.signal === signal && m.delivery === "live")
373
+ .map((m) => ({ id: m.id, createdAt: m.createdAt })),
374
+ decl?.retention,
375
+ now(),
376
+ );
377
+ if (drop.length === 0) return;
378
+ const dropSet = new Set(drop);
379
+ for (let i = messages.length - 1; i >= 0; i--) {
380
+ const m = messages[i]!;
381
+ if (m.delivery === "live" && dropSet.has(m.id)) messages.splice(i, 1);
332
382
  }
333
- return () => {
334
- set!.delete(handler);
335
- };
336
383
  }
337
384
 
338
385
  function toPublic(m: MutMessage): SignalMessage {
@@ -469,8 +516,9 @@ export async function createSignalEngine(
469
516
  if (m.delivery !== "live" || m.status !== "pending") continue;
470
517
  const handlers = liveHandlers.get(m.signal);
471
518
  if (handlers && handlers.size > 0) {
519
+ const event: LiveEvent = { id: m.id, payload: m.payload };
472
520
  for (const h of handlers) {
473
- await h(m.payload);
521
+ await h(event);
474
522
  }
475
523
  }
476
524
  pushLive(m.signal, m.payload);
@@ -478,6 +526,11 @@ export async function createSignalEngine(
478
526
  noteDelivered();
479
527
  progress = true;
480
528
  }
529
+ const touched = new Set<string>();
530
+ for (const m of messages) {
531
+ if (m.delivery === "live") touched.add(m.signal);
532
+ }
533
+ for (const name of touched) pruneLive(name);
481
534
  return progress;
482
535
  }
483
536
 
@@ -717,6 +770,7 @@ export async function createSignalEngine(
717
770
  begin,
718
771
  subscribe,
719
772
  live,
773
+ checkLiveResume,
720
774
  drain,
721
775
  deadLetters,
722
776
  inspect,
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Callback-set → {@link AsyncIterable} adapter for {@link SignalBus.live}.
3
+ */
4
+
5
+ import type { LiveEvent } from "./signal-types.ts";
6
+
7
+ /**
8
+ * Turn a live subscriber (replay-then-push) into an async iterable.
9
+ *
10
+ * `start` must register `emit` and replay retained events into it before
11
+ * resolving; `drain` later pushes new events through the same `emit`.
12
+ *
13
+ * @param start - Register + replay; return unsubscribe
14
+ */
15
+ export function createLiveIterable(
16
+ start: (emit: (event: LiveEvent) => void) => (() => void) | Promise<() => void>,
17
+ ): AsyncIterable<LiveEvent> {
18
+ return {
19
+ [Symbol.asyncIterator]() {
20
+ const queue: LiveEvent[] = [];
21
+ let notify: (() => void) | undefined;
22
+ let finished = false;
23
+ let unsub: (() => void) | undefined;
24
+ let started: Promise<void> | undefined;
25
+
26
+ const ensure = (): Promise<void> => {
27
+ if (started) return started;
28
+ const emit = (event: LiveEvent): void => {
29
+ queue.push(event);
30
+ notify?.();
31
+ };
32
+ const result = start(emit);
33
+ if (typeof result === "function") {
34
+ unsub = result;
35
+ started = Promise.resolve();
36
+ return started;
37
+ }
38
+ started = result.then((fn) => {
39
+ unsub = fn;
40
+ });
41
+ return started;
42
+ };
43
+
44
+ return {
45
+ async next() {
46
+ await ensure();
47
+ for (;;) {
48
+ const nextEvent = queue.shift();
49
+ if (nextEvent !== undefined) return { done: false, value: nextEvent };
50
+ if (finished) return { done: true, value: undefined };
51
+ await new Promise<void>((resolve) => {
52
+ notify = resolve;
53
+ });
54
+ }
55
+ },
56
+ async return() {
57
+ finished = true;
58
+ unsub?.();
59
+ notify?.();
60
+ return { done: true, value: undefined };
61
+ },
62
+ };
63
+ },
64
+ };
65
+ }
@@ -108,7 +108,8 @@ export async function openNatsSignal(options: SignalOpenOptions): Promise<Signal
108
108
  };
109
109
  },
110
110
  subscribe: (signal, subscriberId, handler) => outbox.subscribe(signal, subscriberId, handler),
111
- live: (signal, handler) => outbox.live(signal, handler),
111
+ live: (signal, opts) => outbox.live(signal, opts),
112
+ checkLiveResume: (signal, afterId) => outbox.checkLiveResume(signal, afterId),
112
113
  drain: () => outbox.drain(),
113
114
  deadLetters: (s) => outbox.deadLetters(s),
114
115
  inspect: (s) => outbox.inspect(s),