okengine 0.17.2 → 0.18.3

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 (197) hide show
  1. package/README.md +9 -5
  2. package/manifest.v1.schema.json +61 -2
  3. package/package.json +18 -4
  4. package/site/content/docs/elements/clock.mdx +1 -1
  5. package/site/content/docs/elements/flow.mdx +25 -1
  6. package/site/content/docs/elements/store.mdx +287 -341
  7. package/site/content/docs/elements/vault.mdx +5 -5
  8. package/site/content/docs/get-started/installation.mdx +1 -2
  9. package/site/content/docs/get-started/introduction.mdx +54 -110
  10. package/site/content/docs/get-started/meta.json +9 -1
  11. package/site/content/docs/get-started/testing.mdx +328 -0
  12. package/site/content/docs/get-started/why.mdx +94 -70
  13. package/site/content/docs/index.mdx +1 -1
  14. package/site/content/docs/plugins/apple.mdx +151 -0
  15. package/site/content/docs/plugins/discord.mdx +139 -0
  16. package/site/content/docs/plugins/facebook.mdx +134 -0
  17. package/site/content/docs/plugins/figma.mdx +138 -0
  18. package/site/content/docs/plugins/github.mdx +138 -0
  19. package/site/content/docs/plugins/google.mdx +153 -0
  20. package/site/content/docs/plugins/index.mdx +47 -1
  21. package/site/content/docs/plugins/meta.json +10 -0
  22. package/site/content/docs/plugins/microsoft.mdx +151 -0
  23. package/site/content/docs/plugins/oauth.mdx +188 -0
  24. package/site/content/docs/plugins/x.mdx +125 -0
  25. package/site/content/docs/reference/cli.md +3 -2
  26. package/site/content/docs/reference/client.mdx +58 -1
  27. package/site/content/docs/reference/configuration.mdx +2 -4
  28. package/site/content/docs/reference/fx.mdx +3 -1
  29. package/site/content/docs/reference/index.mdx +0 -5
  30. package/site/content/docs/reference/meta.json +2 -2
  31. package/site/content/docs/reference/okid.mdx +137 -0
  32. package/src/auth/bindings.ts +1 -1
  33. package/src/auth/config.ts +9 -0
  34. package/src/auth/identity-sql.ts +314 -0
  35. package/src/auth/identity.ts +140 -2
  36. package/src/auth/index.ts +17 -1
  37. package/src/auth/method-context.ts +3 -0
  38. package/src/auth/oauth-as/cimd.ts +132 -0
  39. package/src/auth/oauth-as/crypto.test.ts +101 -0
  40. package/src/auth/oauth-as/crypto.ts +393 -0
  41. package/src/auth/oauth-as/errors.ts +68 -0
  42. package/src/auth/oauth-as/http.test.ts +419 -0
  43. package/src/auth/oauth-as/http.ts +842 -0
  44. package/src/auth/oauth-as/stores.ts +61 -0
  45. package/src/auth/oauth-as/tables.ts +142 -0
  46. package/src/auth/tables.ts +0 -11
  47. package/src/bench/README.md +83 -0
  48. package/src/bench/REPORT.md +176 -0
  49. package/src/bench/g01-rls-stamp.bench.ts +194 -0
  50. package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
  51. package/src/bench/g03-signal-once.bench.ts +157 -0
  52. package/src/bench/g03-signal-reconnect.bench.ts +254 -0
  53. package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
  54. package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
  55. package/src/bench/g05-sustained-full.bench.ts +265 -0
  56. package/src/bench/g06-mixed-load.bench.ts +260 -0
  57. package/src/bench/g07-vault-crypto.bench.ts +100 -0
  58. package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
  59. package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
  60. package/src/bench/g08-store-kv-durable.bench.ts +133 -0
  61. package/src/bench/g08-store-sql.bench.ts +178 -0
  62. package/src/bench/g09-journal-sustained.bench.ts +203 -0
  63. package/src/bench/g10-observability-contention.bench.ts +246 -0
  64. package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
  65. package/src/bench/g13-elements.bench.ts +427 -0
  66. package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
  67. package/src/bench/g15-postgres-degradation.bench.ts +264 -0
  68. package/src/bench/g16-live-query-fanout.bench.ts +206 -0
  69. package/src/bench/lib/event-loop-lag.ts +26 -0
  70. package/src/bench/lib/infra.ts +60 -0
  71. package/src/bench/lib/report.ts +52 -0
  72. package/src/bench/lib/rss-sampler.ts +61 -0
  73. package/src/bench/lib/signal-pg.ts +88 -0
  74. package/src/bench/load-app.ts +337 -0
  75. package/src/bench/load-child.ts +108 -0
  76. package/src/bench/smoke.bench.ts +43 -0
  77. package/src/cli/competitor-mention-removal.test.ts +28 -0
  78. package/src/cli/doctor-fd.ts +117 -0
  79. package/src/cli/doctor.test.ts +192 -0
  80. package/src/cli/doctor.ts +129 -1
  81. package/src/client/create.ts +95 -1
  82. package/src/client/index.ts +9 -2
  83. package/src/client/transport.ts +11 -4
  84. package/src/client/use-live-query.ts +154 -0
  85. package/src/client-react/index.ts +15 -1
  86. package/src/client-react/live-resource.ts +246 -0
  87. package/src/client-react/use-live-query.test.ts +475 -0
  88. package/src/client-react/use-live-query.ts +530 -0
  89. package/src/compiler/extract.test.ts +518 -0
  90. package/src/compiler/extract.ts +386 -19
  91. package/src/console/server/invoke-user-flow.ts +2 -1
  92. package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
  94. package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
  95. package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
  96. package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
  97. package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
  98. package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
  99. package/src/console/ui-next/dist/index.html +1 -1
  100. package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
  101. package/src/drivers/cdc-outbox.ts +389 -0
  102. package/src/drivers/memory.ts +20 -0
  103. package/src/drivers/oauth-apple.ts +156 -0
  104. package/src/drivers/oauth-discord.ts +79 -0
  105. package/src/drivers/oauth-facebook.ts +80 -0
  106. package/src/drivers/oauth-figma.ts +116 -0
  107. package/src/drivers/oauth-github.ts +92 -0
  108. package/src/drivers/oauth-google.ts +142 -0
  109. package/src/drivers/oauth-microsoft.ts +174 -0
  110. package/src/drivers/oauth-oidc.ts +293 -0
  111. package/src/drivers/oauth-shared.ts +326 -0
  112. package/src/drivers/oauth-types.ts +159 -0
  113. package/src/drivers/oauth-x.ts +77 -0
  114. package/src/drivers/oauth2-common.ts +95 -0
  115. package/src/drivers/oauth2-token.ts +61 -0
  116. package/src/drivers/pg-rls-row-passes.ts +251 -0
  117. package/src/drivers/pg-rls.ts +2 -0
  118. package/src/drivers/postgres.ts +45 -2
  119. package/src/drivers/signal-postgres.ts +2 -1
  120. package/src/elements/channel/runtime.ts +29 -2
  121. package/src/elements/channel.test.ts +52 -0
  122. package/src/elements/gate/boot.ts +29 -2
  123. package/src/elements/store/emit-drizzle.ts +147 -14
  124. package/src/elements/store/field-ddl.test.ts +118 -0
  125. package/src/elements/store/field-types.test.ts +455 -0
  126. package/src/elements/store/list-query.golden.json +777 -0
  127. package/src/elements/store/list-query.parity.test.ts +396 -0
  128. package/src/elements/store/list-query.ts +792 -0
  129. package/src/elements/store/live-default.test.ts +136 -0
  130. package/src/elements/store/live-http.test.ts +160 -0
  131. package/src/elements/store/live-isolation.test.ts +291 -0
  132. package/src/elements/store/live-query-runtime.test.ts +323 -0
  133. package/src/elements/store/live-query-runtime.ts +403 -0
  134. package/src/elements/store/live-query-server.test.ts +377 -0
  135. package/src/elements/store/live-query-server.ts +102 -0
  136. package/src/elements/store/live-query.ts +97 -0
  137. package/src/elements/store/resource.ts +189 -680
  138. package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
  139. package/src/elements/store/schema-decl.ts +539 -41
  140. package/src/elements/store/sql-rls-stamp.test.ts +27 -0
  141. package/src/elements/store/sql-session.ts +297 -35
  142. package/src/elements/store/table.ts +102 -21
  143. package/src/elements/store.test.ts +3 -1
  144. package/src/elements/store.ts +12 -1
  145. package/src/elements/vault/chaos-child.ts +74 -1
  146. package/src/elements/vault/chaos.test.ts +4 -2
  147. package/src/elements/vault/storage.ts +4 -2
  148. package/src/index.ts +4 -1
  149. package/src/kernel/app-auth.ts +1 -0
  150. package/src/kernel/app.ts +116 -2
  151. package/src/kernel/auth-sharing.test.ts +196 -0
  152. package/src/kernel/boot.test.ts +3 -3
  153. package/src/kernel/errors.ts +8 -0
  154. package/src/kernel/fx.test.ts +1 -0
  155. package/src/kernel/fx.ts +14 -2
  156. package/src/kernel/horizontal-child.ts +2 -1
  157. package/src/kernel/http-resource.ts +33 -7
  158. package/src/kernel/identity-host-persist.test.ts +119 -0
  159. package/src/kernel/instance-id.ts +4 -2
  160. package/src/kernel/journal.ts +2 -1
  161. package/src/kernel/mcp-tool.test.ts +95 -0
  162. package/src/kernel/on.ts +9 -0
  163. package/src/kernel/realtime-bind.ts +326 -0
  164. package/src/kernel/resource-live.ts +117 -0
  165. package/src/kernel/triggers.ts +86 -4
  166. package/src/manifest/diff.ts +37 -0
  167. package/src/manifest/types.ts +64 -2
  168. package/src/okid.bench.test.ts +64 -0
  169. package/src/okid.test.ts +338 -0
  170. package/src/okid.ts +245 -0
  171. package/src/plugins/anonymous.ts +19 -1
  172. package/src/plugins/auth/shared.ts +15 -0
  173. package/src/plugins/index.ts +2 -0
  174. package/src/plugins/magic-link.ts +10 -8
  175. package/src/plugins/mcp-oauth.ts +208 -0
  176. package/src/plugins/oauth/flow-store.ts +117 -0
  177. package/src/plugins/oauth/link.ts +69 -0
  178. package/src/plugins/oauth/shared.ts +108 -0
  179. package/src/plugins/oauth/token-vault.ts +100 -0
  180. package/src/plugins/oauth.security.test.ts +535 -0
  181. package/src/plugins/oauth.ts +532 -0
  182. package/src/plugins/otp.ts +48 -6
  183. package/src/plugins/passkey.ts +20 -1
  184. package/src/plugins/two-factor.ts +11 -0
  185. package/src/plugins/username.ts +40 -7
  186. package/src/release/build-lib.ts +7 -1
  187. package/src/release/measure.ts +1 -0
  188. package/src/release/official-plugins.ts +4 -1
  189. package/src/runs/collect.ts +2 -1
  190. package/src/runs/drivers/files.ts +2 -1
  191. package/src/test/create-test-app.ts +114 -5
  192. package/src/test/export-bundle.test.ts +33 -0
  193. package/src/test/live-signals.test.ts +83 -0
  194. package/src/test/tenant-isolation.test.ts +175 -0
  195. package/src/testing.ts +26 -0
  196. package/src/upgrade/codemods.ts +1 -1
  197. package/site/content/docs/reference/migrating-environments.mdx +0 -158
@@ -0,0 +1,389 @@
1
+ /**
2
+ * `oke_cdc_outbox` — durable, multi-host CDC.
3
+ *
4
+ * Same transactional-outbox physics as the Signal / Journal drivers:
5
+ * writes land in the SAME transaction as the DML (`sql-session` hook),
6
+ * a SKIP LOCKED poller drains rows to in-process subscribers (LiveQuery
7
+ * runtime + user-declared CDC flows). Retention mirrors Signal's
8
+ * `maxAge` / `maxCount` discipline — this is not the one table without it.
9
+ */
10
+
11
+ /** Wire shape of one row drained from the outbox. */
12
+ export interface OutboxRow {
13
+ readonly id: string;
14
+ readonly seq: number;
15
+ readonly tableName: string;
16
+ readonly op: "insert" | "update" | "delete";
17
+ readonly before: Record<string, unknown> | null;
18
+ readonly after: Record<string, unknown> | null;
19
+ /** Echoed `X-Oke-Mutation-Id` (client dedupe), when the write carried one. */
20
+ readonly mutationId?: string;
21
+ readonly createdAt: number;
22
+ }
23
+
24
+ /** Retention caps applied by the poller between claims. */
25
+ export interface OutboxRetention {
26
+ /** Drop delivered rows older than this many ms. */
27
+ readonly maxAgeMs?: number;
28
+ /** Keep at most this many delivered rows (newest survive). */
29
+ readonly maxCount?: number;
30
+ }
31
+
32
+ /** Default `maxAgeMs` — delivered rows pruned after 24h (Signal live-tape parity). */
33
+ export const OUTBOX_DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
34
+
35
+ /** Default `maxCount` — cap total delivered rows retained (never touches pending). */
36
+ export const OUTBOX_DEFAULT_MAX_COUNT = 50_000;
37
+
38
+ /**
39
+ * Fill omitted retention fields with the {@link OUTBOX_DEFAULT_MAX_AGE_MS} /
40
+ * {@link OUTBOX_DEFAULT_MAX_COUNT} defaults so the outbox is never the one
41
+ * durable table without pruning discipline.
42
+ *
43
+ * @param retention - Caller caps (fields omit → default)
44
+ */
45
+ export function resolveOutboxRetention(retention: OutboxRetention = {}): Required<OutboxRetention> {
46
+ return {
47
+ maxAgeMs: retention.maxAgeMs ?? OUTBOX_DEFAULT_MAX_AGE_MS,
48
+ maxCount: retention.maxCount ?? OUTBOX_DEFAULT_MAX_COUNT,
49
+ };
50
+ }
51
+
52
+ /** Postgres DDL for the outbox. */
53
+ export const OKE_CDC_OUTBOX_DDL = `
54
+ CREATE TABLE IF NOT EXISTS oke_cdc_outbox (
55
+ id text PRIMARY KEY,
56
+ seq bigint GENERATED ALWAYS AS IDENTITY,
57
+ table_name text NOT NULL,
58
+ op text NOT NULL,
59
+ before_data jsonb,
60
+ after_data jsonb,
61
+ mutation_id text,
62
+ created_at bigint NOT NULL,
63
+ claimed_at bigint,
64
+ delivered_at bigint
65
+ );
66
+ CREATE INDEX IF NOT EXISTS oke_cdc_outbox_pending_idx
67
+ ON oke_cdc_outbox (seq) WHERE delivered_at IS NULL;
68
+ CREATE INDEX IF NOT EXISTS oke_cdc_outbox_delivered_created_idx
69
+ ON oke_cdc_outbox (created_at) WHERE delivered_at IS NOT NULL;
70
+ `;
71
+
72
+ /**
73
+ * Driver-agnostic outbox on a {@link SqlConnection}.
74
+ *
75
+ * Works on pglite and pooled postgres — every claim runs inside a
76
+ * transaction so row locks are held exactly for mark-as-delivered.
77
+ */
78
+ export class CdcOutbox {
79
+ private readonly conn: {
80
+ query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
81
+ exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
82
+ transaction?<T>(fn: (tx: unknown) => Promise<T>): Promise<T>;
83
+ };
84
+ private readonly retention: Required<OutboxRetention>;
85
+ private readonly now: () => number;
86
+ private ensuring?: Promise<void>;
87
+
88
+ constructor(
89
+ conn: {
90
+ query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
91
+ exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
92
+ transaction?<T>(fn: (tx: unknown) => Promise<T>): Promise<T>;
93
+ },
94
+ retention: OutboxRetention = {},
95
+ now: () => number = () => Date.now(),
96
+ ) {
97
+ this.conn = conn;
98
+ this.retention = resolveOutboxRetention(retention);
99
+ this.now = now;
100
+ }
101
+
102
+ /** Idempotent DDL ensure — first write or poll creates the table. */
103
+ async ensure(): Promise<void> {
104
+ if (!this.ensuring) {
105
+ this.ensuring = (async () => {
106
+ for (const stmt of OKE_CDC_OUTBOX_DDL.split(";").filter((s) => s.trim())) {
107
+ await this.conn.exec(stmt.trim());
108
+ }
109
+ })().catch((err) => {
110
+ this.ensuring = undefined;
111
+ throw err;
112
+ });
113
+ }
114
+ await this.ensuring;
115
+ }
116
+
117
+ /**
118
+ * Enqueue one change. Call from inside the WRITE transaction so the event
119
+ * commits atomically with the data change.
120
+ *
121
+ * @param entry - Table/op/images; ids minted when absent
122
+ */
123
+ async append(entry: {
124
+ readonly id?: string;
125
+ readonly tableName: string;
126
+ readonly op: "insert" | "update" | "delete";
127
+ readonly before?: Record<string, unknown> | null;
128
+ readonly after?: Record<string, unknown> | null;
129
+ readonly mutationId?: string;
130
+ }): Promise<void> {
131
+ await this.ensure();
132
+ const { okid } = await import("../okid.ts");
133
+ await this.conn.exec(
134
+ `INSERT INTO oke_cdc_outbox (id, table_name, op, before_data, after_data, mutation_id, created_at)
135
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
136
+ [
137
+ entry.id ?? okid(),
138
+ entry.tableName,
139
+ entry.op,
140
+ entry.before === null || entry.before === undefined ? null : JSON.stringify(entry.before),
141
+ entry.after === null || entry.after === undefined ? null : JSON.stringify(entry.after),
142
+ entry.mutationId ?? null,
143
+ this.now(),
144
+ ],
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Outbox backlog gauges for metrics/doctor: total pending (undelivered)
150
+ * rows and delivered rows retained beyond the `maxCount` cap.
151
+ *
152
+ * @returns `{ pending, dispatchedOverCap }` row counts
153
+ */
154
+ async stats(): Promise<{ pending: number; dispatchedOverCap: number }> {
155
+ await this.ensure();
156
+ const rows = await this.conn.query(
157
+ `SELECT
158
+ COUNT(*) FILTER (WHERE delivered_at IS NULL) AS pending,
159
+ GREATEST(
160
+ COUNT(*) FILTER (WHERE delivered_at IS NOT NULL) - ?,
161
+ 0
162
+ ) AS dispatched_over_cap
163
+ FROM oke_cdc_outbox`,
164
+ [this.retention.maxCount],
165
+ );
166
+ const row = rows[0];
167
+ return {
168
+ pending: row === undefined ? 0 : Number(row.pending ?? 0),
169
+ dispatchedOverCap: row === undefined ? 0 : Number(row.dispatched_over_cap ?? 0),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Claim up to `limit` undelivered rows with SKIP LOCKED semantics, hand
175
+ * them to `handler`, and mark them delivered after it resolves.
176
+ * A crash between claim and mark redelivers (at-least-once) rather than
177
+ * loses; a handler throw leaves rows pending and returns [] — callers
178
+ * poll again on the next tick.
179
+ *
180
+ * @param handler - Batch consumer; throw to leave rows pending-redelivery
181
+ * @param limit - Max rows per batch
182
+ * @returns Rows handed to the handler this tick (delivered)
183
+ */
184
+ async pollOnce(
185
+ handler: (rows: readonly OutboxRow[]) => Promise<void>,
186
+ limit = 200,
187
+ ): Promise<readonly OutboxRow[]> {
188
+ await this.ensure();
189
+ try {
190
+ if (!this.conn.transaction) {
191
+ const claimed = await this.claimInTx(this.conn as never, limit);
192
+ if (claimed.length > 0) {
193
+ await handler(claimed);
194
+ await this.markDelivered(claimed);
195
+ }
196
+ return claimed;
197
+ }
198
+ let claimed: OutboxRow[] = [];
199
+ await this.conn.transaction(async (tx) => {
200
+ claimed = await this.claimInTx(tx as never, limit);
201
+ });
202
+ if (claimed.length > 0) {
203
+ await handler(claimed);
204
+ await this.markDelivered(claimed);
205
+ }
206
+ return claimed;
207
+ } catch {
208
+ // SKIP LOCKED under concurrent contention can serialize; a transient
209
+ // failure leaves rows unclaimed and the next tick retries.
210
+ return [];
211
+ }
212
+ }
213
+
214
+ /** Internal claim inside an open transaction. */
215
+ private async claimInTx(
216
+ tx: {
217
+ query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
218
+ exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
219
+ },
220
+ limit: number,
221
+ ): Promise<OutboxRow[]> {
222
+ const rows = await tx.query(
223
+ `SELECT id, seq, table_name, op, before_data, after_data, mutation_id, created_at
224
+ FROM oke_cdc_outbox
225
+ WHERE delivered_at IS NULL
226
+ AND (claimed_at IS NULL OR claimed_at <= ?)
227
+ ORDER BY seq ASC
228
+ LIMIT ?`,
229
+ [this.now() - CLAIM_TTL_MS, Math.max(1, limit)],
230
+ );
231
+ if (rows.length === 0) return [];
232
+ const mapped = rows.map((r) => ({
233
+ id: String(r.id),
234
+ seq: Number(r.seq),
235
+ tableName: String(r.table_name),
236
+ op: r.op as OutboxRow["op"],
237
+ before: parseImages(r.before_data),
238
+ after: parseImages(r.after_data),
239
+ ...(typeof r.mutation_id === "string" && r.mutation_id.length > 0
240
+ ? { mutationId: r.mutation_id }
241
+ : {}),
242
+ createdAt: Number(r.created_at),
243
+ }));
244
+ const ids = mapped.map((r) => r.id);
245
+ const marks = ids.map(() => "?").join(", ");
246
+ // Claim bump doubles as the attempt counter: each stale-bump re-claim is
247
+ // one redelivery. `claimed_at` older than the claim TTL becomes eligible
248
+ // again, so a crashed poller's rows get picked up without a hot loop.
249
+ await tx.exec(`UPDATE oke_cdc_outbox SET claimed_at = ? WHERE id IN (${marks})`, [
250
+ this.now(),
251
+ ...ids,
252
+ ]);
253
+ return mapped;
254
+ }
255
+
256
+ /**
257
+ * Prune delivered rows beyond retention (`maxAgeMs` / `maxCount`).
258
+ * Call periodically from the poller loop — NOT per tick.
259
+ *
260
+ * @returns Rows deleted (for metrics)
261
+ */
262
+ async prune(): Promise<number> {
263
+ let pruned = 0;
264
+ const cutoff =
265
+ this.retention.maxAgeMs === undefined ? null : this.now() - this.retention.maxAgeMs;
266
+ if (cutoff !== null) {
267
+ const res = await this.conn.exec(
268
+ `DELETE FROM oke_cdc_outbox WHERE delivered_at IS NOT NULL AND created_at < ?`,
269
+ [cutoff],
270
+ );
271
+ pruned += res.changes;
272
+ }
273
+ if (this.retention.maxCount !== undefined) {
274
+ const res = await this.conn.exec(
275
+ `DELETE FROM oke_cdc_outbox WHERE id IN (
276
+ SELECT id FROM oke_cdc_outbox WHERE delivered_at IS NOT NULL
277
+ ORDER BY created_at DESC OFFSET ?
278
+ )`,
279
+ [Math.max(0, this.retention.maxCount)],
280
+ );
281
+ pruned += res.changes;
282
+ }
283
+ return pruned;
284
+ }
285
+
286
+ /** Mark a specific batch delivered after its handler resolved. */
287
+ async markDelivered(rows: readonly OutboxRow[]): Promise<void> {
288
+ if (rows.length === 0) return;
289
+ const marks = rows.map(() => "?").join(", ");
290
+ await this.conn.exec(`UPDATE oke_cdc_outbox SET delivered_at = ? WHERE id IN (${marks})`, [
291
+ this.now(),
292
+ ...rows.map((r) => r.id),
293
+ ]);
294
+ }
295
+ }
296
+
297
+ /** Claim TTL — rows claimed longer ago than this are eligible for redelivery. */
298
+ const CLAIM_TTL_MS = 30_000;
299
+
300
+ /** Default sweep cadence for {@link CdcOutboxRunner} retention pruning. */
301
+ export const OUTBOX_PRUNE_INTERVAL_MS = 60_000;
302
+
303
+ /**
304
+ * Single-process poller loop: claims batches, feeds the LiveQuery runtime
305
+ * (and any other sinks), marks delivered, and prunes on a slow cadence.
306
+ * Start via {@link start}; idempotent `stop()` on the returned handle.
307
+ */
308
+ export class CdcOutboxRunner {
309
+ private timer?: ReturnType<typeof setInterval>;
310
+ private polling = false;
311
+ private lastPrune = 0;
312
+ private readonly intervalMs: number;
313
+ private readonly batchSize: number;
314
+ private readonly now: () => number;
315
+ private readonly outbox: CdcOutbox;
316
+ private readonly handler: (rows: readonly OutboxRow[]) => Promise<void>;
317
+ /** Counters surfaced through doctor/metrics. */
318
+ readonly metrics = { polls: 0, batches: 0, eventsDrained: 0, pruneFailures: 0 };
319
+
320
+ constructor(
321
+ outbox: CdcOutbox,
322
+ handler: (rows: readonly OutboxRow[]) => Promise<void>,
323
+ options: {
324
+ readonly intervalMs?: number;
325
+ readonly batchSize?: number;
326
+ readonly now?: () => number;
327
+ } = {},
328
+ ) {
329
+ this.outbox = outbox;
330
+ this.handler = handler;
331
+ this.intervalMs = Math.max(10, options.intervalMs ?? 200);
332
+ this.batchSize = Math.max(1, options.batchSize ?? 200);
333
+ this.now = options.now ?? (() => Date.now());
334
+ }
335
+
336
+ /** Begin background polling (idempotent). */
337
+ start(): void {
338
+ if (this.timer) return;
339
+ this.timer = setInterval(() => void this.tick(), this.intervalMs);
340
+ this.timer.unref?.();
341
+ }
342
+
343
+ /** Stop polling (idempotent). */
344
+ stop(): void {
345
+ if (this.timer !== undefined) {
346
+ clearInterval(this.timer);
347
+ this.timer = undefined;
348
+ }
349
+ }
350
+
351
+ /** One claim→handle→deliver→(maybe)prune cycle. Exposed for tests. */
352
+ async tick(): Promise<number> {
353
+ if (this.polling) return 0;
354
+ this.polling = true;
355
+ try {
356
+ this.metrics.polls += 1;
357
+ const nowMs = this.now();
358
+ if (nowMs - this.lastPrune >= OUTBOX_PRUNE_INTERVAL_MS) {
359
+ this.lastPrune = nowMs;
360
+ try {
361
+ await this.outbox.prune();
362
+ } catch {
363
+ this.metrics.pruneFailures += 1;
364
+ }
365
+ }
366
+ const rows = await this.outbox.pollOnce((batch) => this.handler(batch), this.batchSize);
367
+ if (rows.length > 0) {
368
+ this.metrics.batches += 1;
369
+ this.metrics.eventsDrained += rows.length;
370
+ }
371
+ return rows.length;
372
+ } finally {
373
+ this.polling = false;
374
+ }
375
+ }
376
+ }
377
+
378
+ function parseImages(value: unknown): Record<string, unknown> | null {
379
+ if (value === null || value === undefined) return null;
380
+ if (typeof value === "object") return value as Record<string, unknown>;
381
+ if (typeof value === "string") {
382
+ try {
383
+ return JSON.parse(value) as Record<string, unknown>;
384
+ } catch {
385
+ return null;
386
+ }
387
+ }
388
+ return null;
389
+ }
@@ -351,6 +351,26 @@ function createMemorySqlConnection(role: "primary" | "replica"): SqlConnection {
351
351
  return [{ ...row }];
352
352
  }
353
353
 
354
+ // DELETE ... RETURNING * (CDC before-image capture on sql-session
355
+ // delete paths). Same grammar as exec()'s DELETE branch.
356
+ const deleteReturning =
357
+ /^DELETE\s+FROM\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s+WHERE\s+(.+?)\s+RETURNING\s+\*\s*$/i.exec(
358
+ text,
359
+ );
360
+ if (deleteReturning) {
361
+ const table = getTable(parseIdent(deleteReturning[1]!));
362
+ const ast = parseWhere(deleteReturning[2]!);
363
+ const removed: SqlRow[] = [];
364
+ table.rows = table.rows.filter((r) => {
365
+ if (evalWhere(ast, r, params, { i: 0 })) {
366
+ removed.push({ ...r });
367
+ return false;
368
+ }
369
+ return true;
370
+ });
371
+ return removed;
372
+ }
373
+
354
374
  throw new Error(`memory sql: unsupported query: ${sql}`);
355
375
  },
356
376
  async exec(sql, params = []) {
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `apple` OIDC driver — `response_mode=form_post` web flow, ES256
3
+ * client-secret JWT minted per exchange from the team private key, and the
4
+ * strict string/boolean `email_verified` parse (Apple sends `"false"` as a
5
+ * string — naive truthiness would mark unverified emails verified).
6
+ */
7
+
8
+ import {
9
+ type OAuthAssertion,
10
+ type OAuthAssertionInput,
11
+ type OAuthAuthorizeInput,
12
+ type OAuthAuthorizeResult,
13
+ type OAuthDriver,
14
+ type OAuthExchangeInput,
15
+ type OAuthExchangeResult,
16
+ } from "./oauth-types.ts";
17
+ import { buildAuthorizeQuery, normalizeEmail, parseEmailVerified } from "./oauth-shared.ts";
18
+ import { createAppleClientSecretJwt, discoverOpenId, verifyIdToken } from "./oauth-oidc.ts";
19
+
20
+ const AUTHORIZATION_SERVER_ID = "https://appleid.apple.com";
21
+
22
+ /** Default Apple scopes. */
23
+ export const APPLE_DEFAULT_SCOPES = ["name", "email"] as const;
24
+
25
+ /** Apple-specific driver options. */
26
+ export interface AppleDriverOptions {
27
+ /** Apple Developer `teamId` (`iss` of the client-secret JWT). */
28
+ readonly teamId?: string;
29
+ /** Sign in with Apple private-key id (`kid`). */
30
+ readonly keyId?: string;
31
+ /** PKCS#8 PEM private key (Vault-resolved). */
32
+ readonly privateKeyPem?: string;
33
+ readonly scopes?: readonly string[];
34
+ readonly fetch?: typeof globalThis.fetch;
35
+ }
36
+
37
+ /**
38
+ * Open the Apple driver.
39
+ *
40
+ * @param options - Team/key material and injectable fetch
41
+ */
42
+ export function openOAuthApple(options: AppleDriverOptions = {}): OAuthDriver {
43
+ const fetchFn = options.fetch ?? globalThis.fetch;
44
+ return {
45
+ id: "apple",
46
+ kind: "oidc",
47
+ authorizationServerId: AUTHORIZATION_SERVER_ID,
48
+ buildAuthorizeUrl(input: OAuthAuthorizeInput): OAuthAuthorizeResult {
49
+ return {
50
+ url: buildAuthorizeQuery(`${AUTHORIZATION_SERVER_ID}/auth/authorize`, input, {
51
+ response_mode: "form_post",
52
+ }),
53
+ };
54
+ },
55
+ async exchangeCode(input: OAuthExchangeInput): Promise<OAuthExchangeResult> {
56
+ if (
57
+ input.clientSecretJwt === undefined &&
58
+ (!options.teamId || !options.keyId || !options.privateKeyPem)
59
+ ) {
60
+ throw new Error(
61
+ "apple: clientSecretJwt override or teamId + keyId + privateKeyPem required",
62
+ );
63
+ }
64
+ const endpoints = await discoverOpenId(AUTHORIZATION_SERVER_ID, fetchFn);
65
+ return exchangeAppleForm(endpoints.tokenEndpoint, input);
66
+ },
67
+ async resolveAssertion(input: OAuthAssertionInput): Promise<OAuthAssertion> {
68
+ if (!input.tokens.idToken) {
69
+ throw new Error("apple: token response missing id_token");
70
+ }
71
+ const claims = await verifyIdToken({
72
+ idToken: input.tokens.idToken,
73
+ jwksUri: `${AUTHORIZATION_SERVER_ID}/auth/keys`,
74
+ expectedIssuer: input.expectedIssuer,
75
+ expectedAudience: input.expectedAudience,
76
+ ...(input.expectedNonce !== undefined ? { expectedNonce: input.expectedNonce } : {}),
77
+ fetchFn,
78
+ ...(input.now !== undefined ? { now: input.now } : {}),
79
+ });
80
+ const email = normalizeEmail(claims.payload["email"]);
81
+ return {
82
+ subject: claims.subject,
83
+ issuer: claims.issuer,
84
+ ...(email !== undefined ? { email } : {}),
85
+ emailVerified: parseEmailVerified(claims.payload["email_verified"]),
86
+ name: appleName(input),
87
+ raw: claims.payload,
88
+ };
89
+ },
90
+ };
91
+
92
+ async function exchangeAppleForm(
93
+ tokenEndpoint: string,
94
+ exchange: OAuthExchangeInput,
95
+ ): Promise<OAuthExchangeResult> {
96
+ let clientSecret: string;
97
+ if (exchange.clientSecretJwt !== undefined) {
98
+ clientSecret = await exchange.clientSecretJwt();
99
+ } else {
100
+ clientSecret = await createAppleClientSecretJwt({
101
+ privateKeyPem: options.privateKeyPem!,
102
+ teamId: options.teamId!,
103
+ keyId: options.keyId!,
104
+ clientId: exchange.clientId,
105
+ });
106
+ }
107
+ const body = new URLSearchParams({
108
+ grant_type: "authorization_code",
109
+ code: exchange.code,
110
+ redirect_uri: exchange.redirectUri,
111
+ client_id: exchange.clientId,
112
+ client_secret: clientSecret,
113
+ });
114
+ const res = await fetchFn(tokenEndpoint, {
115
+ method: "POST",
116
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
117
+ body: body.toString(),
118
+ });
119
+ const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
120
+ if (!res.ok || typeof json["access_token"] !== "string") {
121
+ throw new Error(`apple: token endpoint HTTP ${res.status}`);
122
+ }
123
+ const expiresIn = json["expires_in"];
124
+ return {
125
+ tokens: {
126
+ accessToken: json["access_token"],
127
+ ...(typeof json["refresh_token"] === "string"
128
+ ? { refreshToken: json["refresh_token"] as string }
129
+ : {}),
130
+ ...(typeof expiresIn === "number"
131
+ ? { expiresAt: Math.floor(Date.now() / 1000 + expiresIn * 1000) }
132
+ : {}),
133
+ raw: json,
134
+ },
135
+ };
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Apple delivers the user's name only on first authorization via a
141
+ * form-posted `user` JSON field; drivers receive it through the raw token bag.
142
+ *
143
+ * @param input - Assertion inputs
144
+ */
145
+ function appleName(input: OAuthAssertionInput): string | undefined {
146
+ const userField = input.tokens.raw["user"];
147
+ if (typeof userField !== "string") return undefined;
148
+ try {
149
+ const parsed = JSON.parse(userField) as { name?: Record<string, string> };
150
+ const n = parsed.name;
151
+ if (!n) return undefined;
152
+ return [n.givenName, n.familyName].filter(Boolean).join(" ") || undefined;
153
+ } catch {
154
+ return undefined;
155
+ }
156
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `discord` OAuth2 driver — `GET /users/@me`. Trust: `verified === true`
3
+ * (requires the `email` scope). `email` may legitimately be null
4
+ * (phone-number accounts) — sign-in proceeds without an email.
5
+ */
6
+
7
+ import {
8
+ type OAuthAssertion,
9
+ type OAuthAssertionInput,
10
+ type OAuthAuthorizeInput,
11
+ type OAuthAuthorizeResult,
12
+ type OAuthDriver,
13
+ type OAuthExchangeInput,
14
+ type OAuthExchangeResult,
15
+ } from "./oauth-types.ts";
16
+ import { buildAuthorizeQuery } from "./oauth-shared.ts";
17
+ import { fetchProviderJson, resolveOauth2Assertion } from "./oauth2-common.ts";
18
+ import { postTokenRequest } from "./oauth2-token.ts";
19
+
20
+ const AUTHORIZATION_SERVER_ID = "https://discord.com";
21
+
22
+ /** Default Discord scopes. */
23
+ export const DISCORD_DEFAULT_SCOPES = ["identify", "email"] as const;
24
+
25
+ /**
26
+ * Open the Discord driver.
27
+ *
28
+ * @param options - Optional scope override / injectable fetch
29
+ */
30
+ export function openOAuthDiscord(
31
+ options: { readonly scopes?: readonly string[]; readonly fetch?: typeof globalThis.fetch } = {},
32
+ ): OAuthDriver {
33
+ const fetchFn = options.fetch ?? globalThis.fetch;
34
+ return {
35
+ id: "discord",
36
+ kind: "oauth2",
37
+ authorizationServerId: AUTHORIZATION_SERVER_ID,
38
+ buildAuthorizeUrl(input: OAuthAuthorizeInput): OAuthAuthorizeResult {
39
+ return {
40
+ url: buildAuthorizeQuery("https://discord.com/oauth2/authorize", input, {
41
+ prompt: "consent",
42
+ }),
43
+ };
44
+ },
45
+ async exchangeCode(input: OAuthExchangeInput): Promise<OAuthExchangeResult> {
46
+ return {
47
+ tokens: await postTokenRequest({
48
+ tokenEndpoint: "https://discord.com/api/oauth2/token",
49
+ input,
50
+ }),
51
+ };
52
+ },
53
+ async resolveAssertion(input: OAuthAssertionInput): Promise<OAuthAssertion> {
54
+ return resolveOauth2Assertion(input, async (accessToken) => {
55
+ const me = await fetchProviderJson(
56
+ "https://discord.com/api/users/@me",
57
+ accessToken,
58
+ fetchFn,
59
+ "discord",
60
+ );
61
+ const subject = typeof me["id"] === "string" ? me["id"] : undefined;
62
+ if (subject === undefined) throw new Error("discord: profile missing id");
63
+ return {
64
+ providerId: "discord",
65
+ subject,
66
+ // Null email is valid — normalizeEmail(undefined) → absent.
67
+ ...(me["email"] !== null && me["email"] !== undefined ? { email: me["email"] } : {}),
68
+ ...(me["verified"] !== undefined ? { verified: me["verified"] } : {}),
69
+ ...(typeof me["global_name"] === "string" && me["global_name"].length > 0
70
+ ? { name: me["global_name"] }
71
+ : typeof me["username"] === "string"
72
+ ? { name: me["username"] }
73
+ : {}),
74
+ raw: me,
75
+ };
76
+ });
77
+ },
78
+ };
79
+ }