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
@@ -15,10 +15,12 @@ import { createSqlStoreHandle } from "./sql-session.ts";
15
15
  function createTinyPoolClient(size = 2): PostgresClientLike & {
16
16
  readonly reserveCalls: { n: number };
17
17
  readonly rootBegins: { n: number };
18
+ readonly helperInstalls: { n: number };
18
19
  } {
19
20
  const slots = Array.from({ length: size }, () => ({ held: false }));
20
21
  const reserveCalls = { n: 0 };
21
22
  const rootBegins = { n: 0 };
23
+ const helperInstalls = { n: 0 };
22
24
 
23
25
  function checkout(): { held: boolean } {
24
26
  const idle = slots.find((slot) => !slot.held);
@@ -35,15 +37,18 @@ function createTinyPoolClient(size = 2): PostgresClientLike & {
35
37
  const client: PostgresClientLike & {
36
38
  readonly reserveCalls: { n: number };
37
39
  readonly rootBegins: { n: number };
40
+ readonly helperInstalls: { n: number };
38
41
  } = {
39
42
  reserveCalls,
40
43
  rootBegins,
44
+ helperInstalls,
41
45
  async reserve() {
42
46
  reserveCalls.n += 1;
43
47
  const slot = checkout();
44
48
  slot.held = true;
45
49
  return {
46
50
  async unsafe(sql) {
51
+ if (/CREATE OR REPLACE FUNCTION oke\.gate\(\)/.test(sql)) helperInstalls.n += 1;
47
52
  return rowsFor(sql);
48
53
  },
49
54
  release() {
@@ -53,6 +58,7 @@ function createTinyPoolClient(size = 2): PostgresClientLike & {
53
58
  },
54
59
  async unsafe(sql) {
55
60
  const slot = checkout();
61
+ if (/CREATE OR REPLACE FUNCTION oke\.gate\(\)/.test(sql)) helperInstalls.n += 1;
56
62
  const head = sql.trim().split(/\s+/)[0]?.toUpperCase();
57
63
  if (head === "BEGIN") {
58
64
  rootBegins.n += 1;
@@ -114,4 +120,25 @@ describe("sql-session RLS stamp on pooled postgres", () => {
114
120
  await expect(pool.unsafe("BEGIN")).resolves.toEqual([]);
115
121
  await expect(pool.unsafe("SET LOCAL ROLE oke_app")).rejects.toThrow("checkout timeout");
116
122
  });
123
+
124
+ test("concurrent identity bags on one connection install helpers exactly once (G8c fix)", async () => {
125
+ const pool = createTinyPoolClient(8);
126
+ const connection = await connectPostgres({ client: pool, role: "primary" });
127
+ // Fresh handles per identity — the pre-fix code installed per handle.
128
+ const mk = (userId: string) =>
129
+ createSqlStoreHandle("sql:app", {
130
+ connection,
131
+ classifications: new Map(),
132
+ routedRole: "primary",
133
+ domainDdl: "off",
134
+ rls: { gate: "member", userId, scopes: ["member"] },
135
+ });
136
+ const sessions = Array.from({ length: 6 }, (_, i) => mk(`user-${i}`));
137
+
138
+ await Promise.all(sessions.map((s) => s.raw(`SELECT id, owner FROM notes`)));
139
+
140
+ // One shared-conn install — concurrent CREATE OR REPLACE would otherwise
141
+ // race pg_proc (`tuple concurrently updated`).
142
+ expect(pool.helperInstalls.n).toBe(1);
143
+ });
117
144
  });
@@ -14,6 +14,7 @@ import {
14
14
  type RlsIdentity,
15
15
  } from "../../drivers/pg-rls.ts";
16
16
  import { throwOke } from "../../kernel/errors.ts";
17
+ import { AsyncLocalStorage } from "node:async_hooks";
17
18
  import { maskRows, tableFromSql } from "./classify.ts";
18
19
  import { isMissingDomainRelationError } from "./missing-relation.ts";
19
20
  import {
@@ -46,6 +47,109 @@ export type InferSelectRow<T> = T extends { readonly $inferSelect: infer R } ? R
46
47
  /** Serialize stamp frames on a shared connection so concurrent identities cannot interleave. */
47
48
  const rlsStampTails = new WeakMap<SqlConnection, Promise<unknown>>();
48
49
 
50
+ /**
51
+ * Runtime CDC sink — set once by the store runtime at boot. Write hooks call
52
+ * {@link notifySqlCdc} after committed DML; the sink feeds LiveQueryRuntime
53
+ * and (when a driver owns an outbox) durable multi-host delivery.
54
+ */
55
+ export interface SqlCdcSink {
56
+ /** Deliver one captured change. */
57
+ (event: {
58
+ readonly tableName: string;
59
+ readonly op: "insert" | "update" | "delete";
60
+ readonly before: Record<string, unknown> | null;
61
+ readonly after: Record<string, unknown> | null;
62
+ /** Echoed `X-Oke-Mutation-Id` when the write rode an HTTP request. */
63
+ readonly mutationId?: string;
64
+ }): void | Promise<void>;
65
+ }
66
+
67
+ let sqlCdcSink: SqlCdcSink | null = null;
68
+
69
+ /**
70
+ * Install the process-wide CDC sink. Idempotent — last install wins.
71
+ *
72
+ * @param sink - Store-runtime-owned delivery target
73
+ */
74
+ export function setSqlCdcSink(sink: SqlCdcSink | null): void {
75
+ sqlCdcSink = sink;
76
+ }
77
+
78
+ /**
79
+ * True when `sql` starts with INSERT/UPDATE/DELETE on a domain table
80
+ * (excludes stamp preludes, txn control, DDL — see {@link isRlsStampExemptSql}).
81
+ *
82
+ * @param sql - Statement text with any placeholder style
83
+ */
84
+ export function isCdcCandidateSql(sql: string): boolean {
85
+ return (
86
+ /^(insert|update|delete)\b/i.test(sql.trim()) && !/^(delete\s+from\s+oke_)/i.test(sql.trim())
87
+ );
88
+ }
89
+
90
+ /** Fire-and-forget CDC notification (never blocks or fails the write path). */
91
+ function notifySqlCdc(event: {
92
+ readonly tableName: string;
93
+ readonly op: "insert" | "update" | "delete";
94
+ readonly before: Record<string, unknown> | null;
95
+ readonly after: Record<string, unknown> | null;
96
+ }): void {
97
+ if (!sqlCdcSink) return;
98
+ const mutationId = currentCdcMutationId();
99
+ try {
100
+ const result = sqlCdcSink(mutationId !== undefined ? { ...event, mutationId } : event);
101
+ if (result instanceof Promise) void result.catch(() => undefined);
102
+ } catch {
103
+ // Telemetry must never break writes.
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Ambient mutation id for the current async chain — read from the
109
+ * `X-Oke-Mutation-Id` request header by the HTTP layer and echoed onto CDC
110
+ * events so clients can dedupe their own writes (Realtime optimistic race
111
+ * contract).
112
+ */
113
+ const cdcMutationStorage = new AsyncLocalStorage<{ readonly mutationId: string }>();
114
+
115
+ /**
116
+ * Read the ambient mutation id, or `undefined` outside a stamped request.
117
+ */
118
+ export function currentCdcMutationId(): string | undefined {
119
+ return cdcMutationStorage.getStore()?.mutationId;
120
+ }
121
+
122
+ /**
123
+ * Run `fn` with `mutationId` as the ambient CDC mutation id.
124
+ *
125
+ * @param mutationId - Client-supplied mutation correlation id
126
+ * @param fn - Flow execution whose store writes carry the id
127
+ */
128
+ export async function withCdcMutationId<T>(mutationId: string, fn: () => Promise<T>): Promise<T> {
129
+ return cdcMutationStorage.run({ mutationId }, fn);
130
+ }
131
+
132
+ /**
133
+ * One helper-installation per CONNECTION, not per session handle.
134
+ *
135
+ * Concurrent identity bags share `sharedSqlConn`; installing per handle races
136
+ * `CREATE OR REPLACE FUNCTION oke.*` on the same `pg_proc` tuple and fails
137
+ * with `tuple concurrently updated`. The install promise is shared so the
138
+ * first stamped op installs and everyone else awaits it.
139
+ */
140
+ const rlsHelperInstalls = new WeakMap<SqlConnection, Promise<void>>();
141
+
142
+ async function ensureOkeRlsHelpers(connection: SqlConnection): Promise<void> {
143
+ let installing = rlsHelperInstalls.get(connection);
144
+ if (!installing) {
145
+ installing = installOkeRlsHelpers((sql) => connection.exec(sql));
146
+ rlsHelperInstalls.set(connection, installing);
147
+ // Failed install must not poison the cache — allow a retry.
148
+ void installing.catch(() => rlsHelperInstalls.delete(connection));
149
+ }
150
+ await installing;
151
+ }
152
+
49
153
  /**
50
154
  * True when SQL must not enter an RLS identity frame (txn control or DDL).
51
155
  *
@@ -405,7 +509,6 @@ export function createSqlStoreHandle(
405
509
  ): SqlStoreHandle {
406
510
  const { connection, classifications, revealPii, rls } = options;
407
511
  const domainDdl: DomainDdlMode = options.domainDdl ?? "ensure";
408
- let helpersReady = false;
409
512
 
410
513
  function mask(rows: SqlRow[], table?: string): SqlRow[] {
411
514
  return maskRows(rows, { classifications, table, revealPii });
@@ -448,10 +551,7 @@ export function createSqlStoreHandle(
448
551
 
449
552
  async function applyRlsStamp<T>(fn: (conn: SqlConnection) => Promise<T>): Promise<T> {
450
553
  if (!rls) return fn(connection);
451
- if (!helpersReady) {
452
- await installOkeRlsHelpers((sql) => connection.exec(sql));
453
- helpersReady = true;
454
- }
554
+ await ensureOkeRlsHelpers(connection);
455
555
  const frame = async (tx: SqlConnection): Promise<T> => {
456
556
  for (const stmt of buildRlsIdentityPreludeSql(rls)) {
457
557
  await tx.exec(stmt.sql, stmt.params ?? []);
@@ -470,13 +570,12 @@ export function createSqlStoreHandle(
470
570
  if (cols.length === 0) return;
471
571
  const name = resolveTableName(table);
472
572
  const pk = cols.find((c) => c.primary)?.sqlName;
473
- // Postgres/PGLite INTEGER is 32-bit abstract `integer` (ms timestamps via
474
- // `now`) must map to BIGINT. SQLite INTEGER is flexible up to 64-bit.
475
- const pgWideInt = connection.driverId === "postgres" || connection.driverId === "pglite";
573
+ // ddlTypeOf already widens the int family to BIGINT; memory/SQLite accept
574
+ // it too (dynamic typing), so no per-driver remap is needed.
575
+ void connection.driverId;
476
576
  const colSql = cols
477
577
  .map((c) => {
478
- const base = pgWideInt && c.sqlType === "INTEGER" ? "BIGINT" : c.sqlType;
479
- const typ = pk && c.sqlName === pk ? `${base} PRIMARY KEY` : base;
578
+ const typ = pk && c.sqlName === pk ? `${c.sqlType} PRIMARY KEY` : c.sqlType;
480
579
  return `${quoteIdent(c.sqlName)} ${typ}`;
481
580
  })
482
581
  .join(", ");
@@ -513,6 +612,30 @@ export function createSqlStoreHandle(
513
612
  return compileWhere(where);
514
613
  }
515
614
 
615
+ /**
616
+ * Postgres `row_to_json` images come back as JSON strings on pglite and
617
+ * objects on Bun.SQL — normalize to the JS row mapping (snake→camel).
618
+ *
619
+ * @param table - Table handle for column remapping
620
+ * @param image - Raw RETURNING cell
621
+ */
622
+ function jsonImageToJs(table: TableHandle | unknown, image: unknown): SqlRow | null {
623
+ if (image === null || image === undefined) return null;
624
+ let obj: Record<string, unknown>;
625
+ if (typeof image === "string") {
626
+ try {
627
+ obj = JSON.parse(image) as Record<string, unknown>;
628
+ } catch {
629
+ return null;
630
+ }
631
+ } else if (typeof image === "object") {
632
+ obj = image as Record<string, unknown>;
633
+ } else {
634
+ return null;
635
+ }
636
+ return toJs(table, [obj])[0] ?? null;
637
+ }
638
+
516
639
  /** Accumulated select state across the fluent chain. */
517
640
  interface SelectPlan {
518
641
  readonly where?: unknown;
@@ -641,8 +764,14 @@ export function createSqlStoreHandle(
641
764
  const placeholders = cols.map(() => "?").join(", ");
642
765
  const colList = cols.map(quoteIdent).join(", ");
643
766
  const params = cols.map((c) => prepared[c]);
644
- const sql = `INSERT INTO ${quoteIdent(name)} (${colList}) VALUES (${placeholders})`;
645
- await exec(sql, params);
767
+ const sql = `INSERT INTO ${quoteIdent(name)} (${colList}) VALUES (${placeholders}) RETURNING *`;
768
+ const inserted = await query(sql, params);
769
+ notifySqlCdc({
770
+ tableName: name,
771
+ op: "insert",
772
+ before: null,
773
+ after: toJs(table, inserted)[0] ?? null,
774
+ });
646
775
  };
647
776
  return {
648
777
  async returning() {
@@ -654,6 +783,12 @@ export function createSqlStoreHandle(
654
783
  const params = cols.map((c) => prepared[c]);
655
784
  const sql = `INSERT INTO ${quoteIdent(name)} (${colList}) VALUES (${placeholders}) RETURNING *`;
656
785
  const rows = await query(sql, params);
786
+ notifySqlCdc({
787
+ tableName: name,
788
+ op: "insert",
789
+ before: null,
790
+ after: (toJs(table, rows)[0] as Record<string, unknown>) ?? null,
791
+ });
657
792
  return mask(toJs(table, rows), name);
658
793
  },
659
794
  execute: runExecute,
@@ -680,11 +815,53 @@ export function createSqlStoreHandle(
680
815
  if (!compiled.clause) {
681
816
  throw new Error("update().set().where(): condition required");
682
817
  }
683
- const params = [...setEntries.map(([, v]) => v), ...compiled.params];
818
+ const pk = resolvePkColumn(table);
819
+ const setParams = setEntries.map(([, v]) => v);
820
+
821
+ // Single-statement before capture (postgres / pglite): the
822
+ // `__oke_old` CTE snapshots matched pre-images FOR UPDATE,
823
+ // joins the UPDATE, and RETURNING emits both JSON images in
824
+ // one round-trip. Binding order follows marker appearance:
825
+ // CTE(where) params first, then SET values.
826
+ if (RLS_CONTEXT_DRIVERS.has(connection.driverId)) {
827
+ const sql =
828
+ `WITH __oke_old AS (` +
829
+ `SELECT * FROM ${quoteIdent(name)} WHERE ${compiled.clause} FOR UPDATE` +
830
+ `)` +
831
+ ` UPDATE ${quoteIdent(name)} SET ${setSql}` +
832
+ ` FROM __oke_old` +
833
+ ` WHERE ${quoteIdent(name)}.${quoteIdent(pk)} = __oke_old.${quoteIdent(pk)}` +
834
+ ` RETURNING row_to_json(__oke_old) AS __oke_before_data, row_to_json(${quoteIdent(name)}) AS __oke_after_data`;
835
+ const result = await query(sql, [...compiled.params, ...setParams]);
836
+ for (const rawRow of result) {
837
+ notifySqlCdc({
838
+ tableName: name,
839
+ op: "update",
840
+ before: jsonImageToJs(table, rawRow.__oke_before_data),
841
+ after: jsonImageToJs(table, rawRow.__oke_after_data),
842
+ });
843
+ }
844
+ return result.length;
845
+ }
846
+
847
+ // Fallback (memory): explicit pre-read then plain DML — the
848
+ // hook still emits true before-images, just via two statements.
849
+ const prior = await query(
850
+ `SELECT * FROM ${quoteIdent(name)} WHERE ${compiled.clause}`,
851
+ compiled.params,
852
+ );
684
853
  const result = await exec(
685
854
  `UPDATE ${quoteIdent(name)} SET ${setSql} WHERE ${compiled.clause}`,
686
- params,
855
+ [...setParams, ...compiled.params],
687
856
  );
857
+ for (const beforeRow of toJs(table, prior)) {
858
+ notifySqlCdc({
859
+ tableName: name,
860
+ op: "update",
861
+ before: beforeRow,
862
+ after: { ...beforeRow },
863
+ });
864
+ }
688
865
  return result.changes;
689
866
  },
690
867
  };
@@ -709,10 +886,19 @@ export function createSqlStoreHandle(
709
886
  await ensureFromMeta(table);
710
887
  const name = resolveTableName(table);
711
888
  const pk = resolvePkColumn(table);
712
- const result = await exec(`DELETE FROM ${quoteIdent(name)} WHERE ${quoteIdent(pk)} = ?`, [
713
- idValue,
714
- ]);
715
- return result.changes > 0;
889
+ // DELETE ... RETURNING * gives the full last image in one statement.
890
+ const rows = await query(
891
+ `DELETE FROM ${quoteIdent(name)} WHERE ${quoteIdent(pk)} = ? RETURNING *`,
892
+ [idValue],
893
+ );
894
+ if (rows.length === 0) return false;
895
+ notifySqlCdc({
896
+ tableName: name,
897
+ op: "delete",
898
+ before: toJs(table, rows)[0] ?? null,
899
+ after: null,
900
+ });
901
+ return true;
716
902
  })();
717
903
  }
718
904
  const builder: DeleteBuilder = {
@@ -723,11 +909,14 @@ export function createSqlStoreHandle(
723
909
  if (!compiled.clause) {
724
910
  throw new Error("delete().where(): condition required");
725
911
  }
726
- const result = await exec(
727
- `DELETE FROM ${quoteIdent(name)} WHERE ${compiled.clause}`,
912
+ const rows = await query(
913
+ `DELETE FROM ${quoteIdent(name)} WHERE ${compiled.clause} RETURNING *`,
728
914
  compiled.params,
729
915
  );
730
- return result.changes;
916
+ for (const beforeRow of toJs(table, rows)) {
917
+ notifySqlCdc({ tableName: name, op: "delete", before: beforeRow, after: null });
918
+ }
919
+ return rows.length;
731
920
  },
732
921
  };
733
922
  return builder;
@@ -773,7 +962,16 @@ export function createSqlStoreHandle(
773
962
  const placeholders = cols.map(() => "?").join(", ");
774
963
  const colList = cols.map(quoteIdent).join(", ");
775
964
  const params = cols.map((c) => prepared[c]);
776
- await exec(`INSERT INTO ${quoteIdent(name)} (${colList}) VALUES (${placeholders})`, params);
965
+ const rows = await query(
966
+ `INSERT INTO ${quoteIdent(name)} (${colList}) VALUES (${placeholders}) RETURNING *`,
967
+ params,
968
+ );
969
+ notifySqlCdc({
970
+ tableName: name,
971
+ op: "insert",
972
+ before: null,
973
+ after: toJs(table, rows)[0] ?? null,
974
+ });
777
975
  return { status: "upserted" as const };
778
976
  }
779
977
  if (upsertOptions?.onExisting !== "update") {
@@ -784,7 +982,45 @@ export function createSqlStoreHandle(
784
982
  if (setEntries.length === 0) return { status: "changed" as const };
785
983
  const setSql = setEntries.map(([col]) => `${quoteIdent(col)} = ?`).join(", ");
786
984
  const params = [...setEntries.map(([, v]) => v), ...compiled.params];
985
+ if (RLS_CONTEXT_DRIVERS.has(connection.driverId)) {
986
+ // Single-statement before/after capture — same shape as update().
987
+ // SET may include the PK (upsert keys) — exclude it from the UPDATE
988
+ // list to keep param order and row identity stable.
989
+ const pkCol = resolvePkColumn(table);
990
+ const setEntriesNoPk = setEntries.filter(([col]) => col !== pkCol);
991
+ const setSqlNoPk = setEntriesNoPk.map(([col]) => `${quoteIdent(col)} = ?`).join(", ");
992
+ const sql =
993
+ `WITH __oke_old AS (` +
994
+ `SELECT * FROM ${quoteIdent(name)} WHERE ${compiled.clause} FOR UPDATE` +
995
+ `)` +
996
+ ` UPDATE ${quoteIdent(name)} SET ${setSqlNoPk}` +
997
+ ` FROM __oke_old` +
998
+ ` WHERE ${quoteIdent(name)}.${quoteIdent(pkCol)} = __oke_old.${quoteIdent(pkCol)}` +
999
+ ` RETURNING row_to_json(__oke_old) AS __oke_before_data, row_to_json(${quoteIdent(name)}) AS __oke_after_data`;
1000
+ const result = await query(sql, [...compiled.params, ...setEntriesNoPk.map(([, v]) => v)]);
1001
+ for (const rawRow of result) {
1002
+ notifySqlCdc({
1003
+ tableName: name,
1004
+ op: "update",
1005
+ before: jsonImageToJs(table, rawRow.__oke_before_data),
1006
+ after: jsonImageToJs(table, rawRow.__oke_after_data),
1007
+ });
1008
+ }
1009
+ return { status: "changed" as const };
1010
+ }
1011
+ const prior = await query(
1012
+ `SELECT * FROM ${quoteIdent(name)} WHERE ${compiled.clause}`,
1013
+ compiled.params,
1014
+ );
787
1015
  await exec(`UPDATE ${quoteIdent(name)} SET ${setSql} WHERE ${compiled.clause}`, params);
1016
+ for (const beforeRow of toJs(table, prior)) {
1017
+ notifySqlCdc({
1018
+ tableName: name,
1019
+ op: "update",
1020
+ before: beforeRow,
1021
+ after: { ...beforeRow, ...values },
1022
+ });
1023
+ }
788
1024
  return { status: "changed" as const };
789
1025
  },
790
1026
 
@@ -796,6 +1032,38 @@ export function createSqlStoreHandle(
796
1032
  const colMeta = cols.find((c) => c.key === column || c.sqlName === column);
797
1033
  const sqlCol = colMeta?.sqlName ?? column;
798
1034
  const col = quoteIdent(sqlCol);
1035
+ if (RLS_CONTEXT_DRIVERS.has(connection.driverId)) {
1036
+ // Single statement: the CTE locks the row and carries only the PK
1037
+ // (projecting more would make the bare SET target ambiguous against
1038
+ // __oke_old's columns), UPDATE returns the after-image and new value
1039
+ // in one round-trip.
1040
+ const sql =
1041
+ `WITH __oke_old AS (` +
1042
+ `SELECT ${quoteIdent(pk)} FROM ${quoteIdent(name)} WHERE ${quoteIdent(pk)} = ? FOR UPDATE` +
1043
+ `)` +
1044
+ ` UPDATE ${quoteIdent(name)} SET ${col} = ${col} + ?` +
1045
+ ` FROM __oke_old` +
1046
+ ` WHERE ${quoteIdent(name)}.${quoteIdent(pk)} = __oke_old.${quoteIdent(pk)}` +
1047
+ ` RETURNING row_to_json(${quoteIdent(name)}) AS __oke_after_data, ${col}`;
1048
+ const result = await query(sql, [idValue, by]);
1049
+ const row = result[0];
1050
+ if (!row) {
1051
+ throw new Error(`increment(): no row with ${pk} ${JSON.stringify(idValue)} in ${name}`);
1052
+ }
1053
+ notifySqlCdc({
1054
+ tableName: name,
1055
+ op: "update",
1056
+ // No full pre-image in this single statement — the CTE projects
1057
+ // just the PK. The runtime classifies a numeric-only mutation as
1058
+ // an upsert event; revocation never needs this before-image.
1059
+ before: null,
1060
+ after: jsonImageToJs(table, row.__oke_after_data),
1061
+ });
1062
+ return asNumber(row[sqlCol], sqlCol);
1063
+ }
1064
+ const prior = await query(`SELECT * FROM ${quoteIdent(name)} WHERE ${quoteIdent(pk)} = ?`, [
1065
+ idValue,
1066
+ ]);
799
1067
  const rows = await query(
800
1068
  `UPDATE ${quoteIdent(name)} SET ${col} = ${col} + ? WHERE ${quoteIdent(pk)} = ? RETURNING ${col}`,
801
1069
  [by, idValue],
@@ -804,6 +1072,12 @@ export function createSqlStoreHandle(
804
1072
  if (!row) {
805
1073
  throw new Error(`increment(): no row with ${pk} ${JSON.stringify(idValue)} in ${name}`);
806
1074
  }
1075
+ notifySqlCdc({
1076
+ tableName: name,
1077
+ op: "update",
1078
+ before: toJs(table, prior)[0] ?? null,
1079
+ after: null,
1080
+ });
807
1081
  return asNumber(row[sqlCol], sqlCol);
808
1082
  },
809
1083
 
@@ -863,21 +1137,9 @@ export function createSqlStoreHandle(
863
1137
  async ensureTable(table: TableHandle) {
864
1138
  const cols = Object.values(table.columns);
865
1139
  const pk = resolvePkColumn(table);
866
- const intType =
867
- connection.driverId === "postgres" || connection.driverId === "pglite"
868
- ? "BIGINT"
869
- : "INTEGER";
870
1140
  const colSql = cols
871
1141
  .map((c) => {
872
- const typ =
873
- c.name === pk
874
- ? "TEXT PRIMARY KEY"
875
- : c.name === "clicks" ||
876
- c.name === "qty" ||
877
- c.name === "createdAt" ||
878
- c.name === "created_at"
879
- ? intType
880
- : "TEXT";
1142
+ const typ = c.name === pk ? "TEXT PRIMARY KEY" : "TEXT";
881
1143
  return `${quoteIdent(c.name)} ${typ}`;
882
1144
  })
883
1145
  .join(", ");
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ColumnClassification } from "../../manifest/types.ts";
9
+ import { okid } from "../../okid.ts";
9
10
 
10
11
  /** Column descriptor with optional classification. */
11
12
  export interface ColumnDef {
@@ -59,8 +60,22 @@ export interface ResolvedColumn {
59
60
  readonly sqlName: string;
60
61
  /** Whether this column is the primary key. */
61
62
  readonly primary: boolean;
62
- /** SQL type hint for DDL. */
63
- readonly sqlType: "TEXT" | "INTEGER" | "REAL" | "BLOB";
63
+ /** Unparameterized Postgres DDL type for auto-DDL (`CREATE TABLE`). */
64
+ readonly sqlType:
65
+ | "TEXT"
66
+ | "INTEGER"
67
+ | "BIGINT"
68
+ | "REAL"
69
+ | "DOUBLE PRECISION"
70
+ | "NUMERIC"
71
+ | "BOOLEAN"
72
+ | "JSONB"
73
+ | "BYTEA"
74
+ | "POINT"
75
+ | "LINE"
76
+ | "TIMESTAMP"
77
+ | "DATE"
78
+ | "BLOB";
64
79
  /** Optional `$defaultFn` from Drizzle. */
65
80
  readonly defaultFn?: () => unknown;
66
81
  }
@@ -119,6 +134,57 @@ export function isDrizzleTable(t: Record<string, unknown>): boolean {
119
134
  return ("$" in t || "_" in t) && !("columns" in t);
120
135
  }
121
136
 
137
+ /**
138
+ * Unparameterized Postgres DDL type for an abstract {@link FieldSqlType}.
139
+ *
140
+ * Mirrors each type's drizzle `getSQLType()` output minus parameters — the
141
+ * memory driver's `CREATE TABLE` parser comma-splits column defs, so
142
+ * `numeric(p, s)` parens are never emitted here. Precision/length remain
143
+ * migration-time concerns via drizzle-kit reading the emitted schema.
144
+ *
145
+ * @param sqlType - Declared SQL type primitive
146
+ */
147
+ export function ddlTypeOf(sqlType: string): ResolvedColumn["sqlType"] {
148
+ switch (sqlType) {
149
+ case "smallint":
150
+ case "smallserial":
151
+ return "INTEGER";
152
+ case "integer":
153
+ case "bigint":
154
+ case "serial":
155
+ case "bigserial":
156
+ // Abstract integer (ms clocks) and int8 family widen to BIGINT.
157
+ return "BIGINT";
158
+ case "real":
159
+ return "REAL";
160
+ case "doublePrecision":
161
+ return "DOUBLE PRECISION";
162
+ case "numeric":
163
+ return "NUMERIC";
164
+ case "boolean":
165
+ return "BOOLEAN";
166
+ case "json":
167
+ case "jsonb":
168
+ // jsonb chosen for indexability; auto-DDL is a dev-only convenience.
169
+ return "JSONB";
170
+ case "bytea":
171
+ return "BYTEA";
172
+ case "point":
173
+ return "POINT";
174
+ case "line":
175
+ return "LINE";
176
+ case "timestamp":
177
+ return "TIMESTAMP";
178
+ case "date":
179
+ return "DATE";
180
+ case "BLOB":
181
+ return "BLOB";
182
+ default:
183
+ // text · varchar · char · uuid · time · interval · inet/cidr/mac* — text-shaped.
184
+ return "TEXT";
185
+ }
186
+ }
187
+
122
188
  /**
123
189
  * Introspect columns from a TableHandle or Drizzle table.
124
190
  *
@@ -144,19 +210,11 @@ export function resolveColumns(table: unknown): ResolvedColumn[] {
144
210
  typeof col.primaryKey === "boolean"
145
211
  ? col.primaryKey
146
212
  : col.name === "id" || key === "id" || sqlName === "id";
147
- const sqlType =
148
- col.sqlType === "integer" || col.sqlType === "INTEGER"
149
- ? ("INTEGER" as const)
150
- : col.sqlType === "real" || col.sqlType === "REAL"
151
- ? ("REAL" as const)
152
- : col.sqlType === "blob" || col.sqlType === "BLOB"
153
- ? ("BLOB" as const)
154
- : ("TEXT" as const);
155
213
  return {
156
214
  key,
157
215
  sqlName,
158
216
  primary,
159
- sqlType,
217
+ sqlType: ddlTypeOf(col.sqlType ?? ""),
160
218
  ...(typeof col.defaultFn === "function" ? { defaultFn: col.defaultFn } : {}),
161
219
  };
162
220
  });
@@ -219,13 +277,19 @@ function drizzleSqlType(
219
277
  ): ResolvedColumn["sqlType"] {
220
278
  const ct = (columnType ?? "").toLowerCase();
221
279
  const dt = (dataType ?? "").toLowerCase();
222
- if (ct.includes("int") || dt.includes("int") || dt.includes("number")) {
223
- return "INTEGER";
224
- }
225
- if (ct.includes("real") || ct.includes("numeric") || dt.includes("float")) {
226
- return "REAL";
227
- }
228
- if (ct.includes("blob") || dt.includes("buffer")) return "BLOB";
280
+ if (ct.includes("bigint") || dt.includes("bigint")) return "BIGINT";
281
+ if (ct.includes("smallint")) return "INTEGER";
282
+ if (ct.includes("int") || dt.includes("int") || dt.includes("number")) return "INTEGER";
283
+ if (dt.includes("boolean") || ct.includes("bool")) return "BOOLEAN";
284
+ if (ct.includes("jsonb")) return "JSONB";
285
+ if (ct.includes("json")) return "JSONB";
286
+ if (ct.includes("bytea") || dt.includes("buffer")) return "BYTEA";
287
+ if (ct.includes("double precision")) return "DOUBLE PRECISION";
288
+ if (ct.includes("real") || dt.includes("float")) return "REAL";
289
+ if (ct.includes("numeric")) return "NUMERIC";
290
+ if (ct.includes("timestamp")) return "TIMESTAMP";
291
+ if (ct.startsWith("date")) return "DATE";
292
+ if (ct.includes("blob")) return "BLOB";
229
293
  return "TEXT";
230
294
  }
231
295
 
@@ -330,15 +394,32 @@ export function classificationsFromTable(table: TableHandle): Record<string, Col
330
394
  }
331
395
 
332
396
  /**
333
- * Generate a unique id (UUID). Suitable for `$defaultFn(id)` in schemas.
397
+ * Generate a unique id (21-char OKID, 126-bit entropy). Suitable for
398
+ * `$defaultFn(id)` in schemas.
334
399
  */
335
400
  export function id(): string {
336
- return crypto.randomUUID();
401
+ return okid();
337
402
  }
338
403
 
339
404
  /**
340
- * Current epoch-ms. Suitable for `$defaultFn(now)` in schemas.
405
+ * Current epoch-ms. Suitable for `$defaultFn(now)` in schemas (number columns).
341
406
  */
342
407
  export function now(): number {
343
408
  return Date.now();
344
409
  }
410
+
411
+ /**
412
+ * Current instant as an ISO-8601 string. The resolved default for `.now()` on
413
+ * string-typed temporal columns (`timestamp` / `date` in string mode).
414
+ */
415
+ export function nowIso(): string {
416
+ return new Date().toISOString();
417
+ }
418
+
419
+ /**
420
+ * Current instant as a `Date`. The resolved default for `.now()` on temporal
421
+ * columns declared with `{ mode: "date" }`.
422
+ */
423
+ export function nowDate(): Date {
424
+ return new Date();
425
+ }