okengine 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "One law. Eight elements. Ten exports. One package. One manifest. Every backend need is derived, never added.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -51,6 +51,12 @@ const consoleUrl = Bun.env["OKE_DEV_HERO_CONSOLE"];
51
51
  const mcpUrl = Bun.env["OKE_DEV_HERO_MCP"];
52
52
  const heroMeta = decodeHeroSnapshot(Bun.env["OKE_DEV_HERO_META"]);
53
53
 
54
+ // Signal to boot() where to lazily extract effects from when a flow has no
55
+ // hand-declared `effects` — kernel/boot.ts's mintCapabilities() reads this
56
+ // (explicit opt-in only; the kernel itself never defaults to cwd). `cwd` is
57
+ // already the project root here (Bun.spawn's `cwd` option in dev.ts).
58
+ process.env["OKE_ROOT_DIR"] ??= process.cwd();
59
+
54
60
  const absoluteEntry = resolve(entry);
55
61
  const mod = (await import(pathToFileURL(absoluteEntry).href)) as {
56
62
  app?: BootableApp;
package/src/cli/dev.ts CHANGED
@@ -233,6 +233,21 @@ export interface DevOptions {
233
233
  readonly env: Record<string, string>;
234
234
  readonly onUpdate?: (service: string, status: DevStatus) => void;
235
235
  }) => Promise<Map<string, DevStatus>>;
236
+ /**
237
+ * Injectable `docker compose ps` runner for live health polls
238
+ * ({@link startComposeHealthWatch} / keyboard refresh). Default: real
239
+ * `docker`. When {@link DevOptions.composeHealth} is set and this is
240
+ * omitted, live polls return empty (unit tests never shell out).
241
+ *
242
+ * @param args - Args after `docker` (e.g. `compose -f … ps -a …`)
243
+ * @param cwd - Compose directory
244
+ * @param env - Stack env merged into the process env
245
+ */
246
+ readonly composeHealthRun?: (
247
+ args: readonly string[],
248
+ cwd: string,
249
+ env: Record<string, string>,
250
+ ) => Promise<string>;
236
251
  /**
237
252
  * Regenerate client types (injectable).
238
253
  *
@@ -298,6 +313,14 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
298
313
  /** Reassigned to {@link createAnchoredBoard} wrap after the status board paints. */
299
314
  let write: (text: string) => void = options.write ?? ((t) => process.stdout.write(t));
300
315
  const chromeWrite = (t: string) => write(formatCliChrome(t));
316
+ /**
317
+ * `docker compose ps` runner for live / default health polls.
318
+ * Explicit inject wins; otherwise a `composeHealth` inject implies tests —
319
+ * never shell out to a real `docker` binary.
320
+ */
321
+ const composeHealthRun =
322
+ options.composeHealthRun ??
323
+ (options.composeHealth ? async (): Promise<string> => "[]" : undefined);
301
324
  const previousWarn = console.warn;
302
325
  console.warn = (...args: unknown[]) => {
303
326
  const msg = args.map(String).join(" ");
@@ -656,6 +679,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
656
679
  files: composeFiles,
657
680
  cwd: dockerOut,
658
681
  env: dockerStarted.env,
682
+ run: composeHealthRun,
659
683
  timeoutMs: 20_000,
660
684
  isDone: (map) => {
661
685
  // Empty ps (compose just-created / injectable gap): do not spin 20s.
@@ -952,6 +976,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
952
976
  files: started.files,
953
977
  cwd: started.cwd,
954
978
  env: started.env,
979
+ run: composeHealthRun,
955
980
  intervalMs: 2_000,
956
981
  onChange: (map) => {
957
982
  liveComposeHealth = map;
@@ -1325,6 +1350,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
1325
1350
  files: started.files,
1326
1351
  cwd: started.cwd,
1327
1352
  env: started.env,
1353
+ run: composeHealthRun,
1328
1354
  });
1329
1355
  if (liveComposeHealth.get("ai") === "error") heroAiStatus = "error";
1330
1356
  repaintBoard();
@@ -207,6 +207,21 @@ describe("oke start", () => {
207
207
  expect(code).toBe(0);
208
208
  expect(ran).toBe(entry);
209
209
  });
210
+
211
+ test("sets OKE_ROOT_DIR so boot() can lazily derive effects for undeclared flows", async () => {
212
+ const dir = await mkdtemp(join(tmpdir(), "oke-cli-start-rootdir-"));
213
+ await Bun.write(join(dir, "src/app.ts"), "export {}\n");
214
+ let seenRootDir: string | undefined;
215
+ const code = await runStart({
216
+ cwd: dir,
217
+ runEntry: async (_e, env) => {
218
+ seenRootDir = env.OKE_ROOT_DIR;
219
+ },
220
+ write: () => {},
221
+ });
222
+ expect(code).toBe(0);
223
+ expect(seenRootDir).toBe(dir);
224
+ });
210
225
  });
211
226
 
212
227
  describe("oke vault", () => {
package/src/cli/start.ts CHANGED
@@ -55,7 +55,18 @@ export async function runStart(options: StartOptions = {}): Promise<number> {
55
55
  try {
56
56
  const entry = await resolveStartEntry(cwd, options.entry);
57
57
  const port = String(options.port ?? Number(Bun.env.PORT ?? APP_PORT));
58
- const env = { ...process.env, NODE_ENV: "production", PORT: port } as Record<string, string>;
58
+ const env = {
59
+ ...process.env,
60
+ NODE_ENV: "production",
61
+ PORT: port,
62
+ // Signal to boot() where to lazily extract effects from when a flow
63
+ // has no hand-declared `effects` — kernel/boot.ts's mintCapabilities()
64
+ // reads this (explicit opt-in only). docker/prod hard-fail (OKE1008)
65
+ // rather than silently open when neither this nor an explicit
66
+ // `manifest` resolves anything — this just gives that resolution a
67
+ // real chance to succeed instead of failing every time.
68
+ OKE_ROOT_DIR: process.env["OKE_ROOT_DIR"] ?? cwd,
69
+ } as Record<string, string>;
59
70
  write(`oke start: ${entry} (port ${port})\n`);
60
71
  if (options.runEntry) {
61
72
  await options.runEntry(entry, env);
@@ -15,6 +15,7 @@ import type {
15
15
  TemplateRef,
16
16
  FlowRef,
17
17
  } from "../manifest/types.ts";
18
+ import { sqlTableRef } from "../manifest/sql-resource.ts";
18
19
 
19
20
  /** Minimal ESTree-shaped nodes produced by oxc-parser. */
20
21
  export interface AstNode {
@@ -54,6 +55,7 @@ export interface InferBinding {
54
55
  | "template"
55
56
  | "flow"
56
57
  | "embed"
58
+ | "table"
57
59
  | "unknown";
58
60
  /** Resolved resource / name. */
59
61
  readonly ref: string;
@@ -103,9 +105,20 @@ const READ_METHODS = new Set([
103
105
  "findMany",
104
106
  "findFirst",
105
107
  "find",
108
+ "list",
106
109
  ]);
107
110
 
108
- const WRITE_METHODS = new Set(["insert", "set", "delete", "increment", "update", "upsert", "log"]);
111
+ const WRITE_METHODS = new Set([
112
+ "insert",
113
+ "set",
114
+ "delete",
115
+ "increment",
116
+ "update",
117
+ "upsert",
118
+ "log",
119
+ "put",
120
+ "putImage",
121
+ ]);
109
122
 
110
123
  /** Methods whose first argument is a table / collection identifier. */
111
124
  const TABLE_ARG_METHODS = new Set([
@@ -203,7 +216,7 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
203
216
  // Skip incomplete chains (`select` before `.from`, `insert` before table) —
204
217
  // the sibling call that carries the table arg records the real resource.
205
218
  const leaf = resolved.methods[resolved.methods.length - 1]!;
206
- const hasTable = tableFromStoreChain(call) !== undefined;
219
+ const hasTable = tableFromStoreChain(call, options.bindings) !== undefined;
207
220
  if (!hasTable && (leaf === "select" || leaf === "insert" || leaf === "update")) {
208
221
  continue;
209
222
  }
@@ -352,11 +365,14 @@ export function storeResourceFromCall(
352
365
  const storeBinding = resolveBinding(storeArg, bindings);
353
366
  const facet = storeBinding?.facet ?? "sql";
354
367
 
355
- const table = tableFromStoreChain(call);
368
+ const table = tableFromStoreChain(call, bindings);
356
369
 
357
370
  if (table) {
358
371
  return {
359
- resource: `${facet}:${table}` as ResourceRef,
372
+ // sql:<table> is the shared naming convention with the kernel's
373
+ // runtime capability gate (see ../manifest/sql-resource.ts) — table
374
+ // args only ever occur on sql-facet methods in practice.
375
+ resource: (facet === "sql" ? sqlTableRef(table) : `${facet}:${table}`) as ResourceRef,
360
376
  methods: chain.methods,
361
377
  };
362
378
  }
@@ -382,11 +398,21 @@ export function storeResourceFromCall(
382
398
  }
383
399
 
384
400
  /**
385
- * Walk an `fx.store(…).a().b(table)` chain and return the table identifier.
401
+ * Walk an `fx.store(…).a().b(table)` chain and return the declared table
402
+ * name — resolved through a registered `table` binding (the real string
403
+ * passed to `store.schema.table(name, …)`) when the argument is one, so a
404
+ * JS binding named differently from its declared table (`const notesTable
405
+ * = store.schema.table("notes", …)`) still resolves to `"notes"`, matching
406
+ * what the kernel reads off the live table object at call time. Falls back
407
+ * to the raw identifier text otherwise.
386
408
  *
387
409
  * @param call - Any call in the chain
410
+ * @param bindings - Scope bindings
388
411
  */
389
- function tableFromStoreChain(call: CallExpression): string | undefined {
412
+ function tableFromStoreChain(
413
+ call: CallExpression,
414
+ bindings: ReadonlyMap<string, InferBinding>,
415
+ ): string | undefined {
390
416
  let current: AstNode | undefined = call;
391
417
  while (current && current.type === "CallExpression") {
392
418
  const c = current as CallExpression;
@@ -394,8 +420,11 @@ function tableFromStoreChain(call: CallExpression): string | undefined {
394
420
  if (!link || link.rootMethod !== "store") break;
395
421
  const leaf = link.methods[link.methods.length - 1]!;
396
422
  if (TABLE_ARG_METHODS.has(leaf)) {
397
- const table = identifierName(c.arguments[0]);
398
- if (table) return table;
423
+ const id = identifierName(c.arguments[0]);
424
+ if (id) {
425
+ const binding = bindings.get(id);
426
+ return binding?.kind === "table" ? binding.ref : id;
427
+ }
399
428
  }
400
429
  const callee = c.callee;
401
430
  if (callee.type === "MemberExpression") {
@@ -344,3 +344,128 @@ export const remove = mounted.remove;
344
344
  expect(manifest.flows?.list?.breaking).toBe(true);
345
345
  });
346
346
  });
347
+
348
+ describe("extractManifest — files-store write methods", () => {
349
+ test("fx.store(files).put(...) infers a write, unannotated", async () => {
350
+ const source = `
351
+ import { on, flow, http, gate, store } from "okengine";
352
+
353
+ export const files = store.files("uploads");
354
+
355
+ export const attach = on(
356
+ http.post("/attach").gate(gate.public),
357
+ flow({
358
+ name: "notes.attach",
359
+ unit: "notes",
360
+ do: async (input, fx) => {
361
+ await fx.store(files).put("key", input.text);
362
+ return { ok: true };
363
+ },
364
+ }),
365
+ );
366
+ `;
367
+ const manifest = await extractFromSources({ "src/flows/attach.ts": source });
368
+
369
+ expect(manifest.flows?.["notes.attach"]?.effects?.writes).toEqual(["files:uploads"]);
370
+ });
371
+
372
+ test("fx.store(files).putImage(...) infers a write, unannotated", async () => {
373
+ const source = `
374
+ import { on, flow, http, gate, store } from "okengine";
375
+
376
+ export const files = store.files("uploads");
377
+
378
+ export const attach = on(
379
+ http.post("/attach").gate(gate.public),
380
+ flow({
381
+ name: "notes.attachImage",
382
+ unit: "notes",
383
+ do: async (input, fx) => {
384
+ await fx.store(files).putImage("key", input.bytes);
385
+ return { ok: true };
386
+ },
387
+ }),
388
+ );
389
+ `;
390
+ const manifest = await extractFromSources({ "src/flows/attach.ts": source });
391
+
392
+ expect(manifest.flows?.["notes.attachImage"]?.effects?.writes).toEqual(["files:uploads"]);
393
+ });
394
+
395
+ test("fx.store(files).list(...) infers a read, unannotated", async () => {
396
+ const source = `
397
+ import { on, flow, every, store } from "okengine";
398
+
399
+ export const files = store.files("uploads");
400
+
401
+ export const sweep = on(
402
+ every("1d"),
403
+ flow({
404
+ name: "notes.sweep",
405
+ unit: "notes",
406
+ do: async (_input, fx) => {
407
+ const keys = await fx.store(files).list();
408
+ return { count: keys.length };
409
+ },
410
+ }),
411
+ );
412
+ `;
413
+ const manifest = await extractFromSources({ "src/flows/sweep.ts": source });
414
+
415
+ expect(manifest.flows?.["notes.sweep"]?.effects?.reads).toEqual(["files:uploads"]);
416
+ });
417
+ });
418
+
419
+ describe("extractManifest — channel medium binder aliasing", () => {
420
+ test("mail.template(...) resolves through `const mail = channel.email(...)`", async () => {
421
+ const source = `
422
+ import { on, flow, http, gate, channel } from "okengine";
423
+
424
+ const mail = channel.email({ from: "Notes <notes@localhost>" });
425
+
426
+ export const noteCreatedMail = mail.template("note-created", {
427
+ locales: ["en", "ar"],
428
+ });
429
+
430
+ export const onCreated = on(
431
+ http.post("/hook").gate(gate.public),
432
+ flow({
433
+ name: "notes.onCreated",
434
+ unit: "notes",
435
+ do: async (payload, fx) => {
436
+ await fx.send(noteCreatedMail, { to: "you@localhost", data: payload });
437
+ },
438
+ }),
439
+ );
440
+ `;
441
+ const manifest = await extractFromSources({ "src/flows/notes.ts": source });
442
+
443
+ expect(manifest.flows?.["notes.onCreated"]?.effects?.sends).toEqual(["note-created"]);
444
+ expect(manifest.channels?.["note-created"]?.medium).toBe("email");
445
+ });
446
+
447
+ test("sms medium binder alias carries its medium (not defaulted to email)", async () => {
448
+ const source = `
449
+ import { on, flow, http, gate, channel } from "okengine";
450
+
451
+ const otp = channel.sms({});
452
+
453
+ export const otpTemplate = otp.template("otp-code", {});
454
+
455
+ export const send = on(
456
+ http.post("/otp").gate(gate.public),
457
+ flow({
458
+ name: "notes.sendOtp",
459
+ unit: "notes",
460
+ do: async (payload, fx) => {
461
+ await fx.send(otpTemplate, { to: "+10000000000", data: payload });
462
+ },
463
+ }),
464
+ );
465
+ `;
466
+ const manifest = await extractFromSources({ "src/flows/otp.ts": source });
467
+
468
+ expect(manifest.flows?.["notes.sendOtp"]?.effects?.sends).toEqual(["otp-code"]);
469
+ expect(manifest.channels?.["otp-code"]?.medium).toBe("sms");
470
+ });
471
+ });
@@ -14,6 +14,7 @@ import type {
14
14
  AiModel,
15
15
  AiPrompt,
16
16
  Channel,
17
+ ChannelMedium,
17
18
  Clock,
18
19
  DeclaredColumn,
19
20
  Effects,
@@ -94,6 +95,8 @@ interface ProjectScope {
94
95
  string,
95
96
  { storeName: string; storeRef: string; unit?: string; breaking?: boolean }
96
97
  >;
98
+ /** Local binding name → medium, for `const x = channel.email(…)` binders. */
99
+ channelMediumBindings: Map<string, ChannelMedium>;
97
100
  }
98
101
 
99
102
  /**
@@ -123,6 +126,7 @@ export async function extractManifest(options: ExtractManifestOptions = {}): Pro
123
126
  flows: {},
124
127
  flowExports: new Map(),
125
128
  resources: new Map(),
129
+ channelMediumBindings: new Map(),
126
130
  };
127
131
 
128
132
  const parsed = files.map((file) => {
@@ -329,7 +333,14 @@ function collectSchemaTable(call: CallExpression, program: AstNode, scope: Proje
329
333
  const entry = { name: tableName, columns };
330
334
  scope.schemaTables.set(tableName, entry);
331
335
  const bindingName = enclosingConstName(call, program);
332
- if (bindingName) scope.schemaTables.set(bindingName, entry);
336
+ if (bindingName) {
337
+ scope.schemaTables.set(bindingName, entry);
338
+ // Declared table name, not the JS binding — resolved by
339
+ // tableFromStoreChain so `const notesTable = store.schema.table("notes",
340
+ // …)` still infers "sql:notes", matching what the kernel reads off the
341
+ // live table object (see ../manifest/sql-resource.ts).
342
+ scope.bindings.set(bindingName, { kind: "table", ref: tableName });
343
+ }
333
344
  }
334
345
 
335
346
  function attachSchemaOption(
@@ -584,6 +595,8 @@ function camelToSnakeKey(key: string): string {
584
595
  .toLowerCase();
585
596
  }
586
597
 
598
+ const CHANNEL_MEDIUM_METHODS = new Set(["email", "sms", "whatsapp", "push"]);
599
+
587
600
  function visitDeclarationCall(call: CallExpression, program: AstNode, scope: ProjectScope): void {
588
601
  const callee = call.callee;
589
602
 
@@ -718,7 +731,17 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
718
731
  }
719
732
  }
720
733
 
721
- if (obj === "channel" && prop === "template") {
734
+ // channel.email(…) / .sms(…) / .whatsapp(…) / .push(…) — medium binder,
735
+ // usually held in a local const and called later as `mail.template(…)`.
736
+ if (obj === "channel" && prop && CHANNEL_MEDIUM_METHODS.has(prop)) {
737
+ const bindingName = enclosingConstName(call, program);
738
+ if (bindingName) {
739
+ scope.channelMediumBindings.set(bindingName, prop as ChannelMedium);
740
+ }
741
+ }
742
+
743
+ const boundMedium = obj ? scope.channelMediumBindings.get(obj) : undefined;
744
+ if ((obj === "channel" && prop === "template") || (prop === "template" && boundMedium)) {
722
745
  const templateName = stringArg(call.arguments[0]);
723
746
  const opts = objectArg(call.arguments[1]);
724
747
  if (templateName) {
@@ -726,7 +749,9 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
726
749
  const locales = stringArrayProp(opts, "locales");
727
750
  const description = stringProp(opts, "description");
728
751
  scope.channels[templateName] = {
729
- ...(medium ? { medium: medium as Channel["medium"] } : { medium: "email" }),
752
+ ...(medium
753
+ ? { medium: medium as Channel["medium"] }
754
+ : { medium: boundMedium ?? "email" }),
730
755
  ...(locales ? { locales } : {}),
731
756
  ...(description ? { description } : {}),
732
757
  };
@@ -43,4 +43,32 @@ describe("tier-1 cache — exact per-resource invalidation (path b)", () => {
43
43
  expect(cache.invalidateFromEffects({ writes: ["sql:links"] }).keys).toEqual(keys);
44
44
  expect(cache.get(keys[0]!)).toBeUndefined();
45
45
  });
46
+
47
+ test("write to table A does not invalidate a cached read of table B in the same sql store", () => {
48
+ // Two tables under one `store.sql("app", …)` — the exact shape Direction
49
+ // B's per-table kernel resolution (fx.ts `gatedTable`) now produces:
50
+ // `sql:notes` / `sql:orders`, not the old coarse `sql:app` for both.
51
+ // This precision was already correct in cache.ts before Direction B —
52
+ // it simply never received per-table refs to prove it with. Confirmed
53
+ // here with the exact naming the kernel now emits.
54
+ const cache = createStoreCache();
55
+ const notesRead: Effects = { reads: ["sql:notes"] };
56
+ const keys = tier1KeysForReads(notesRead);
57
+
58
+ cache.set({
59
+ tier: 1,
60
+ key: keys[0]!,
61
+ value: [{ id: "n1" }],
62
+ resources: ["sql:notes"],
63
+ expiresAt: null,
64
+ });
65
+
66
+ // A write to "orders" — a different table, same store — must not touch it.
67
+ expect(cache.invalidateFromEffects({ writes: ["sql:orders"] }).keys).toEqual([]);
68
+ expect(cache.get(keys[0]!)).toBeDefined();
69
+
70
+ // A write to "notes" itself still invalidates.
71
+ expect(cache.invalidateFromEffects({ writes: ["sql:notes"] }).keys).toEqual(keys);
72
+ expect(cache.get(keys[0]!)).toBeUndefined();
73
+ });
46
74
  });
package/src/kernel/app.ts CHANGED
@@ -162,6 +162,18 @@ export interface OkeOptions {
162
162
  readonly docker?: BootOptions["docker"];
163
163
  /** Optional `oke.config.ts` document consumed at boot. */
164
164
  readonly config?: BootOptions["config"];
165
+ /**
166
+ * Compiled Manifest for flows with no hand-declared `effects` — highest
167
+ * priority source for the boot-time capability stamp (see
168
+ * {@link BootOptions.manifest}).
169
+ */
170
+ readonly manifest?: BootOptions["manifest"];
171
+ /**
172
+ * Project root for a lazy, best-effort effects extraction when a flow has
173
+ * no hand-declared `effects` and no {@link manifest} was given (see
174
+ * {@link BootOptions.rootDir}).
175
+ */
176
+ readonly rootDir?: BootOptions["rootDir"];
165
177
  /** Pre-built element runtimes (skip construction at boot when present). */
166
178
  readonly elements?: BootOptions["elements"];
167
179
  /** Vault create options (chain / allowDevFallbacks / secrets). */
@@ -827,6 +839,8 @@ export function oke(options: OkeOptions): OkeApp {
827
839
  env: bootEnv,
828
840
  docker: overrides?.docker ?? options.docker,
829
841
  config: overrides?.config ?? options.config,
842
+ manifest: overrides?.manifest ?? options.manifest,
843
+ rootDir: overrides?.rootDir ?? options.rootDir,
830
844
  elements: overrides?.elements ?? options.elements,
831
845
  secrets: [...baseSecrets, ...pluginSecrets],
832
846
  vault: overrides?.vault ?? options.vault,
@@ -17,7 +17,10 @@
17
17
  * Element runtimes are loaded via `new URL("./boot-bind/"+name+".ts", …)` so
18
18
  * unused binders stay out of the `oke()` bundle (semantic tree-shaking).
19
19
  * Vault still loads whenever secrets are declared and lists every gap in one
20
- * failure; capability minting uses flow effect refs only — no element modules.
20
+ * failure; capability minting uses flow effect refs only — no element modules,
21
+ * except a lazy `extractManifest` import when a flow has no hand-declared
22
+ * `effects` and neither {@link BootOptions.manifest} nor
23
+ * {@link BootOptions.rootDir} / `OKE_ROOT_DIR` are set to derive one.
21
24
  *
22
25
  * The scheduler reads the effective state from the Store after reconciliation,
23
26
  * never the code directly (console §5).
@@ -38,8 +41,11 @@ import type {
38
41
  import type { JournalRuntime } from "./boot-bind/journal.ts";
39
42
  import type { CreateRunsRuntimeOptions, RunsRuntime } from "../runs/index.ts";
40
43
  import { createCapabilityToken, type CapabilityToken } from "./capability.ts";
44
+ import { throwOke } from "./errors.ts";
41
45
  import type { AnyFlowDef } from "./flow.ts";
42
46
  import type { Binding } from "./on.ts";
47
+ import type { Manifest } from "../manifest/types.ts";
48
+ import { emitBootWarn } from "../runtime/boot-warn.ts";
43
49
 
44
50
  /** Pre-built or partially-built element runtimes. */
45
51
  export interface ElementRuntimes {
@@ -104,6 +110,21 @@ export interface BootOptions {
104
110
  readonly bindings?: readonly Binding[];
105
111
  /** Flows known to the app (for capability minting). */
106
112
  readonly flows?: readonly AnyFlowDef[];
113
+ /**
114
+ * Compiled Manifest to derive capability tokens from for flows with no
115
+ * hand-declared `effects` (highest priority — build tooling / bundler
116
+ * import, never a filesystem read the kernel performs itself).
117
+ */
118
+ readonly manifest?: Manifest;
119
+ /**
120
+ * Project root for a lazy, best-effort `extractManifest` when a flow has
121
+ * no hand-declared `effects` and no {@link manifest} was given. Explicit
122
+ * opt-in only — never defaults to `process.cwd()` — so the existing test
123
+ * suite (hundreds of boots against synthetic flows, not a real source
124
+ * tree) never pays extraction cost. Falls back to `OKE_ROOT_DIR` when
125
+ * unset (the CLI sets this for `oke dev` / `oke start`).
126
+ */
127
+ readonly rootDir?: string;
107
128
  /**
108
129
  * Dispatch a cron / every interval when the scheduler fires.
109
130
  *
@@ -457,8 +478,11 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
457
478
  }
458
479
  }
459
480
 
460
- // 8. Caps — effect refs only; no element modules.
461
- const capabilities = mintCapabilities(options.flows ?? []);
481
+ // 8. Caps — effect refs only; no element modules (unless a lazy extract is needed).
482
+ const capabilities = await mintCapabilities(options.flows ?? [], env, {
483
+ manifest: options.manifest,
484
+ rootDir: options.rootDir,
485
+ });
462
486
 
463
487
  return {
464
488
  vault,
@@ -491,16 +515,90 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
491
515
  };
492
516
  }
493
517
 
518
+ /** Per-process guard so the "no effects" boot warning fires once, not per-flow. */
519
+ let noEffectsWarned = false;
520
+
521
+ /** Reset the once-per-process "no effects" warn latch (tests only). */
522
+ export function resetNoEffectsWarnForTests(): void {
523
+ noEffectsWarned = false;
524
+ }
525
+
526
+ /**
527
+ * Best-effort AoT extraction from `rootDir` — mirrors the CLI's own lazy,
528
+ * defensive `extractManifest` use (`oke dev`'s `tryLoadProjectManifest`).
529
+ * Never throws: a broken or mid-edit source tree just means "no Manifest
530
+ * available," handled by the caller like any other missing Manifest.
531
+ *
532
+ * `new URL` (not a literal specifier) so `oxc-parser` / the whole compiler
533
+ * never enters the bundler graph for apps that never hit this path — same
534
+ * trick as {@link loadBind} for the element binders.
535
+ *
536
+ * @param rootDir - Project root to extract from
537
+ */
538
+ async function tryAutoExtractManifest(rootDir: string): Promise<Manifest | undefined> {
539
+ try {
540
+ const url = new URL("../compiler/extract.ts", import.meta.url);
541
+ const { extractManifest } = (await import(url.href)) as typeof import("../compiler/extract.ts");
542
+ return await extractManifest({ rootDir });
543
+ } catch {
544
+ return undefined;
545
+ }
546
+ }
547
+
494
548
  /**
495
- * Mint capability tokens from each flow's declared effects.
496
- * Tokens come from the Manifest / flow contract never hand-passed sets.
549
+ * Mint capability tokens from each flow's declared effects, falling back to
550
+ * a Manifest-derived stamp when a flow declares no `effects` of its own —
551
+ * an explicit {@link BootOptions.manifest}, or a lazy AoT extract from
552
+ * {@link BootOptions.rootDir} / `OKE_ROOT_DIR`.
553
+ *
554
+ * When neither is available: `local` / `test` stay open (today's dev-loop
555
+ * behavior, unbroken) with a once-per-process `oke boot:` warning; `docker`
556
+ * / `prod` fail loud (`OKE1008`) — docker mirrors prod's posture, never a
557
+ * silent open door in a deploy-shaped environment.
497
558
  *
498
559
  * @param flows - Adopted flows
560
+ * @param env - Resolved {@link ConfigEnv}
561
+ * @param options - Manifest / rootDir sources for the fallback stamp
499
562
  */
500
- export function mintCapabilities(flows: readonly AnyFlowDef[]): Map<string, CapabilityToken> {
563
+ export async function mintCapabilities(
564
+ flows: readonly AnyFlowDef[],
565
+ env: ConfigEnv = "local",
566
+ options: { readonly manifest?: Manifest; readonly rootDir?: string } = {},
567
+ ): Promise<Map<string, CapabilityToken>> {
501
568
  const map = new Map<string, CapabilityToken>();
569
+
570
+ let manifest = options.manifest;
571
+ const needsManifest = manifest === undefined && flows.some((f) => f.effects === undefined);
572
+ if (needsManifest) {
573
+ const rootDir = options.rootDir ?? process.env["OKE_ROOT_DIR"];
574
+ if (rootDir) manifest = await tryAutoExtractManifest(rootDir);
575
+ }
576
+
502
577
  for (const f of flows) {
503
- map.set(f.name, createCapabilityToken(f.name, f.effects));
578
+ if (f.effects !== undefined) {
579
+ map.set(f.name, createCapabilityToken(f.name, f.effects));
580
+ continue;
581
+ }
582
+
583
+ const stamped = manifest?.flows?.[f.name]?.effects;
584
+ if (stamped !== undefined) {
585
+ map.set(f.name, createCapabilityToken(f.name, stamped));
586
+ continue;
587
+ }
588
+
589
+ if (env === "docker" || env === "prod") {
590
+ throwOke("NO_EFFECTS_DECLARED", { flow: f.name });
591
+ }
592
+ if (!noEffectsWarned) {
593
+ noEffectsWarned = true;
594
+ emitBootWarn(
595
+ `oke boot: flow "${f.name}" (and possibly others) has no declared effects and no ` +
596
+ "Manifest-derived effects — running with an OPEN capability token (every access " +
597
+ "allowed, ledgered but not gated). Run `oke build`, or boot with `manifest` / " +
598
+ "`rootDir`, before deploying — docker/prod refuse to boot this way.",
599
+ );
600
+ }
601
+ map.set(f.name, createCapabilityToken(f.name, undefined));
504
602
  }
505
603
  return map;
506
604
  }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Boot-level proof: compiled/inferred effects reaching a REAL running app's
3
+ * capability token — not an isolated compiler-only or kernel-only test.
4
+ *
5
+ * Before this file existed, `flow({...})` with no hand-declared `effects`
6
+ * always minted an OPEN capability token (every access allowed, no gate at
7
+ * all) in every environment, including `docker` / `prod` — `extractManifest`
8
+ * inference only ever fed the static `manifest.oke.json` artifact (Console,
9
+ * docs, publish), never the live boot path. Confirmed via real `oke()` boot
10
+ * + `app.fetch()`, not assumption.
11
+ *
12
+ * Also documents the sql:<table> (compiler inference) vs sql:<store-name>
13
+ * (kernel `gatedSqlHandle`) naming mismatch this stamping bridge surfaces:
14
+ * once inference actually reaches the runtime, a flow relying on it for a
15
+ * real `fx.store(db).insert(table)` write must not throw `UNDECLARED_WRITE`
16
+ * for touching the exact table it declared.
17
+ */
18
+
19
+ import { afterEach, describe, expect, test } from "bun:test";
20
+ import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { z } from "zod";
24
+ import { gate } from "../elements/gate.ts";
25
+ import { field, id, now, store } from "../elements/store.ts";
26
+ import { oke } from "./app.ts";
27
+ import { resetNoEffectsWarnForTests } from "./boot.ts";
28
+ import { flow, resetFlowSeq } from "./flow.ts";
29
+ import { on, resetBindings } from "./on.ts";
30
+ import { createTestApp } from "../test/create-test-app.ts";
31
+ import { http } from "./triggers.ts";
32
+
33
+ afterEach(() => {
34
+ resetBindings();
35
+ resetFlowSeq();
36
+ resetNoEffectsWarnForTests();
37
+ });
38
+
39
+ const notes = store.schema.table("notes", {
40
+ id: field.text().primaryKey().defaultFn(id),
41
+ title: field.text().notNull(),
42
+ createdAt: field.integer().notNull().defaultFn(now),
43
+ });
44
+
45
+ const CreateIn = z.object({ id: z.string(), title: z.string() });
46
+ const CreateOut = z.object({ ok: z.boolean() });
47
+
48
+ function buildUnannotatedCreateFlow(db: ReturnType<typeof store.sql>) {
49
+ return on(
50
+ http.post("/notes").gate(gate.public),
51
+ flow({
52
+ name: "notes.create",
53
+ in: CreateIn,
54
+ out: CreateOut,
55
+ // Deliberately no `effects` — the "let the compiler infer it" case.
56
+ do: async (input, fx) => {
57
+ await fx.store(db).insert(notes).values({ id: input.id, title: input.title, createdAt: 1 });
58
+ return { ok: true };
59
+ },
60
+ }),
61
+ );
62
+ }
63
+
64
+ describe("boot-level: undeclared-effects flow, no manifest / rootDir", () => {
65
+ test("local / test: open token — real insert succeeds (documented dev-loop fallback)", async () => {
66
+ resetBindings();
67
+ resetFlowSeq();
68
+ const db = store.sql("app", { schema: { notes } });
69
+ // `on(...)` (inside buildUnannotatedCreateFlow) must run BEFORE `oke(...)`
70
+ // is constructed — oke() synchronously drains the global on() registry
71
+ // at construction time; a binding created only as .adopt()'s argument
72
+ // (after oke() already ran) never gets wired to a route.
73
+ const create = buildUnannotatedCreateFlow(db);
74
+ const app = oke({ name: "stamp-open", gate: { policies: [gate.public] } }).adopt({ create });
75
+ Object.assign(app.$options, { stores: [db] });
76
+ await createTestApp(app); // createTestApp always boots env: "test"
77
+
78
+ const res = await app.fetch(
79
+ new Request("http://localhost/notes", {
80
+ method: "POST",
81
+ headers: { "content-type": "application/json" },
82
+ body: JSON.stringify({ id: "x1", title: "hi" }),
83
+ }),
84
+ );
85
+ expect(res.status).toBe(200);
86
+ });
87
+
88
+ test("docker: hard fail at boot — OKE1008, never an open token in a deploy-shaped env", async () => {
89
+ resetBindings();
90
+ resetFlowSeq();
91
+ const db = store.sql("app", { schema: { notes } });
92
+ const create = buildUnannotatedCreateFlow(db);
93
+ const app = oke({ name: "stamp-docker", gate: { policies: [gate.public] } }).adopt({ create });
94
+
95
+ await expect(
96
+ app.boot({ env: "docker", stores: [db], unguardedHttp: "allow", startScheduler: false }),
97
+ ).rejects.toThrow(/OKE1008/);
98
+ });
99
+
100
+ test("prod: hard fail at boot — same posture as docker", async () => {
101
+ resetBindings();
102
+ resetFlowSeq();
103
+ const db = store.sql("app", { schema: { notes } });
104
+ const create = buildUnannotatedCreateFlow(db);
105
+ const app = oke({ name: "stamp-prod", gate: { policies: [gate.public] } }).adopt({ create });
106
+
107
+ await expect(
108
+ app.boot({ env: "prod", stores: [db], unguardedHttp: "allow", startScheduler: false }),
109
+ ).rejects.toThrow(/OKE1008/);
110
+ });
111
+ });
112
+
113
+ describe("boot-level: table-ref resolution stays backward compatible", () => {
114
+ test("older store-level effects (sql:<store>) still cover a schema-table op — every existing template's convention", async () => {
115
+ resetBindings();
116
+ resetFlowSeq();
117
+ const db = store.sql("app", { schema: { notes } });
118
+ const create = on(
119
+ http.post("/notes").gate(gate.public),
120
+ flow({
121
+ name: "notes.create",
122
+ in: CreateIn,
123
+ out: CreateOut,
124
+ // The convention every template/test predates Direction B with —
125
+ // store-level, not table-level. Must keep working unchanged.
126
+ effects: { writes: ["sql:app"] },
127
+ do: async (input, fx) => {
128
+ await fx
129
+ .store(db)
130
+ .insert(notes)
131
+ .values({ id: input.id, title: input.title, createdAt: 1 });
132
+ return { ok: true };
133
+ },
134
+ }),
135
+ );
136
+ const app = oke({ name: "stamp-back-compat", gate: { policies: [gate.public] } }).adopt({
137
+ create,
138
+ });
139
+ Object.assign(app.$options, { stores: [db] });
140
+ await createTestApp(app);
141
+
142
+ const res = await app.fetch(
143
+ new Request("http://localhost/notes", {
144
+ method: "POST",
145
+ headers: { "content-type": "application/json" },
146
+ body: JSON.stringify({ id: "x1", title: "hi" }),
147
+ }),
148
+ );
149
+ expect(res.status).toBe(200);
150
+ });
151
+ });
152
+
153
+ describe("boot-level: undeclared-effects flow, rootDir stamping from a real source tree", () => {
154
+ test("real fx.store(db).insert(table) write succeeds once inference is stamped onto the capability token", async () => {
155
+ resetBindings();
156
+ resetFlowSeq();
157
+ const dir = await mkdtemp(join(tmpdir(), "oke-stamp-"));
158
+ try {
159
+ await mkdir(join(dir, "flows", "notes"), { recursive: true });
160
+ // A real source file with NO manual `effects:` — extractManifest infers
161
+ // `writes: ["sql:notes"]` for this from the real `fx.store(db).insert`
162
+ // call, exactly like the kernel test flow above.
163
+ await writeFile(
164
+ join(dir, "flows", "notes", "index.ts"),
165
+ `
166
+ import { on, flow, http, gate } from "okengine";
167
+ // Unresolved bindings are fine — extraction is AST-only, never executed.
168
+ // What matters is the literal shape: fx.store(db).insert(notes) so the
169
+ // same table-name inference that produced "sql:notes" earlier fires here.
170
+ const db = {} as any;
171
+ const notes = {} as any;
172
+ export const create = on(
173
+ http.post("/notes").gate(gate.public),
174
+ flow({
175
+ name: "notes.create",
176
+ do: async (input, fx) => {
177
+ await fx.store(db).insert(notes).values({ id: input.id, title: input.title, createdAt: 1 });
178
+ return { ok: true };
179
+ },
180
+ }),
181
+ );
182
+ `,
183
+ );
184
+
185
+ const db = store.sql("app", { schema: { notes } });
186
+ const create = buildUnannotatedCreateFlow(db);
187
+ const app = oke({ name: "stamp-rootdir", gate: { policies: [gate.public] } }).adopt({
188
+ create,
189
+ });
190
+ Object.assign(app.$options, { stores: [db] });
191
+ await createTestApp(app, { boot: { rootDir: dir } });
192
+
193
+ const res = await app.fetch(
194
+ new Request("http://localhost/notes", {
195
+ method: "POST",
196
+ headers: { "content-type": "application/json" },
197
+ body: JSON.stringify({ id: "x1", title: "hi" }),
198
+ }),
199
+ );
200
+ const body = (await res.json()) as { data: unknown; error: { message?: string } | null };
201
+ expect(res.status, body.error?.message ?? "").toBe(200);
202
+ expect(body.data).toEqual({ ok: true });
203
+ } finally {
204
+ await rm(dir, { recursive: true, force: true });
205
+ }
206
+ });
207
+ });
@@ -142,6 +142,17 @@ export const OKE_ERRORS = {
142
142
  cause: 'Flow "{flow}" calls "{resource}" without declaring it.',
143
143
  fix: 'Add "{resource}" to this flow\'s effects.calls.',
144
144
  },
145
+ /**
146
+ * Flow has no declared `effects` and no Manifest-derived effects were
147
+ * available to stamp at boot (docker / prod — never a silent open token).
148
+ */
149
+ NO_EFFECTS_DECLARED: {
150
+ code: 1008,
151
+ cause: 'Flow "{flow}" has no declared effects and no Manifest to derive them from.',
152
+ fix:
153
+ "Add explicit `effects` to this flow, or boot with a Manifest (`oke build`) / " +
154
+ "`rootDir` so effects can be derived. docker/prod refuse an open capability token.",
155
+ },
145
156
  /**
146
157
  * Emit target has no subscriber (unified-theory §21).
147
158
  * Thrown at emit when `optional` is false and nobody is subscribed.
package/src/kernel/fx.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { Effects, ResourceRef } from "../manifest/types.ts";
13
+ import { schemaTableName, sqlTableRef } from "../manifest/sql-resource.ts";
13
14
  import type {
14
15
  FilesStoreDecl,
15
16
  FilesStoreFxHandle,
@@ -852,6 +853,34 @@ export function createFxContext(options: CreateFxOptions): FxContext {
852
853
  );
853
854
  }
854
855
  };
856
+ /**
857
+ * Gate a table-scoped SQL operation. Prefers the precise `sql:<table>`
858
+ * ref (matches what the compiler's AST inference derives from the same
859
+ * call site — {@link "../manifest/sql-resource.ts"}); falls back to the
860
+ * store-level ref when the table ref isn't declared — every flow that
861
+ * hand-declared the older `effects: { writes: ["sql:<store>"] }`
862
+ * convention (every existing template, `upsert-app.test.ts`, …) must
863
+ * keep working unchanged. Ledger / journal record whichever ref the
864
+ * capability check actually matched, not always the coarser one.
865
+ *
866
+ * @param kind - Effect kind
867
+ * @param table - Table argument passed to a `SqlStoreHandle` method
868
+ * @param body - Work to run under the gate
869
+ */
870
+ const gatedTable = <T>(
871
+ kind: Parameters<CapabilityToken["assert"]>[0],
872
+ table: unknown,
873
+ body: () => T | Promise<T>,
874
+ ): Promise<T> => {
875
+ const name = schemaTableName(table);
876
+ if (name !== undefined) {
877
+ const perTable = sqlTableRef(name);
878
+ if (perTable !== ref && capability.allows(kind, perTable)) {
879
+ return gated(kind, perTable, body);
880
+ }
881
+ }
882
+ return gated(kind, ref, body);
883
+ };
855
884
 
856
885
  return {
857
886
  ref,
@@ -870,7 +899,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
870
899
  limit?: number;
871
900
  offset?: number;
872
901
  }): Promise<SqlRow[]> =>
873
- gated("read", ref, async () => {
902
+ gatedTable("read", table, async () => {
874
903
  const h = await ensure();
875
904
  const from = h.select(columns).from(table);
876
905
  const filtered = plan.where === undefined ? from : from.where(plan.where);
@@ -920,14 +949,14 @@ export function createFxContext(options: CreateFxOptions): FxContext {
920
949
  return {
921
950
  values(row) {
922
951
  const runExecute = () =>
923
- gated("write", ref, async () => {
952
+ gatedTable("write", table, async () => {
924
953
  refuseDryRunWrite();
925
954
  const h = await ensure();
926
955
  await h.insert(table).values(row).execute();
927
956
  });
928
957
  return {
929
958
  returning() {
930
- return gated("write", ref, async () => {
959
+ return gatedTable("write", table, async () => {
931
960
  refuseDryRunWrite();
932
961
  const h = await ensure();
933
962
  return h.insert(table).values(row).returning();
@@ -946,7 +975,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
946
975
  set(row) {
947
976
  return {
948
977
  where(where) {
949
- return gated("write", ref, async () => {
978
+ return gatedTable("write", table, async () => {
950
979
  refuseDryRunWrite();
951
980
  const h = await ensure();
952
981
  return h.update(table).set(row).where(where);
@@ -957,14 +986,14 @@ export function createFxContext(options: CreateFxOptions): FxContext {
957
986
  };
958
987
  },
959
988
  findById(table, id) {
960
- return gated("read", ref, async () => {
989
+ return gatedTable("read", table, async () => {
961
990
  const h = await ensure();
962
991
  return h.findById(table, id);
963
992
  });
964
993
  },
965
994
  delete(table: Parameters<SqlStoreHandle["delete"]>[0], id?: string) {
966
995
  if (id !== undefined) {
967
- return gated("write", ref, async () => {
996
+ return gatedTable("write", table, async () => {
968
997
  refuseDryRunWrite();
969
998
  const h = await ensure();
970
999
  return h.delete(table, id);
@@ -972,7 +1001,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
972
1001
  }
973
1002
  return {
974
1003
  where(where: unknown) {
975
- return gated("write", ref, async () => {
1004
+ return gatedTable("write", table, async () => {
976
1005
  refuseDryRunWrite();
977
1006
  const h = await ensure();
978
1007
  return h.delete(table).where(where);
@@ -981,20 +1010,20 @@ export function createFxContext(options: CreateFxOptions): FxContext {
981
1010
  };
982
1011
  },
983
1012
  exists(table, idOrWhere) {
984
- return gated("read", ref, async () => {
1013
+ return gatedTable("read", table, async () => {
985
1014
  const h = await ensure();
986
1015
  return h.exists(table, idOrWhere);
987
1016
  });
988
1017
  },
989
1018
  upsert(table, matchOn, values, upsertOptions) {
990
- return gated("write", ref, async () => {
1019
+ return gatedTable("write", table, async () => {
991
1020
  refuseDryRunWrite();
992
1021
  const h = await ensure();
993
1022
  return h.upsert(table, matchOn, values, upsertOptions);
994
1023
  });
995
1024
  },
996
1025
  increment(table, id, column, by) {
997
- return gated("write", ref, async () => {
1026
+ return gatedTable("write", table, async () => {
998
1027
  refuseDryRunWrite();
999
1028
  const h = await ensure();
1000
1029
  return h.increment(table, id, column, by);
@@ -1007,19 +1036,19 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1007
1036
  });
1008
1037
  },
1009
1038
  count(table, where) {
1010
- return gated("read", ref, async () => {
1039
+ return gatedTable("read", table, async () => {
1011
1040
  const h = await ensure();
1012
1041
  return h.count(table, where);
1013
1042
  });
1014
1043
  },
1015
1044
  page(table, pageOptions) {
1016
- return gated("read", ref, async () => {
1045
+ return gatedTable("read", table, async () => {
1017
1046
  const h = await ensure();
1018
1047
  return h.page(table, pageOptions);
1019
1048
  });
1020
1049
  },
1021
1050
  ensureTable(table) {
1022
- return gated("write", ref, async () => {
1051
+ return gatedTable("write", table, async () => {
1023
1052
  refuseDryRunWrite();
1024
1053
  const h = await ensure();
1025
1054
  return h.ensureTable(table);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * SQL resource-ref naming — the one place both the compiler's AST-based
3
+ * effect inference ({@link "../compiler/effects-infer.ts"}) and the
4
+ * kernel's capability gate ({@link "../kernel/fx.ts"} `gatedSqlHandle`)
5
+ * compute a per-table `sql:<table>` ref, so the two cannot silently
6
+ * drift apart again.
7
+ *
8
+ * Table-name *resolution* differs by nature — the compiler reads a static
9
+ * AST identifier (or, better, the declared name behind a
10
+ * `store.schema.table(name, …)` binding); the kernel reads the declared
11
+ * name straight off the live table object at call time. Only the
12
+ * naming/formatting step is shared here; that is the part a second,
13
+ * independently-written implementation could get wrong.
14
+ */
15
+
16
+ import type { ResourceRef } from "./types.ts";
17
+
18
+ /**
19
+ * The declared name a real `store.schema.table(name, columns)` object
20
+ * carries at runtime — the same string that was the first argument to
21
+ * `store.schema.table(...)` in source, regardless of what the JS binding
22
+ * is called. `undefined` for anything else (raw ORM tables, plain
23
+ * objects, missing/optional table arguments).
24
+ *
25
+ * @param value - Value passed where a table argument is expected
26
+ */
27
+ export function schemaTableName(value: unknown): string | undefined {
28
+ if (typeof value !== "object" || value === null) return undefined;
29
+ const v = value as { kind?: unknown; name?: unknown };
30
+ if (v.kind !== "schema-table") return undefined;
31
+ return typeof v.name === "string" ? v.name : undefined;
32
+ }
33
+
34
+ /**
35
+ * `sql:<table>` resource ref for a declared table name.
36
+ *
37
+ * @param table - Declared table name
38
+ */
39
+ export function sqlTableRef(table: string): ResourceRef {
40
+ return `sql:${table}` as ResourceRef;
41
+ }