okengine 0.17.2 → 0.18.4

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 (209) hide show
  1. package/AGENTS.md +5 -3
  2. package/README.md +14 -10
  3. package/manifest.v1.schema.json +61 -2
  4. package/package.json +19 -5
  5. package/site/content/docs/ai/index.mdx +1 -1
  6. package/site/content/docs/ai/mcp.mdx +10 -1
  7. package/site/content/docs/ai/skills.mdx +9 -9
  8. package/site/content/docs/elements/ai.mdx +1 -1
  9. package/site/content/docs/elements/clock.mdx +1 -1
  10. package/site/content/docs/elements/flow.mdx +25 -1
  11. package/site/content/docs/elements/gate.mdx +3 -2
  12. package/site/content/docs/elements/store.mdx +289 -341
  13. package/site/content/docs/elements/vault.mdx +5 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +3 -10
  15. package/site/content/docs/get-started/index.mdx +1 -1
  16. package/site/content/docs/get-started/installation.mdx +2 -3
  17. package/site/content/docs/get-started/introduction.mdx +58 -121
  18. package/site/content/docs/get-started/meta.json +9 -1
  19. package/site/content/docs/get-started/project-structure.mdx +4 -11
  20. package/site/content/docs/get-started/testing.mdx +328 -0
  21. package/site/content/docs/get-started/why.mdx +93 -71
  22. package/site/content/docs/index.mdx +44 -11
  23. package/site/content/docs/meta.json +8 -5
  24. package/site/content/docs/plugins/apple.mdx +151 -0
  25. package/site/content/docs/plugins/discord.mdx +139 -0
  26. package/site/content/docs/plugins/facebook.mdx +134 -0
  27. package/site/content/docs/plugins/figma.mdx +138 -0
  28. package/site/content/docs/plugins/github.mdx +138 -0
  29. package/site/content/docs/plugins/google.mdx +153 -0
  30. package/site/content/docs/plugins/index.mdx +47 -1
  31. package/site/content/docs/plugins/meta.json +10 -0
  32. package/site/content/docs/plugins/microsoft.mdx +151 -0
  33. package/site/content/docs/plugins/oauth.mdx +188 -0
  34. package/site/content/docs/plugins/x.mdx +125 -0
  35. package/site/content/docs/providers/index.mdx +2 -0
  36. package/site/content/docs/recipes/index.mdx +2 -0
  37. package/site/content/docs/reference/cli.md +3 -2
  38. package/site/content/docs/reference/client.mdx +58 -1
  39. package/site/content/docs/reference/configuration.mdx +2 -4
  40. package/site/content/docs/reference/fx.mdx +3 -1
  41. package/site/content/docs/reference/index.mdx +0 -5
  42. package/site/content/docs/reference/meta.json +2 -2
  43. package/site/content/docs/reference/okid.mdx +137 -0
  44. package/src/auth/bindings.ts +1 -1
  45. package/src/auth/config.ts +9 -0
  46. package/src/auth/identity-sql.ts +314 -0
  47. package/src/auth/identity.ts +140 -2
  48. package/src/auth/index.ts +17 -1
  49. package/src/auth/method-context.ts +3 -0
  50. package/src/auth/oauth-as/cimd.ts +132 -0
  51. package/src/auth/oauth-as/crypto.test.ts +101 -0
  52. package/src/auth/oauth-as/crypto.ts +393 -0
  53. package/src/auth/oauth-as/errors.ts +68 -0
  54. package/src/auth/oauth-as/http.test.ts +419 -0
  55. package/src/auth/oauth-as/http.ts +842 -0
  56. package/src/auth/oauth-as/stores.ts +61 -0
  57. package/src/auth/oauth-as/tables.ts +142 -0
  58. package/src/auth/tables.ts +0 -11
  59. package/src/bench/README.md +83 -0
  60. package/src/bench/REPORT.md +176 -0
  61. package/src/bench/g01-rls-stamp.bench.ts +194 -0
  62. package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
  63. package/src/bench/g03-signal-once.bench.ts +157 -0
  64. package/src/bench/g03-signal-reconnect.bench.ts +254 -0
  65. package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
  66. package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
  67. package/src/bench/g05-sustained-full.bench.ts +265 -0
  68. package/src/bench/g06-mixed-load.bench.ts +260 -0
  69. package/src/bench/g07-vault-crypto.bench.ts +100 -0
  70. package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
  71. package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
  72. package/src/bench/g08-store-kv-durable.bench.ts +133 -0
  73. package/src/bench/g08-store-sql.bench.ts +178 -0
  74. package/src/bench/g09-journal-sustained.bench.ts +203 -0
  75. package/src/bench/g10-observability-contention.bench.ts +246 -0
  76. package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
  77. package/src/bench/g13-elements.bench.ts +427 -0
  78. package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
  79. package/src/bench/g15-postgres-degradation.bench.ts +264 -0
  80. package/src/bench/g16-live-query-fanout.bench.ts +206 -0
  81. package/src/bench/lib/event-loop-lag.ts +26 -0
  82. package/src/bench/lib/infra.ts +60 -0
  83. package/src/bench/lib/report.ts +52 -0
  84. package/src/bench/lib/rss-sampler.ts +61 -0
  85. package/src/bench/lib/signal-pg.ts +88 -0
  86. package/src/bench/load-app.ts +337 -0
  87. package/src/bench/load-child.ts +108 -0
  88. package/src/bench/smoke.bench.ts +43 -0
  89. package/src/cli/competitor-mention-removal.test.ts +28 -0
  90. package/src/cli/doctor-fd.ts +117 -0
  91. package/src/cli/doctor.test.ts +192 -0
  92. package/src/cli/doctor.ts +129 -1
  93. package/src/client/create.ts +95 -1
  94. package/src/client/index.ts +9 -2
  95. package/src/client/transport.ts +11 -4
  96. package/src/client/use-live-query.ts +154 -0
  97. package/src/client-react/index.ts +15 -1
  98. package/src/client-react/live-resource.ts +246 -0
  99. package/src/client-react/use-live-query.test.ts +475 -0
  100. package/src/client-react/use-live-query.ts +530 -0
  101. package/src/compiler/extract.test.ts +518 -0
  102. package/src/compiler/extract.ts +386 -19
  103. package/src/console/server/invoke-user-flow.ts +2 -1
  104. package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-De7Lc2JC.js} +1 -1
  105. package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-RGy7VEA_.js} +1 -1
  106. package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-_rgpdVzo.js} +3 -3
  107. package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-Ds6pcnh-.js} +1 -1
  108. package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
  109. package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-4rHOePuE.js} +1 -1
  110. package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-DISPgxLM.js} +1 -1
  111. package/src/console/ui-next/dist/index.html +1 -1
  112. package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
  113. package/src/drivers/cdc-outbox.ts +389 -0
  114. package/src/drivers/memory.ts +20 -0
  115. package/src/drivers/oauth-apple.ts +156 -0
  116. package/src/drivers/oauth-discord.ts +79 -0
  117. package/src/drivers/oauth-facebook.ts +80 -0
  118. package/src/drivers/oauth-figma.ts +116 -0
  119. package/src/drivers/oauth-github.ts +92 -0
  120. package/src/drivers/oauth-google.ts +142 -0
  121. package/src/drivers/oauth-microsoft.ts +174 -0
  122. package/src/drivers/oauth-oidc.ts +293 -0
  123. package/src/drivers/oauth-shared.ts +326 -0
  124. package/src/drivers/oauth-types.ts +159 -0
  125. package/src/drivers/oauth-x.ts +77 -0
  126. package/src/drivers/oauth2-common.ts +95 -0
  127. package/src/drivers/oauth2-token.ts +61 -0
  128. package/src/drivers/pg-rls-row-passes.ts +251 -0
  129. package/src/drivers/pg-rls.ts +2 -0
  130. package/src/drivers/postgres.ts +45 -2
  131. package/src/drivers/signal-postgres.ts +2 -1
  132. package/src/elements/channel/runtime.ts +29 -2
  133. package/src/elements/channel.test.ts +52 -0
  134. package/src/elements/gate/boot.ts +29 -2
  135. package/src/elements/store/emit-drizzle.ts +147 -14
  136. package/src/elements/store/field-ddl.test.ts +118 -0
  137. package/src/elements/store/field-types.test.ts +455 -0
  138. package/src/elements/store/list-query.golden.json +777 -0
  139. package/src/elements/store/list-query.parity.test.ts +396 -0
  140. package/src/elements/store/list-query.ts +792 -0
  141. package/src/elements/store/live-default.test.ts +136 -0
  142. package/src/elements/store/live-http.test.ts +160 -0
  143. package/src/elements/store/live-isolation.test.ts +291 -0
  144. package/src/elements/store/live-query-runtime.test.ts +323 -0
  145. package/src/elements/store/live-query-runtime.ts +403 -0
  146. package/src/elements/store/live-query-server.test.ts +377 -0
  147. package/src/elements/store/live-query-server.ts +102 -0
  148. package/src/elements/store/live-query.ts +97 -0
  149. package/src/elements/store/resource.ts +189 -680
  150. package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
  151. package/src/elements/store/schema-decl.ts +539 -41
  152. package/src/elements/store/sql-rls-stamp.test.ts +27 -0
  153. package/src/elements/store/sql-session.ts +297 -35
  154. package/src/elements/store/table.ts +102 -21
  155. package/src/elements/store.test.ts +3 -1
  156. package/src/elements/store.ts +12 -1
  157. package/src/elements/vault/chaos-child.ts +74 -1
  158. package/src/elements/vault/chaos.test.ts +4 -2
  159. package/src/elements/vault/storage.ts +4 -2
  160. package/src/index.ts +5 -2
  161. package/src/kernel/app-auth.ts +1 -0
  162. package/src/kernel/app.ts +116 -2
  163. package/src/kernel/auth-sharing.test.ts +196 -0
  164. package/src/kernel/boot.test.ts +3 -3
  165. package/src/kernel/errors.ts +8 -0
  166. package/src/kernel/fx.test.ts +1 -0
  167. package/src/kernel/fx.ts +14 -2
  168. package/src/kernel/horizontal-child.ts +2 -1
  169. package/src/kernel/http-resource.ts +33 -7
  170. package/src/kernel/identity-host-persist.test.ts +119 -0
  171. package/src/kernel/instance-id.ts +4 -2
  172. package/src/kernel/journal.ts +2 -1
  173. package/src/kernel/mcp-tool.test.ts +95 -0
  174. package/src/kernel/on.ts +9 -0
  175. package/src/kernel/realtime-bind.ts +326 -0
  176. package/src/kernel/resource-live.ts +117 -0
  177. package/src/kernel/triggers.ts +86 -4
  178. package/src/manifest/diff.ts +37 -0
  179. package/src/manifest/types.ts +64 -2
  180. package/src/okid.bench.test.ts +64 -0
  181. package/src/okid.test.ts +338 -0
  182. package/src/okid.ts +245 -0
  183. package/src/plugins/anonymous.ts +19 -1
  184. package/src/plugins/auth/shared.ts +15 -0
  185. package/src/plugins/index.ts +2 -0
  186. package/src/plugins/magic-link.ts +10 -8
  187. package/src/plugins/mcp-oauth.ts +208 -0
  188. package/src/plugins/oauth/flow-store.ts +117 -0
  189. package/src/plugins/oauth/link.ts +69 -0
  190. package/src/plugins/oauth/shared.ts +108 -0
  191. package/src/plugins/oauth/token-vault.ts +100 -0
  192. package/src/plugins/oauth.security.test.ts +535 -0
  193. package/src/plugins/oauth.ts +532 -0
  194. package/src/plugins/otp.ts +48 -6
  195. package/src/plugins/passkey.ts +20 -1
  196. package/src/plugins/two-factor.ts +11 -0
  197. package/src/plugins/username.ts +40 -7
  198. package/src/release/build-lib.ts +7 -1
  199. package/src/release/measure.ts +1 -0
  200. package/src/release/official-plugins.ts +4 -1
  201. package/src/runs/collect.ts +2 -1
  202. package/src/runs/drivers/files.ts +2 -1
  203. package/src/test/create-test-app.ts +114 -5
  204. package/src/test/export-bundle.test.ts +33 -0
  205. package/src/test/live-signals.test.ts +83 -0
  206. package/src/test/tenant-isolation.test.ts +175 -0
  207. package/src/testing.ts +26 -0
  208. package/src/upgrade/codemods.ts +1 -1
  209. package/site/content/docs/reference/migrating-environments.mdx +0 -158
@@ -81,6 +81,8 @@ type DeclaredSchemaTable = {
81
81
  readonly rls?: boolean;
82
82
  readonly policies?: Record<string, TablePolicy>;
83
83
  readonly tenantScoped?: boolean;
84
+ /** `false` when `store.schema.live(false)` opted out (project flag on). */
85
+ readonly live?: boolean;
84
86
  };
85
87
 
86
88
  interface ProjectScope {
@@ -111,11 +113,30 @@ interface ProjectScope {
111
113
  i18n?: Manifest["i18n"];
112
114
  topology?: Manifest["topology"];
113
115
  images?: Record<string, string>;
116
+ /** Store-wide live defaulting — `oke({ store: { live: true } })`. */
117
+ store?: Manifest["store"];
114
118
  flows: Record<string, Flow>;
115
119
  /** Export name → flow id (for agent tools). */
116
120
  flowExports: Map<string, string>;
117
121
  /** Local binding name → store.resource declaration (for on(http.resource)). */
118
- resources: Map<string, { storeName: string; storeRef: string; breaking?: boolean }>;
122
+ resources: Map<
123
+ string,
124
+ {
125
+ storeName: string;
126
+ storeRef: string;
127
+ breaking?: boolean;
128
+ /** Physical table name (second argument or schema-table binding ref). */
129
+ tableName?: string;
130
+ /**
131
+ * Raw `live` option on `store.resource(…, { live })` — `undefined`
132
+ * when omitted. Resolved against the project-wide `store.live` flag
133
+ * and the table's `live: false` opt-out in pass 2.
134
+ */
135
+ liveOpt?: boolean;
136
+ /** `true` when the resource carries a live query surface. */
137
+ live?: boolean;
138
+ }
139
+ >;
119
140
  /** Local binding name → medium, for `const x = channel.email(…)` binders. */
120
141
  channelMediumBindings: Map<string, ChannelMedium>;
121
142
  }
@@ -209,12 +230,14 @@ export async function extractManifest(options: ExtractManifestOptions = {}): Pro
209
230
  if (Object.keys(journeys).length > 0) manifest.journeys = journeys;
210
231
  if (Object.keys(drivers).length > 0) manifest.drivers = drivers;
211
232
  if (scope.tenancy) manifest.tenancy = scope.tenancy;
233
+ if (scope.store && scope.store.live === true) manifest.store = { live: true };
212
234
  if (scope.i18n) manifest.i18n = scope.i18n;
213
235
  if (scope.topology) manifest.topology = scope.topology;
214
236
  if (scope.images) manifest.images = scope.images;
215
237
 
216
238
  applyTenancyDefaults(manifest);
217
239
  assertTenantSafeSchema(manifest);
240
+ applyLiveDefaults(manifest, scope);
218
241
 
219
242
  return manifest;
220
243
  }
@@ -271,6 +294,7 @@ function collectDeclarations(filePath: string, program: AstNode, scope: ProjectS
271
294
  const name = stringProp(opts, "name");
272
295
  if (name) scope.app = name;
273
296
  applyOkeTenantConfig(opts, scope);
297
+ applyOkeStoreLiveConfig(opts, scope);
274
298
  });
275
299
  }
276
300
 
@@ -470,23 +494,26 @@ function tableFromDeclared(t: DeclaredSchemaTable): {
470
494
  rls?: boolean;
471
495
  policies?: Record<string, TablePolicy>;
472
496
  tenantScoped?: boolean;
497
+ live?: boolean;
473
498
  } {
474
499
  return {
475
500
  columns: t.columns,
476
501
  ...(t.rls ? { rls: true } : {}),
477
502
  ...(t.policies ? { policies: t.policies } : {}),
478
503
  ...(t.tenantScoped === false ? { tenantScoped: false } : {}),
504
+ ...(t.live === false ? { live: false } : {}),
479
505
  };
480
506
  }
481
507
 
482
508
  function parseSchemaTableExtras(
483
509
  node: AstNode | undefined,
484
- ): Pick<DeclaredSchemaTable, "rls" | "policies" | "tenantScoped"> {
510
+ ): Pick<DeclaredSchemaTable, "rls" | "policies" | "tenantScoped" | "live"> {
485
511
  if (!node || node.type !== "ArrayExpression") return {};
486
512
  const els = (node as AstNode & { elements?: AstNode[] }).elements ?? [];
487
513
  const policies: Record<string, TablePolicy> = {};
488
514
  let rls = false;
489
515
  let tenantScoped: boolean | undefined;
516
+ let live: boolean | undefined;
490
517
  for (const el of els) {
491
518
  if (!el || el.type !== "CallExpression") continue;
492
519
  const extra = parseSchemaTableExtra(el as CallExpression);
@@ -499,6 +526,10 @@ function parseSchemaTableExtras(
499
526
  tenantScoped = false;
500
527
  continue;
501
528
  }
529
+ if (extra.kind === "live") {
530
+ live = false;
531
+ continue;
532
+ }
502
533
  policies[extra.name] = extra.policy;
503
534
  rls = true;
504
535
  }
@@ -506,6 +537,7 @@ function parseSchemaTableExtras(
506
537
  ...(rls ? { rls: true } : {}),
507
538
  ...(Object.keys(policies).length > 0 ? { policies } : {}),
508
539
  ...(tenantScoped === false ? { tenantScoped: false } : {}),
540
+ ...(live === false ? { live: false } : {}),
509
541
  };
510
542
  }
511
543
 
@@ -514,6 +546,7 @@ function parseSchemaTableExtra(
514
546
  ):
515
547
  | { kind: "rls" }
516
548
  | { kind: "unscoped" }
549
+ | { kind: "live" }
517
550
  | { kind: "policy"; name: string; policy: TablePolicy }
518
551
  | undefined {
519
552
  const callee = call.callee;
@@ -526,6 +559,9 @@ function parseSchemaTableExtra(
526
559
  if (method === "unscoped" && isStoreSchemaCallee(member.object)) {
527
560
  return { kind: "unscoped" };
528
561
  }
562
+ if (method === "live" && isStoreSchemaCallee(member.object)) {
563
+ return { kind: "live" };
564
+ }
529
565
  if (method === "policy" && isStoreSchemaCallee(member.object)) {
530
566
  const name = stringArg(call.arguments[0]);
531
567
  if (!name) return undefined;
@@ -610,19 +646,63 @@ function parseDeclaredColumns(obj: AstNode): Record<string, DeclaredColumn> {
610
646
  return out;
611
647
  }
612
648
 
649
+ /**
650
+ * Abstract `field.*` SQL type heads recognized by the compiler.
651
+ */
652
+ const FIELD_SQL_TYPES: ReadonlySet<string> = new Set([
653
+ "text",
654
+ "varchar",
655
+ "char",
656
+ "boolean",
657
+ "smallint",
658
+ "integer",
659
+ "bigint",
660
+ "serial",
661
+ "smallserial",
662
+ "bigserial",
663
+ "numeric",
664
+ "decimal",
665
+ "real",
666
+ "doublePrecision",
667
+ "json",
668
+ "jsonb",
669
+ "uuid",
670
+ "time",
671
+ "timestamp",
672
+ "date",
673
+ "interval",
674
+ "point",
675
+ "line",
676
+ "bytea",
677
+ "inet",
678
+ "cidr",
679
+ "macaddr",
680
+ "macaddr8",
681
+ ]);
682
+
683
+ /** Manifest primitive for a field head (`decimal` collapses into `numeric`). */
684
+ function manifestFieldType(head: string): DeclaredColumn["type"] {
685
+ return head === "decimal" ? "numeric" : (head as DeclaredColumn["type"]);
686
+ }
687
+
613
688
  /**
614
689
  * Walk `field.text().notNull().pii()` (and similar) into a DeclaredColumn.
615
690
  *
691
+ * Recognizes every {@link FIELD_SQL_TYPES} head plus the option bag passed to
692
+ * the factory call (`field.varchar({ length: 256 })`,
693
+ * `field.timestamp({ mode: "date", precision: 6 })`, …).
694
+ *
616
695
  * @param node - AST node for the column value
617
696
  * @param key - JS key (for default sqlName)
618
697
  */
619
698
  function parseFieldChain(node: AstNode, key: string): DeclaredColumn | undefined {
620
699
  const chain: string[] = [];
621
- let sqlType: "text" | "integer" | undefined;
700
+ let sqlType: DeclaredColumn["type"];
622
701
  let sqlName: string | undefined;
623
702
  let description: string | undefined;
624
703
  let defaultValue: string | number | boolean | null | undefined;
625
704
  let hasDefault = false;
705
+ let optionsNode: AstNode | undefined;
626
706
  let cur: AstNode | undefined = node;
627
707
 
628
708
  while (cur && cur.type === "CallExpression") {
@@ -639,7 +719,7 @@ function parseFieldChain(node: AstNode, key: string): DeclaredColumn | undefined
639
719
  if (method === "describe") {
640
720
  description = stringArg(call.arguments[0]) ?? description;
641
721
  }
642
- if (method === "defaultFn") {
722
+ if (method === "defaultFn" || method === "okid" || method === "now") {
643
723
  hasDefault = true;
644
724
  defaultValue = defaultValue ?? null;
645
725
  }
@@ -669,30 +749,29 @@ function parseFieldChain(node: AstNode, key: string): DeclaredColumn | undefined
669
749
  break;
670
750
  }
671
751
 
672
- // Remaining should be field.text / field.integer
752
+ // Remaining should be field.<type> bare MemberExpression or factory call
753
+ // with an options object (field.varchar({ length: 256 })).
673
754
  if (cur && cur.type === "MemberExpression") {
674
755
  const member = cur as AstNode & { object: AstNode; property: AstNode };
675
756
  if (identifierName(member.object) === "field") {
676
757
  const t = identifierName(member.property);
677
- if (t === "text" || t === "integer") sqlType = t;
758
+ if (t && FIELD_SQL_TYPES.has(t)) sqlType = manifestFieldType(t);
678
759
  }
679
760
  }
680
761
 
681
762
  // Also: field.text() ends as CallExpression with callee MemberExpression field.text
682
763
  if (!sqlType && node.type === "CallExpression") {
683
- // Re-walk to find field.text() / field.integer() as a call
684
764
  let probe: AstNode | undefined = node;
685
765
  while (probe && probe.type === "CallExpression") {
686
766
  const call = probe as CallExpression;
687
767
  const callee = call.callee;
688
768
  if (callee.type === "MemberExpression") {
689
769
  const member = callee as AstNode & { object: AstNode; property: AstNode };
690
- if (
691
- identifierName(member.object) === "field" &&
692
- (identifierName(member.property) === "text" ||
693
- identifierName(member.property) === "integer")
694
- ) {
695
- sqlType = identifierName(member.property) as "text" | "integer";
770
+ const head = identifierName(member.property);
771
+ if (identifierName(member.object) === "field" && head && FIELD_SQL_TYPES.has(head)) {
772
+ sqlType = manifestFieldType(head);
773
+ const arg0 = call.arguments[0];
774
+ if (arg0?.type === "ObjectExpression") optionsNode = arg0;
696
775
  break;
697
776
  }
698
777
  probe = member.object;
@@ -715,6 +794,7 @@ function parseFieldChain(node: AstNode, key: string): DeclaredColumn | undefined
715
794
  ...(description !== undefined ? { description } : {}),
716
795
  ...(methods.has("pii") ? { pii: true } : {}),
717
796
  ...(methods.has("sensitive") ? { sensitive: true } : {}),
797
+ ...parseFieldOptions(optionsNode),
718
798
  };
719
799
  if (methods.has("retain")) {
720
800
  // retain("7y") — find the call in the original chain by re-walk
@@ -780,6 +860,49 @@ function parseReferencesArg(
780
860
  return {};
781
861
  }
782
862
 
863
+ /**
864
+ * Extract per-type options (`length`, `precision`, `scale`, `withTimezone`,
865
+ * `mode`, `fields`, `enumValues`) from a factory call's options object.
866
+ *
867
+ * @param node - Options object AST node (may be undefined)
868
+ */
869
+ function parseFieldOptions(node: AstNode | undefined): Partial<DeclaredColumn> | undefined {
870
+ if (!node || node.type !== "ObjectExpression") return undefined;
871
+ const length = numberProp(node, "length");
872
+ const precision = numberProp(node, "precision");
873
+ const scale = numberProp(node, "scale");
874
+ const withTimezone = boolProp(node, "withTimezone");
875
+ const mode = stringProp(node, "mode");
876
+ const fields = stringProp(node, "fields");
877
+ const enumNode = objectProp(node, "enum");
878
+ const enumValues =
879
+ enumNode?.type === "ArrayExpression"
880
+ ? ((enumNode as AstNode & { elements?: AstNode[] }).elements ?? [])
881
+ .map((el) => stringArg(el))
882
+ .filter((v): v is string => v !== undefined)
883
+ : undefined;
884
+ if (
885
+ length === undefined &&
886
+ precision === undefined &&
887
+ scale === undefined &&
888
+ withTimezone === undefined &&
889
+ mode === undefined &&
890
+ fields === undefined &&
891
+ (enumValues === undefined || enumValues.length === 0)
892
+ ) {
893
+ return undefined;
894
+ }
895
+ return {
896
+ ...(length !== undefined ? { length } : {}),
897
+ ...(precision !== undefined ? { precision } : {}),
898
+ ...(scale !== undefined ? { scale } : {}),
899
+ ...(withTimezone !== undefined ? { withTimezone } : {}),
900
+ ...(mode !== undefined ? { mode } : {}),
901
+ ...(fields !== undefined ? { fields } : {}),
902
+ ...(enumValues !== undefined && enumValues.length > 0 ? { enumValues } : {}),
903
+ };
904
+ }
905
+
783
906
  function camelToSnakeKey(key: string): string {
784
907
  return key
785
908
  .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
@@ -852,11 +975,27 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
852
975
  const storeName = ref.split(":")[1] ?? dbName ?? "store";
853
976
  const opts = objectArg(call.arguments[2]);
854
977
  const breaking = opts ? boolProp(opts, "breaking") === true : false;
978
+ // Table name: the second argument may be a schema-table binding
979
+ // (resolve to its declared name) or a bare string literal.
980
+ const tableArgName = identifierName(call.arguments[1]);
981
+ const tableName =
982
+ (tableArgName
983
+ ? scope.bindings.get(tableArgName)?.kind === "table"
984
+ ? scope.bindings.get(tableArgName)?.ref
985
+ : undefined
986
+ : undefined) ?? stringArg(call.arguments[1]);
855
987
  if (bindingName) {
988
+ // Raw declaration: `{ live: true }` opts in, `{ live: false }`
989
+ // opts out of the project-wide default, omitted defers to the
990
+ // project flag resolved in pass 2 (scope/store live stamp complete
991
+ // and `schemaTables` populated regardless of file order).
992
+ const liveOpt = opts ? boolProp(opts, "live") : undefined;
856
993
  scope.resources.set(bindingName, {
857
994
  storeName,
858
995
  storeRef: ref,
859
996
  ...(breaking ? { breaking: true } : {}),
997
+ ...(tableName !== undefined ? { tableName } : {}),
998
+ ...(liveOpt !== undefined ? { liveOpt } : {}),
860
999
  });
861
1000
  }
862
1001
  }
@@ -1584,7 +1723,21 @@ function registerFlow(args: {
1584
1723
  flow.source = `${args.file.path}:${line}`;
1585
1724
 
1586
1725
  if (boolProp(opts, "durable")) flow.durable = true;
1587
- if (typeof liveFromTrigger === "string") flow.live = liveFromTrigger;
1726
+ if (typeof liveFromTrigger === "string") {
1727
+ flow.live = liveFromTrigger;
1728
+ // Manual live surface — `.live(tableBinding)` on the trigger. Synthesize
1729
+ // the internal signal exactly like `store.resource({ live: true })` so
1730
+ // the Manifest stays a faithful mirror of what booted, and enforce the
1731
+ // same guardrails (PK required; updatedAt / RLS warnings).
1732
+ const liveTable = trigger?.liveTable;
1733
+ if (liveTable !== undefined) {
1734
+ assertLiveGuardrails(args.scope, liveTable, `sql:${liveTable}`);
1735
+ args.scope.signals[`oke/live/sql:${liveTable}`] = {
1736
+ delivery: "live",
1737
+ description: `Live query CDC fan-out for ${liveTable} (synthesized by .live(table))`,
1738
+ };
1739
+ }
1740
+ }
1588
1741
  if (boolProp(opts, "breaking")) flow.breaking = true;
1589
1742
 
1590
1743
  const slo = parseSlo(objectProp(opts, "slo"));
@@ -1787,6 +1940,19 @@ function registerResourceMount(
1787
1940
  }
1788
1941
  const resource = baseName ? scope.resources.get(baseName) : undefined;
1789
1942
 
1943
+ // Resolve the table's live posture once: an explicit `{ live: false }`
1944
+ // opts out, an explicit `{ live: true }` opts in, and an omitted `live`
1945
+ // defers to the project-wide `store.live` flag unless the table itself
1946
+ // declared `store.schema.live(false)`. The raw option stays on the
1947
+ // resource; this pass-2 resolution relies on the fully-populated scope
1948
+ // (project flag stamped in pass 1, `schemaTables` regardless of file order).
1949
+ const resourceLive =
1950
+ resource?.liveOpt !== undefined
1951
+ ? resource.liveOpt
1952
+ : resource?.tableName !== undefined &&
1953
+ scope.store?.live === true &&
1954
+ scope.schemaTables.get(resource.tableName)?.live !== false;
1955
+
1790
1956
  const storeRef: ResourceRef | undefined =
1791
1957
  resource?.storeRef !== undefined ? (resource.storeRef as ResourceRef) : undefined;
1792
1958
  const effects: Effects | undefined = storeRef
@@ -1823,6 +1989,40 @@ function registerResourceMount(
1823
1989
  scope.bindings.set(name, { kind: "flow", ref: name });
1824
1990
  }
1825
1991
 
1992
+ // Live query surface — synthesize GET <path>/live + the internal signal
1993
+ // exactly like `store.resource({ live: true })` does at runtime so the
1994
+ // Manifest stays a faithful mirror of what booted. Gate is the resolved
1995
+ // `resourceLive` (explicit opt-in/opt-out, else the project-wide
1996
+ // `store.live` flag minus the table's `live: false` opt-out). Requires a
1997
+ // statically known table name; runtime `resolveTableName` throws for
1998
+ // anything else, so an unresolvable table never boots a live surface either.
1999
+ if (resourceLive === true && resource !== undefined) {
2000
+ const tableName = resource.tableName;
2001
+ if (tableName !== undefined) {
2002
+ const resourceStoreRef = resource.storeRef;
2003
+ assertLiveGuardrails(scope, tableName, resourceStoreRef);
2004
+ const signalName = `oke/live/sql:${tableName}`;
2005
+ scope.signals[signalName] = {
2006
+ delivery: "live",
2007
+ description: `Live query CDC fan-out for ${tableName} (synthesized by store.resource live)`,
2008
+ };
2009
+ const flowName = `_live_${tableName}`;
2010
+ scope.flows[flowName] = {
2011
+ trigger: { http: { method: "GET", path: `${path}/live` } },
2012
+ effects: {
2013
+ reads: [
2014
+ (resourceStoreRef ?? sqlTableRef(tableName)) as ResourceRef,
2015
+ `signal:${signalName}` as ResourceRef,
2016
+ ],
2017
+ },
2018
+ ...(gates && gates.length > 0 ? { gates } : {}),
2019
+ ...(resource.breaking ? { breaking: true } : {}),
2020
+ source: `${file.path}:${line}`,
2021
+ };
2022
+ scope.bindings.set(flowName, { kind: "flow", ref: flowName });
2023
+ }
2024
+ }
2025
+
1826
2026
  const exportName = enclosingConstName(onCall, program);
1827
2027
  if (exportName) {
1828
2028
  scope.bindings.set(exportName, { kind: "flow", ref: baseName ?? exportName });
@@ -1942,8 +2142,10 @@ function registerNamedTableCrud(call: CallExpression, file: SourceFile, scope: P
1942
2142
  interface ParsedTrigger {
1943
2143
  trigger: Trigger;
1944
2144
  gates?: string[];
1945
- /** Live signal name from `.live(signal)` / `http.live(signal)`. */
2145
+ /** Live signal name from `.live(signal)` / `http.live(signal)` / `.live(table)`. */
1946
2146
  live?: string;
2147
+ /** Physical table name when `.live(tableBinding)` — manual live surface. */
2148
+ liveTable?: string;
1947
2149
  }
1948
2150
 
1949
2151
  function parseTrigger(
@@ -1963,6 +2165,10 @@ function parseTrigger(
1963
2165
  return { trigger: {} };
1964
2166
  }
1965
2167
 
2168
+ // mcp.tool("bookings.create").gate(...) / mcp.tool(name)
2169
+ const mcpTool = parseMcpTrigger(call, scope);
2170
+ if (mcpTool) return mcpTool;
2171
+
1966
2172
  // http.post("/x").gate(...) / http.get("/x").live(signal) / http.live(signal)
1967
2173
  const http = parseHttpTrigger(call, scope, filePath);
1968
2174
  if (http) return http;
@@ -1977,6 +2183,8 @@ function parseTrigger(
1977
2183
  if (node.type === "MemberExpression") {
1978
2184
  const http = parseHttpTrigger(node, scope, filePath);
1979
2185
  if (http) return http;
2186
+ const mcp = parseMcpTrigger(node, scope);
2187
+ if (mcp) return mcp;
1980
2188
  }
1981
2189
 
1982
2190
  if (node.type === "Identifier") {
@@ -2004,6 +2212,19 @@ function resolveSignalName(node: AstNode | undefined, scope: ProjectScope): stri
2004
2212
  return id;
2005
2213
  }
2006
2214
 
2215
+ /**
2216
+ * Resolve a `.live(tableBinding)` argument — a `store.schema.table(...)`
2217
+ * (or `table(...)`) binding — to the internal live-signal name
2218
+ * `oke/live/sql:<table>`. Returns `undefined` for anything that is not a
2219
+ * table binding (signal handles resolve through {@link resolveSignalName}).
2220
+ */
2221
+ function resolveLiveTableName(node: AstNode | undefined, scope: ProjectScope): string | undefined {
2222
+ const id = identifierName(node);
2223
+ if (!id) return undefined;
2224
+ const binding = scope.bindings.get(id);
2225
+ return binding?.kind === "table" ? binding.ref : undefined;
2226
+ }
2227
+
2007
2228
  function parseHttpTrigger(
2008
2229
  leaf: AstNode,
2009
2230
  scope: ProjectScope,
@@ -2014,6 +2235,8 @@ function parseHttpTrigger(
2014
2235
  let method: string | undefined;
2015
2236
  let path: string | undefined;
2016
2237
  let live: string | undefined;
2238
+ /** Table name when `.live(tableBinding)` — a manual live query surface. */
2239
+ let liveTable: string | undefined;
2017
2240
  const gateNames: string[] = [];
2018
2241
 
2019
2242
  for (const node of chain) {
@@ -2028,10 +2251,20 @@ function parseHttpTrigger(
2028
2251
  if (obj.type === "Identifier" && (obj as Identifier).name === "http" && prop) {
2029
2252
  if (prop === "live") {
2030
2253
  method = "GET";
2031
- const signalName = resolveSignalName(c.arguments[0], scope);
2254
+ const tableName = resolveLiveTableName(c.arguments[0], scope);
2255
+ const signalName =
2256
+ tableName !== undefined
2257
+ ? `oke/live/sql:${tableName}`
2258
+ : resolveSignalName(c.arguments[0], scope);
2032
2259
  if (signalName) {
2033
2260
  live = signalName;
2034
- path = `/_oke/live/${encodeURIComponent(signalName)}`;
2261
+ if (tableName !== undefined) liveTable = tableName;
2262
+ // Keep any explicit GET path chained so far (e.g.
2263
+ // `http.get("/tasks/live").live(tasks)`); only synthesize the
2264
+ // firehose path for the bare `http.live(signal)` form.
2265
+ if (path === undefined) {
2266
+ path = `/_oke/live/${encodeURIComponent(signalName)}`;
2267
+ }
2035
2268
  }
2036
2269
  continue;
2037
2270
  }
@@ -2049,8 +2282,15 @@ function parseHttpTrigger(
2049
2282
 
2050
2283
  if (prop === "public") gateNames.push("public");
2051
2284
  if (prop === "live") {
2052
- const signalName = resolveSignalName(c.arguments[0], scope);
2053
- if (signalName) live = signalName;
2285
+ const tableName = resolveLiveTableName(c.arguments[0], scope);
2286
+ const signalName =
2287
+ tableName !== undefined
2288
+ ? `oke/live/sql:${tableName}`
2289
+ : resolveSignalName(c.arguments[0], scope);
2290
+ if (signalName) {
2291
+ live = signalName;
2292
+ if (tableName !== undefined) liveTable = tableName;
2293
+ }
2054
2294
  }
2055
2295
  }
2056
2296
  }
@@ -2082,6 +2322,7 @@ function parseHttpTrigger(
2082
2322
  },
2083
2323
  gates: gateNames.length > 0 ? gateNames : undefined,
2084
2324
  ...(live !== undefined ? { live } : {}),
2325
+ ...(liveTable !== undefined ? { liveTable } : {}),
2085
2326
  };
2086
2327
  }
2087
2328
 
@@ -2119,6 +2360,45 @@ function stripTriggerExtras(trigger: Trigger): Trigger {
2119
2360
  return trigger;
2120
2361
  }
2121
2362
 
2363
+ /**
2364
+ * `mcp.tool("name").gate(...)` — per-flow MCP tool opt-in. The Manifest
2365
+ * records `trigger.mcp: { name }` plus any chained gates; absence of the
2366
+ * trigger means the flow is not an MCP tool.
2367
+ */
2368
+ function parseMcpTrigger(leaf: AstNode, scope: ProjectScope): ParsedTrigger | undefined {
2369
+ const chain = flattenMemberCallChain(leaf);
2370
+ let toolName: string | undefined;
2371
+ const gateNames: string[] = [];
2372
+
2373
+ for (const node of chain) {
2374
+ if (node.type !== "CallExpression") continue;
2375
+ const c = node as CallExpression;
2376
+ const callee = c.callee;
2377
+ if (callee.type !== "MemberExpression") continue;
2378
+ const member = callee as AstNode & { object: AstNode; property: AstNode };
2379
+ const prop = identifierName(member.property);
2380
+ if (
2381
+ prop === "tool" &&
2382
+ member.object.type === "Identifier" &&
2383
+ (member.object as Identifier).name === "mcp"
2384
+ ) {
2385
+ toolName = stringArg(c.arguments[0]);
2386
+ continue;
2387
+ }
2388
+ if (prop === "gate") {
2389
+ for (const arg of c.arguments) {
2390
+ gateNames.push(...gateNamesFromArg(arg, scope));
2391
+ }
2392
+ }
2393
+ }
2394
+
2395
+ if (!toolName) return undefined;
2396
+ return {
2397
+ trigger: { mcp: { name: toolName } },
2398
+ gates: gateNames.length > 0 ? gateNames : undefined,
2399
+ };
2400
+ }
2401
+
2122
2402
  function parseEffectsObject(node: AstNode | undefined): Effects | undefined {
2123
2403
  if (!node) return undefined;
2124
2404
  const effects: Effects = {};
@@ -2493,6 +2773,93 @@ function applyTenancyDefaults(manifest: Manifest): void {
2493
2773
  }
2494
2774
  }
2495
2775
 
2776
+ /**
2777
+ * Stamp the project-wide store-live default from `oke({ store: { live: true } })`.
2778
+ *
2779
+ * Mirrors {@link applyOkeTenantConfig} — the proven "opt-in flag flips the
2780
+ * default posture for NEW declarations" pattern. Only `live: true` flips the
2781
+ * default; `false` / omitted leaves today's explicit-only behavior untouched.
2782
+ */
2783
+ function applyOkeStoreLiveConfig(opts: AstNode | undefined, scope: ProjectScope): void {
2784
+ const store = objectProp(opts, "store");
2785
+ const live = boolProp(store, "live");
2786
+ if (live !== true) return;
2787
+ scope.store = { ...scope.store, live: true };
2788
+ }
2789
+
2790
+ /**
2791
+ * Default every SQL table to live when `oke({ store: { live: true } })` is
2792
+ * on, unless the table explicitly opted out with `store.schema.live(false)`.
2793
+ *
2794
+ * The default flip is declaration ergonomics only: each live table is
2795
+ * recorded in the Manifest and its DX Pack A guardrails run through the exact
2796
+ * same {@link assertLiveGuardrails} path as an explicit `live: true` — no
2797
+ * separate, weaker code path. RLS-per-event fan-out cost is unchanged.
2798
+ */
2799
+ function applyLiveDefaults(manifest: Manifest, scope: ProjectScope): void {
2800
+ if (manifest.store?.live !== true) return;
2801
+ for (const [storeName, store] of Object.entries(manifest.stores ?? {})) {
2802
+ if (store.facet !== "sql" || !store.tables) continue;
2803
+ for (const [tableName, table] of Object.entries(store.tables)) {
2804
+ if (table.live === false) continue;
2805
+ table.live = true;
2806
+ const signalName = `oke/live/sql:${tableName}`;
2807
+ if (manifest.signals?.[signalName] === undefined) {
2808
+ manifest.signals = manifest.signals ?? {};
2809
+ manifest.signals[signalName] = {
2810
+ delivery: "live",
2811
+ description: `Live query CDC fan-out for ${tableName} (synthesized by store.live project default)`,
2812
+ };
2813
+ }
2814
+ assertLiveGuardrails(scope, tableName, `sql:${storeName}`);
2815
+ }
2816
+ }
2817
+ }
2818
+
2819
+ /**
2820
+ * DX Pack A guardrails for live query surfaces (Realtime Hardening plan) —
2821
+ * enforced identically for `store.resource({ live: true })` and manual
2822
+ * `.live(table)` flows:
2823
+ *
2824
+ * - **error** — table has no primary key column (upsert/revoked/delete are
2825
+ * PK-addressed; without one classification cannot address rows).
2826
+ * - **warn** — no `updatedAt`/`updated_at` timestamp column (`rowVersion`
2827
+ * LWW guard unavailable; optimistic races rely on `mutationId` only).
2828
+ * - **warn** — table carries no RLS policies (live without RLS is
2829
+ * operator-bypass territory — confirm intent).
2830
+ *
2831
+ * Column information lives on the declared schema table; when the compiler
2832
+ * could not resolve columns statically (bare string table), checks skip.
2833
+ *
2834
+ * @param scope - Project scope (declared tables + resolved drivers)
2835
+ * @param tableName - Physical SQL table name
2836
+ * @param storeRef - ResourceRef of the owning store (`sql:<name>`)
2837
+ */
2838
+ function assertLiveGuardrails(scope: ProjectScope, tableName: string, storeRef: string): void {
2839
+ const declared = scope.schemaTables.get(tableName);
2840
+ if (!declared) return;
2841
+ const columns = Object.values(declared.columns);
2842
+ if (columns.length > 0 && !columns.some((c) => c.primaryKey === true)) {
2843
+ throw new Error(
2844
+ `extract: live: true on table "${tableName}" requires a primary key column (upsert/revoked/delete address rows by PK)`,
2845
+ );
2846
+ }
2847
+ const hasUpdatedAt = columns.some((c) => c.sqlName === "updated_at" || c.sqlName === "updatedAt");
2848
+ if (columns.length > 0 && !hasUpdatedAt) {
2849
+ console.warn(
2850
+ `[oke extract] warn: live: true on "${tableName}" has no updatedAt/updated_at column — rowVersion LWW guard unavailable; optimistic races rely on mutationId`,
2851
+ );
2852
+ }
2853
+ if (
2854
+ (declared.rls !== true || Object.keys(declared.policies ?? {}).length === 0) &&
2855
+ storeRef.startsWith("sql:")
2856
+ ) {
2857
+ console.warn(
2858
+ `[oke extract] warn: live: true on "${tableName}" without RLS policies — every gated subscriber sees every row; confirm this is intended`,
2859
+ );
2860
+ }
2861
+ }
2862
+
2496
2863
  function assertTenantSafeSchema(manifest: Manifest): void {
2497
2864
  if (manifest.tenancy?.enabled !== true) return;
2498
2865
  for (const [storeName, store] of Object.entries(manifest.stores ?? {})) {
@@ -16,6 +16,7 @@ import { isFlowFailure } from "../../kernel/hooks.ts";
16
16
  import type { Trigger } from "../../kernel/triggers.ts";
17
17
  import type { RlsIdentity } from "../../drivers/pg-rls.ts";
18
18
  import { resolveRlsIdentity } from "../../elements/store.ts";
19
+ import { okid } from "../../okid.ts";
19
20
  import type { Manifest } from "../../manifest/types.ts";
20
21
  import type { ConsoleIdentity } from "./state.ts";
21
22
 
@@ -361,7 +362,7 @@ export function bindHostInvokeUserFlow(app: InvokeHostApp): ConsoleInvokeUserFlo
361
362
  ...(input.body !== undefined ? { body: input.body } : {}),
362
363
  });
363
364
 
364
- const runId = crypto.randomUUID();
365
+ const runId = okid();
365
366
  const result = await app.execute(flowDef, assembled, trigger, {
366
367
  trustedInvoke: true,
367
368
  runId,