okengine 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/manifest.v1.schema.json +21 -2
  2. package/package.json +1 -1
  3. package/site/content/docs/ai/llms-txt.mdx +9 -3
  4. package/site/content/docs/elements/clock.mdx +7 -0
  5. package/site/content/docs/elements/flow.mdx +14 -12
  6. package/site/content/docs/elements/gate.mdx +58 -8
  7. package/site/content/docs/elements/signal.mdx +46 -17
  8. package/site/content/docs/elements/store.mdx +23 -17
  9. package/site/content/docs/elements/vault.mdx +23 -0
  10. package/site/content/docs/get-started/project-structure.mdx +4 -4
  11. package/site/content/docs/reference/cli.md +1 -1
  12. package/site/content/docs/reference/client.mdx +72 -17
  13. package/site/content/docs/reference/configuration.mdx +7 -4
  14. package/site/content/docs/reference/errors.mdx +24 -17
  15. package/site/content/docs/reference/fx.mdx +15 -9
  16. package/src/auth/api-key-sql.ts +11 -4
  17. package/src/auth/api-keys.ts +3 -0
  18. package/src/auth/config.ts +19 -0
  19. package/src/auth/index.ts +37 -0
  20. package/src/auth/plugin.ts +6 -1
  21. package/src/auth/sessions.ts +18 -0
  22. package/src/auth/tables.ts +32 -0
  23. package/src/auth/tenant-config.ts +74 -0
  24. package/src/auth/tenant-tables.ts +11 -0
  25. package/src/auth/tenants.test.ts +63 -0
  26. package/src/auth/tenants.ts +360 -0
  27. package/src/cli/build.ts +2 -2
  28. package/src/client/budget.test.ts +1 -1
  29. package/src/client/create.ts +75 -5
  30. package/src/client/index.ts +13 -0
  31. package/src/client/live.test.ts +422 -0
  32. package/src/client/live.ts +389 -0
  33. package/src/client/notes-contract.test.ts +41 -0
  34. package/src/client/types.ts +85 -6
  35. package/src/client-react/index.ts +85 -1
  36. package/src/client-react/use-live.test.ts +129 -0
  37. package/src/compiler/effects-infer.ts +22 -2
  38. package/src/compiler/extract.test.ts +143 -11
  39. package/src/compiler/extract.ts +210 -30
  40. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  41. package/src/compiler/fixtures/skyport.expected.json +2 -3
  42. package/src/compiler/response.ts +41 -11
  43. package/src/console/server/app.ts +44 -11
  44. package/src/console/server/console.test.ts +6 -8
  45. package/src/console/server/gates.ts +2 -9
  46. package/src/console/server/store.test.ts +17 -0
  47. package/src/console/server/store.ts +43 -1
  48. package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-9Wwx1g4Q.js} +1 -1
  49. package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-DydRDi70.js} +1 -1
  50. package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-Cg9nxd6m.js} +3 -3
  51. package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-D-OzmFOI.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
  53. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
  54. package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-B_zeux2Y.js} +1 -1
  55. package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-CsMg7XDW.js} +1 -1
  56. package/src/console/ui-next/dist/index.html +1 -1
  57. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  58. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  59. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  60. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  61. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  62. package/src/drivers/index.ts +2 -0
  63. package/src/drivers/journal-postgres.ts +12 -3
  64. package/src/drivers/pg-rls.ts +26 -2
  65. package/src/drivers/pg-vault-rls.ts +68 -0
  66. package/src/drivers/signal-engine.ts +69 -15
  67. package/src/drivers/signal-live-iter.ts +65 -0
  68. package/src/drivers/signal-nats.ts +2 -1
  69. package/src/drivers/signal-postgres.ts +95 -12
  70. package/src/drivers/signal-redis.ts +2 -1
  71. package/src/drivers/signal-retention.ts +64 -0
  72. package/src/drivers/signal-types.ts +35 -5
  73. package/src/elements/clock/declare.ts +64 -2
  74. package/src/elements/clock/reconcile.ts +98 -25
  75. package/src/elements/clock/runtime.ts +13 -2
  76. package/src/elements/clock.test.ts +28 -0
  77. package/src/elements/clock.ts +9 -1
  78. package/src/elements/gate/permissions.ts +12 -0
  79. package/src/elements/gate.ts +6 -1
  80. package/src/elements/signal/declare.ts +44 -10
  81. package/src/elements/signal/delivery-modes.test.ts +90 -4
  82. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  83. package/src/elements/signal/runtime.ts +42 -0
  84. package/src/elements/signal.test.ts +21 -8
  85. package/src/elements/signal.ts +1 -1
  86. package/src/elements/store/declare.ts +7 -0
  87. package/src/elements/store/rls-identity.test.ts +29 -0
  88. package/src/elements/store/rls-identity.ts +3 -0
  89. package/src/elements/store/schema-decl.ts +63 -3
  90. package/src/elements/store/schema-tenant.ts +41 -0
  91. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  92. package/src/elements/store.ts +2 -0
  93. package/src/elements/vault/builtin-adapter.ts +15 -2
  94. package/src/elements/vault/declare.ts +8 -0
  95. package/src/elements/vault/runtime.ts +7 -0
  96. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  97. package/src/elements/vault/storage.ts +9 -0
  98. package/src/elements/vault/test-helpers.ts +2 -1
  99. package/src/i18n/catalogs/ar.ts +19 -0
  100. package/src/i18n/catalogs/en.ts +19 -0
  101. package/src/index.ts +1 -0
  102. package/src/kernel/adopt-routes.ts +56 -8
  103. package/src/kernel/app-tenant.ts +122 -0
  104. package/src/kernel/app.ts +183 -56
  105. package/src/kernel/auth-resolve.ts +3 -0
  106. package/src/kernel/boot-bind/clock.ts +9 -3
  107. package/src/kernel/boot.ts +2 -0
  108. package/src/kernel/budget.test.ts +1 -1
  109. package/src/kernel/clock-durable.ts +8 -0
  110. package/src/kernel/clock-per-tenant-name.ts +5 -0
  111. package/src/kernel/clock-reconcile.ts +8 -0
  112. package/src/kernel/errors-live-resume.ts +15 -0
  113. package/src/kernel/errors-tenant.ts +29 -0
  114. package/src/kernel/errors.registry.test.ts +31 -3
  115. package/src/kernel/errors.ts +43 -3
  116. package/src/kernel/flow.ts +26 -5
  117. package/src/kernel/fx-auth-keys.ts +6 -1
  118. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  119. package/src/kernel/fx-auth-tenants.ts +286 -0
  120. package/src/kernel/fx-live-stream.ts +149 -0
  121. package/src/kernel/fx-live.test.ts +157 -0
  122. package/src/kernel/fx-runtime.ts +15 -0
  123. package/src/kernel/fx-tenant-store.ts +213 -0
  124. package/src/kernel/fx.test.ts +91 -0
  125. package/src/kernel/fx.ts +173 -13
  126. package/src/kernel/hooks.ts +2 -2
  127. package/src/kernel/http-resource.ts +9 -18
  128. package/src/kernel/index.ts +2 -0
  129. package/src/kernel/journal.ts +12 -0
  130. package/src/kernel/live-http.test.ts +78 -0
  131. package/src/kernel/live-http.ts +114 -0
  132. package/src/kernel/live-resume.test.ts +125 -0
  133. package/src/kernel/on.ts +51 -0
  134. package/src/kernel/pipeline-tenant.ts +49 -0
  135. package/src/kernel/pipeline.test.ts +1 -1
  136. package/src/kernel/pipeline.ts +37 -2
  137. package/src/kernel/resource-mount.test.ts +10 -27
  138. package/src/kernel/tenant-resolve.test.ts +101 -0
  139. package/src/kernel/tenant-resolve.ts +124 -0
  140. package/src/kernel/tenant-roles.test.ts +87 -0
  141. package/src/kernel/triggers.ts +59 -21
  142. package/src/manifest/diff.test.ts +11 -2
  143. package/src/manifest/diff.ts +53 -11
  144. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  145. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  146. package/src/manifest/types.ts +52 -6
  147. package/src/release/build-lib.ts +13 -1
  148. package/src/release/limits.ts +2 -2
  149. package/src/release/measure.ts +55 -1
  150. package/src/client/live-gap.test.ts +0 -35
@@ -0,0 +1,129 @@
1
+ /**
2
+ * useLive — mount/unmount and typed event state.
3
+ */
4
+
5
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
6
+ import { Window } from "happy-dom";
7
+ import { act, createElement } from "react";
8
+ import { createRoot, type Root } from "react-dom/client";
9
+ import { createClient } from "../client/create.ts";
10
+ import type { AppOf, LiveSignalHandle } from "../client/types.ts";
11
+ import { useLive } from "./index.ts";
12
+
13
+ type EventsApp = AppOf<{
14
+ orders: {
15
+ events: {
16
+ in: { orderId: string };
17
+ out: { orderId: string; status: string };
18
+ method: "GET";
19
+ path: "/orders/:orderId/events";
20
+ live: "order-status";
21
+ matchKey: ["orderId"];
22
+ stream: true;
23
+ };
24
+ };
25
+ }>;
26
+
27
+ type Payload = { orderId: string; status: string };
28
+
29
+ const orderStatus: LiveSignalHandle<Payload> = {
30
+ name: "order-status",
31
+ };
32
+
33
+ function sseResponse(frames: readonly unknown[], ids?: readonly string[]): Response {
34
+ const lines: string[] = [];
35
+ frames.forEach((payload, i) => {
36
+ const id = ids?.[i];
37
+ if (id !== undefined) lines.push(`id: ${id}`);
38
+ lines.push(`data: ${JSON.stringify(payload)}`);
39
+ lines.push("");
40
+ });
41
+ lines.push("data: [DONE]");
42
+ lines.push("");
43
+ return new Response(lines.join("\n"), {
44
+ status: 200,
45
+ headers: { "content-type": "text/event-stream" },
46
+ });
47
+ }
48
+
49
+ async function waitFor(pred: () => boolean, ms = 1_000): Promise<void> {
50
+ const start = Date.now();
51
+ while (!pred()) {
52
+ if (Date.now() - start > ms) throw new Error("timeout");
53
+ await new Promise((r) => setTimeout(r, 5));
54
+ }
55
+ }
56
+
57
+ describe("useLive", () => {
58
+ let happy: Window;
59
+
60
+ beforeEach(() => {
61
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
62
+ happy = new Window({ url: "http://app.test/" });
63
+ Object.defineProperty(globalThis, "window", {
64
+ value: happy,
65
+ configurable: true,
66
+ writable: true,
67
+ });
68
+ Object.defineProperty(globalThis, "document", {
69
+ value: happy.document,
70
+ configurable: true,
71
+ writable: true,
72
+ });
73
+ });
74
+
75
+ afterEach(() => {
76
+ happy.close();
77
+ });
78
+
79
+ test("collects events and latest; unmount stops fetches", async () => {
80
+ let calls = 0;
81
+ const payload = { orderId: "ord_1", status: "placed" as const };
82
+ const api = createClient<EventsApp>("http://app.test", {
83
+ fetch: async () => {
84
+ calls += 1;
85
+ return sseResponse([payload], ["evt-1"]);
86
+ },
87
+ $routes: {
88
+ orders: {
89
+ events: {
90
+ method: "GET",
91
+ path: "/orders/:orderId/events",
92
+ live: "order-status",
93
+ matchKey: ["orderId"],
94
+ stream: true,
95
+ },
96
+ },
97
+ },
98
+ });
99
+
100
+ const host = happy.document.createElement("div");
101
+ happy.document.body.appendChild(host);
102
+ const seen: { latest: Payload | null; events: Payload[] } = { latest: null, events: [] };
103
+
104
+ function Probe(): null {
105
+ const state = useLive(api, orderStatus, { orderId: "ord_1" });
106
+ seen.latest = state.latest;
107
+ seen.events = state.events;
108
+ return null;
109
+ }
110
+
111
+ let root: Root | undefined;
112
+ await act(async () => {
113
+ root = createRoot(host as unknown as Element);
114
+ root.render(createElement(Probe));
115
+ });
116
+ await waitFor(() => seen.events.length === 1);
117
+ expect(seen.latest).toEqual(payload);
118
+ expect(seen.events).toEqual([payload]);
119
+ expect(calls).toBe(1);
120
+
121
+ await act(async () => {
122
+ root?.unmount();
123
+ });
124
+ const after = calls;
125
+ await new Promise((r) => setTimeout(r, 200));
126
+ expect(calls).toBe(after);
127
+ host.remove();
128
+ });
129
+ });
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type {
10
10
  AuthApiKeysResourceRef,
11
+ AuthTenantsResourceRef,
11
12
  Effects,
12
13
  PromptRef,
13
14
  ResourceRef,
@@ -146,8 +147,10 @@ const TABLE_ARG_METHODS = new Set([
146
147
  * @param options - Handler AST, bindings, and annotation flag
147
148
  */
148
149
  export function inferEffects(options: InferEffectsOptions): InferredEffects {
149
- const reads = new Set<ResourceRef | SignalResourceRef | AuthApiKeysResourceRef>();
150
- const writes = new Set<ResourceRef | AuthApiKeysResourceRef>();
150
+ const reads = new Set<
151
+ ResourceRef | SignalResourceRef | AuthApiKeysResourceRef | AuthTenantsResourceRef
152
+ >();
153
+ const writes = new Set<ResourceRef | AuthApiKeysResourceRef | AuthTenantsResourceRef>();
151
154
  const emits = new Set<SignalRef>();
152
155
  const sends = new Set<TemplateRef>();
153
156
  const asks = new Set<PromptRef>();
@@ -183,6 +186,12 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
183
186
  continue;
184
187
  }
185
188
 
189
+ if (chain.rootMethod === "live" && call === chain.rootCall) {
190
+ const ref = resolveNamed(call.arguments[0], options.bindings, "signal");
191
+ if (ref) reads.add(`signal:${ref}`);
192
+ continue;
193
+ }
194
+
186
195
  if (chain.rootMethod === "send" && call === chain.rootCall) {
187
196
  const ref = resolveNamed(call.arguments[0], options.bindings, "template");
188
197
  if (ref) sends.add(ref);
@@ -221,6 +230,7 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
221
230
  if (chain.rootMethod === "auth" && call === chain.rootCall) {
222
231
  const method = chain.methods[1];
223
232
  if (method === "listApiKeys") reads.add("auth:api-keys");
233
+ if (method === "listTenants" || method === "listMembers") reads.add("auth:tenants");
224
234
  if (
225
235
  method === "createApiKey" ||
226
236
  method === "revokeApiKey" ||
@@ -229,6 +239,16 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
229
239
  ) {
230
240
  writes.add("auth:api-keys");
231
241
  }
242
+ if (
243
+ method === "switchTenant" ||
244
+ method === "createTenant" ||
245
+ method === "deleteTenant" ||
246
+ method === "addMember" ||
247
+ method === "removeMember" ||
248
+ method === "upsertTenantRole"
249
+ ) {
250
+ writes.add("auth:tenants");
251
+ }
232
252
  continue;
233
253
  }
234
254
 
@@ -337,6 +337,23 @@ export const stripeKey = vault.secret("STRIPE_KEY", {
337
337
  expect(manifest.vault?.STRIPE_KEY?.description).toBe("Payments gateway key");
338
338
  });
339
339
 
340
+ test("extracts live signal retention", async () => {
341
+ const manifest = await extractFromSources({
342
+ "src/signals.ts": `
343
+ import { signal } from "okengine";
344
+ export const orderStatus = signal("order-status", {
345
+ delivery: "live",
346
+ optional: true,
347
+ retention: { maxAge: "24h", maxCount: 500 },
348
+ });
349
+ `,
350
+ });
351
+ expect(manifest.signals?.["order-status"]?.retention).toEqual({
352
+ maxAge: "24h",
353
+ maxCount: 500,
354
+ });
355
+ });
356
+
340
357
  test("extracts store.kv durable onto the Manifest store", async () => {
341
358
  const manifest = await extractFromSources({
342
359
  "src/kv.ts": `
@@ -362,7 +379,6 @@ export const { list, create, get, update, remove } = bindNamedTableCrud({
362
379
  unit: "cycles",
363
380
  path: "/cycles",
364
381
  table: cycles,
365
- liveList: true,
366
382
  });
367
383
  `;
368
384
  const helper = `
@@ -379,7 +395,7 @@ export function bindNamedTableCrud(spec) {
379
395
  expect(manifest.flows?.["cycles.list"]?.trigger).toEqual({
380
396
  http: { method: "GET", path: "/cycles" },
381
397
  });
382
- expect(manifest.flows?.["cycles.list"]?.live).toBe(true);
398
+ expect(manifest.flows?.["cycles.list"]?.live).toBeUndefined();
383
399
  expect(manifest.flows?.["cycles.create"]?.trigger).toEqual({
384
400
  http: { method: "POST", path: "/cycles" },
385
401
  });
@@ -469,7 +485,7 @@ export const remove = mounted.remove;
469
485
  expect(manifest.flows?.list?.breaking).toBe(true);
470
486
  });
471
487
 
472
- test("chains .gate(...) and .live() onto the five verbs", async () => {
488
+ test("chains .gate(...) onto the five verbs", async () => {
473
489
  const source = `
474
490
  import { on, http, store, gate } from "okengine";
475
491
 
@@ -478,7 +494,7 @@ export const db = store.sql("notes", { schema: {} });
478
494
 
479
495
  const notesR = store.resource(db, {}, {});
480
496
 
481
- const mounted = on(http.resource("/notes", notesR.all()).gate(member).live());
497
+ const mounted = on(http.resource("/notes", notesR.all()).gate(member));
482
498
 
483
499
  export const list = mounted.list;
484
500
  export const create = mounted.create;
@@ -493,20 +509,18 @@ export const remove = mounted.remove;
493
509
  expect(manifest.flows?.get?.gates).toEqual(["member"]);
494
510
  expect(manifest.flows?.update?.gates).toEqual(["member"]);
495
511
  expect(manifest.flows?.remove?.gates).toEqual(["member"]);
496
- expect(manifest.flows?.list?.live).toBe(true);
497
- expect(manifest.flows?.get?.live).toBe(true);
512
+ expect(manifest.flows?.list?.live).toBeUndefined();
513
+ expect(manifest.flows?.get?.live).toBeUndefined();
498
514
  expect(manifest.flows?.create?.live).toBeUndefined();
499
- expect(manifest.flows?.update?.live).toBeUndefined();
500
- expect(manifest.flows?.remove?.live).toBeUndefined();
501
515
  });
502
516
 
503
- test("chains .public().live() onto the five verbs", async () => {
517
+ test("chains .public() onto the five verbs", async () => {
504
518
  const source = `
505
519
  import { on, http, store } from "okengine";
506
520
 
507
521
  export const db = store.sql("notes", { schema: {} });
508
522
  const notesR = store.resource(db, {}, {});
509
- const mounted = on(http.resource("/notes", notesR.all()).public().live());
523
+ const mounted = on(http.resource("/notes", notesR.all()).public());
510
524
 
511
525
  export const list = mounted.list;
512
526
  export const create = mounted.create;
@@ -517,7 +531,7 @@ export const remove = mounted.remove;
517
531
  const manifest = await extractFromSources({ "src/flows/notes.ts": source });
518
532
  expect(manifest.flows?.list?.gates).toEqual(["public"]);
519
533
  expect(manifest.flows?.create?.gates).toEqual(["public"]);
520
- expect(manifest.flows?.get?.live).toBe(true);
534
+ expect(manifest.flows?.get?.live).toBeUndefined();
521
535
  expect(manifest.flows?.create?.live).toBeUndefined();
522
536
  });
523
537
  });
@@ -672,6 +686,67 @@ export const failed = on(
672
686
  });
673
687
  });
674
688
 
689
+ describe("extractManifest — fx.live / .live(signal)", () => {
690
+ test("one-arg on(http.get(path).live(signal)) stamps live + signal read", async () => {
691
+ const source = `
692
+ import { on, http, gate, signal } from "okengine";
693
+ import { z } from "zod";
694
+
695
+ export const member = gate.policy("member", ({ auth }) => !!auth.verified);
696
+ export const orderStatus = signal("order-status", {
697
+ delivery: "live",
698
+ optional: true,
699
+ schema: z.object({ orderId: z.string(), status: z.string() }),
700
+ });
701
+
702
+ export const events = on(http.get("/orders/:orderId/events").gate(member).live(orderStatus));
703
+ `;
704
+ const manifest = await extractFromSources({ "src/flows/orders/index.ts": source });
705
+ const flow = manifest.flows?.["orders.events"];
706
+ expect(flow?.live).toBe("order-status");
707
+ expect(flow?.trigger).toEqual({
708
+ http: { method: "GET", path: "/orders/:orderId/events" },
709
+ });
710
+ expect(flow?.gates).toEqual(["member"]);
711
+ expect(flow?.effects?.reads).toEqual(["signal:order-status"]);
712
+ });
713
+
714
+ test("http.live(signal) uses GET /_oke/live/{name}", async () => {
715
+ const source = `
716
+ import { on, http, gate, signal } from "okengine";
717
+
718
+ export const member = gate.policy("member", ({ auth }) => !!auth.verified);
719
+ export const orderStatus = signal("order-status", { delivery: "live", optional: true });
720
+
721
+ export const firehose = on(http.live(orderStatus).gate(member));
722
+ `;
723
+ const manifest = await extractFromSources({ "src/flows/orders/index.ts": source });
724
+ const flow = manifest.flows?.["orders.firehose"];
725
+ expect(flow?.live).toBe("order-status");
726
+ expect(flow?.trigger).toEqual({
727
+ http: { method: "GET", path: "/_oke/live/order-status" },
728
+ });
729
+ });
730
+
731
+ test("fx.live(signal) infers reads: [signal:<name>]", async () => {
732
+ const source = `
733
+ import { on, flow, http, signal } from "okengine";
734
+
735
+ export const orderStatus = signal("order-status", { delivery: "live", optional: true });
736
+
737
+ export const events = on(
738
+ http.get("/feed").public().live(orderStatus),
739
+ flow("orders.events", {
740
+ do: (_input, fx) => fx.live(orderStatus),
741
+ }),
742
+ );
743
+ `;
744
+ const manifest = await extractFromSources({ "src/flows/orders.ts": source });
745
+ expect(manifest.flows?.["orders.events"]?.live).toBe("order-status");
746
+ expect(manifest.flows?.["orders.events"]?.effects?.reads).toEqual(["signal:order-status"]);
747
+ });
748
+ });
749
+
675
750
  describe("extractManifest — channel medium binder aliasing", () => {
676
751
  test("mail.template(...) resolves through `const mail = channel.email(...)`", async () => {
677
752
  const source = `
@@ -1205,3 +1280,60 @@ export const create = flow("views.create", {
1205
1280
  });
1206
1281
  });
1207
1282
  });
1283
+
1284
+ describe("extractManifest — tenancy", () => {
1285
+ test("oke({ gate: { auth: { tenant: true }}}) stamps tenancy.enabled", async () => {
1286
+ const source = `
1287
+ import { oke, on, flow, http, store, field } from "okengine";
1288
+
1289
+ export const bookings = store.schema.table("bookings", {
1290
+ id: field.text().primaryKey(),
1291
+ tenantId: field.text().notNull(),
1292
+ }, [
1293
+ store.schema.policy.tenant("tenant_id"),
1294
+ ]);
1295
+
1296
+ export const db = store.sql("app", { schema: { bookings } });
1297
+
1298
+ export const ping = on(
1299
+ http.get("/ping").public(),
1300
+ flow("health.ping", { do: () => ({ ok: true }) }),
1301
+ );
1302
+
1303
+ export const app = oke({
1304
+ name: "shop",
1305
+ gate: { auth: { tenant: true } },
1306
+ });
1307
+ `;
1308
+ const manifest = await extractFromSources({ "src/app.ts": source });
1309
+ expect(manifest.tenancy).toMatchObject({
1310
+ enabled: true,
1311
+ isolation: "row",
1312
+ membershipRequired: false,
1313
+ });
1314
+ expect(manifest.flows?.["health.ping"]?.tenantScoped).toBe(true);
1315
+ expect(manifest.stores?.app?.tables?.bookings?.policies?.tenant_tenant_id_all?.using).toContain(
1316
+ "oke.tenant()",
1317
+ );
1318
+ });
1319
+
1320
+ test("SQL table without tenant policy or unscoped() fails loud", async () => {
1321
+ const source = `
1322
+ import { oke, store, field } from "okengine";
1323
+
1324
+ export const notes = store.schema.table("notes", {
1325
+ id: field.text().primaryKey(),
1326
+ });
1327
+
1328
+ export const db = store.sql("app", { schema: { notes } });
1329
+
1330
+ export const app = oke({
1331
+ name: "shop",
1332
+ gate: { auth: { tenant: true } },
1333
+ });
1334
+ `;
1335
+ await expect(extractFromSources({ "src/app.ts": source })).rejects.toThrow(
1336
+ /needs store.schema.policy.tenant/,
1337
+ );
1338
+ });
1339
+ });