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.
- package/AGENTS.md +5 -3
- package/README.md +14 -10
- package/manifest.v1.schema.json +61 -2
- package/package.json +19 -5
- package/site/content/docs/ai/index.mdx +1 -1
- package/site/content/docs/ai/mcp.mdx +10 -1
- package/site/content/docs/ai/skills.mdx +9 -9
- package/site/content/docs/elements/ai.mdx +1 -1
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/gate.mdx +3 -2
- package/site/content/docs/elements/store.mdx +289 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/basic-usage.mdx +3 -10
- package/site/content/docs/get-started/index.mdx +1 -1
- package/site/content/docs/get-started/installation.mdx +2 -3
- package/site/content/docs/get-started/introduction.mdx +58 -121
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/project-structure.mdx +4 -11
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +93 -71
- package/site/content/docs/index.mdx +44 -11
- package/site/content/docs/meta.json +8 -5
- package/site/content/docs/plugins/apple.mdx +151 -0
- package/site/content/docs/plugins/discord.mdx +139 -0
- package/site/content/docs/plugins/facebook.mdx +134 -0
- package/site/content/docs/plugins/figma.mdx +138 -0
- package/site/content/docs/plugins/github.mdx +138 -0
- package/site/content/docs/plugins/google.mdx +153 -0
- package/site/content/docs/plugins/index.mdx +47 -1
- package/site/content/docs/plugins/meta.json +10 -0
- package/site/content/docs/plugins/microsoft.mdx +151 -0
- package/site/content/docs/plugins/oauth.mdx +188 -0
- package/site/content/docs/plugins/x.mdx +125 -0
- package/site/content/docs/providers/index.mdx +2 -0
- package/site/content/docs/recipes/index.mdx +2 -0
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/client.mdx +58 -1
- package/site/content/docs/reference/configuration.mdx +2 -4
- package/site/content/docs/reference/fx.mdx +3 -1
- package/site/content/docs/reference/index.mdx +0 -5
- package/site/content/docs/reference/meta.json +2 -2
- package/site/content/docs/reference/okid.mdx +137 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/config.ts +9 -0
- package/src/auth/identity-sql.ts +314 -0
- package/src/auth/identity.ts +140 -2
- package/src/auth/index.ts +17 -1
- package/src/auth/method-context.ts +3 -0
- package/src/auth/oauth-as/cimd.ts +132 -0
- package/src/auth/oauth-as/crypto.test.ts +101 -0
- package/src/auth/oauth-as/crypto.ts +393 -0
- package/src/auth/oauth-as/errors.ts +68 -0
- package/src/auth/oauth-as/http.test.ts +419 -0
- package/src/auth/oauth-as/http.ts +842 -0
- package/src/auth/oauth-as/stores.ts +61 -0
- package/src/auth/oauth-as/tables.ts +142 -0
- package/src/auth/tables.ts +0 -11
- package/src/bench/README.md +83 -0
- package/src/bench/REPORT.md +176 -0
- package/src/bench/g01-rls-stamp.bench.ts +194 -0
- package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
- package/src/bench/g03-signal-once.bench.ts +157 -0
- package/src/bench/g03-signal-reconnect.bench.ts +254 -0
- package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
- package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
- package/src/bench/g05-sustained-full.bench.ts +265 -0
- package/src/bench/g06-mixed-load.bench.ts +260 -0
- package/src/bench/g07-vault-crypto.bench.ts +100 -0
- package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
- package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
- package/src/bench/g08-store-kv-durable.bench.ts +133 -0
- package/src/bench/g08-store-sql.bench.ts +178 -0
- package/src/bench/g09-journal-sustained.bench.ts +203 -0
- package/src/bench/g10-observability-contention.bench.ts +246 -0
- package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
- package/src/bench/g13-elements.bench.ts +427 -0
- package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
- package/src/bench/g15-postgres-degradation.bench.ts +264 -0
- package/src/bench/g16-live-query-fanout.bench.ts +206 -0
- package/src/bench/lib/event-loop-lag.ts +26 -0
- package/src/bench/lib/infra.ts +60 -0
- package/src/bench/lib/report.ts +52 -0
- package/src/bench/lib/rss-sampler.ts +61 -0
- package/src/bench/lib/signal-pg.ts +88 -0
- package/src/bench/load-app.ts +337 -0
- package/src/bench/load-child.ts +108 -0
- package/src/bench/smoke.bench.ts +43 -0
- package/src/cli/competitor-mention-removal.test.ts +28 -0
- package/src/cli/doctor-fd.ts +117 -0
- package/src/cli/doctor.test.ts +192 -0
- package/src/cli/doctor.ts +129 -1
- package/src/client/create.ts +95 -1
- package/src/client/index.ts +9 -2
- package/src/client/transport.ts +11 -4
- package/src/client/use-live-query.ts +154 -0
- package/src/client-react/index.ts +15 -1
- package/src/client-react/live-resource.ts +246 -0
- package/src/client-react/use-live-query.test.ts +475 -0
- package/src/client-react/use-live-query.ts +530 -0
- package/src/compiler/extract.test.ts +518 -0
- package/src/compiler/extract.ts +386 -19
- package/src/console/server/invoke-user-flow.ts +2 -1
- package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-De7Lc2JC.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-RGy7VEA_.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-_rgpdVzo.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-Ds6pcnh-.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
- package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-4rHOePuE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-DISPgxLM.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
- package/src/drivers/cdc-outbox.ts +389 -0
- package/src/drivers/memory.ts +20 -0
- package/src/drivers/oauth-apple.ts +156 -0
- package/src/drivers/oauth-discord.ts +79 -0
- package/src/drivers/oauth-facebook.ts +80 -0
- package/src/drivers/oauth-figma.ts +116 -0
- package/src/drivers/oauth-github.ts +92 -0
- package/src/drivers/oauth-google.ts +142 -0
- package/src/drivers/oauth-microsoft.ts +174 -0
- package/src/drivers/oauth-oidc.ts +293 -0
- package/src/drivers/oauth-shared.ts +326 -0
- package/src/drivers/oauth-types.ts +159 -0
- package/src/drivers/oauth-x.ts +77 -0
- package/src/drivers/oauth2-common.ts +95 -0
- package/src/drivers/oauth2-token.ts +61 -0
- package/src/drivers/pg-rls-row-passes.ts +251 -0
- package/src/drivers/pg-rls.ts +2 -0
- package/src/drivers/postgres.ts +45 -2
- package/src/drivers/signal-postgres.ts +2 -1
- package/src/elements/channel/runtime.ts +29 -2
- package/src/elements/channel.test.ts +52 -0
- package/src/elements/gate/boot.ts +29 -2
- package/src/elements/store/emit-drizzle.ts +147 -14
- package/src/elements/store/field-ddl.test.ts +118 -0
- package/src/elements/store/field-types.test.ts +455 -0
- package/src/elements/store/list-query.golden.json +777 -0
- package/src/elements/store/list-query.parity.test.ts +396 -0
- package/src/elements/store/list-query.ts +792 -0
- package/src/elements/store/live-default.test.ts +136 -0
- package/src/elements/store/live-http.test.ts +160 -0
- package/src/elements/store/live-isolation.test.ts +291 -0
- package/src/elements/store/live-query-runtime.test.ts +323 -0
- package/src/elements/store/live-query-runtime.ts +403 -0
- package/src/elements/store/live-query-server.test.ts +377 -0
- package/src/elements/store/live-query-server.ts +102 -0
- package/src/elements/store/live-query.ts +97 -0
- package/src/elements/store/resource.ts +189 -680
- package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
- package/src/elements/store/schema-decl.ts +539 -41
- package/src/elements/store/sql-rls-stamp.test.ts +27 -0
- package/src/elements/store/sql-session.ts +297 -35
- package/src/elements/store/table.ts +102 -21
- package/src/elements/store.test.ts +3 -1
- package/src/elements/store.ts +12 -1
- package/src/elements/vault/chaos-child.ts +74 -1
- package/src/elements/vault/chaos.test.ts +4 -2
- package/src/elements/vault/storage.ts +4 -2
- package/src/index.ts +5 -2
- package/src/kernel/app-auth.ts +1 -0
- package/src/kernel/app.ts +116 -2
- package/src/kernel/auth-sharing.test.ts +196 -0
- package/src/kernel/boot.test.ts +3 -3
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +14 -2
- package/src/kernel/horizontal-child.ts +2 -1
- package/src/kernel/http-resource.ts +33 -7
- package/src/kernel/identity-host-persist.test.ts +119 -0
- package/src/kernel/instance-id.ts +4 -2
- package/src/kernel/journal.ts +2 -1
- package/src/kernel/mcp-tool.test.ts +95 -0
- package/src/kernel/on.ts +9 -0
- package/src/kernel/realtime-bind.ts +326 -0
- package/src/kernel/resource-live.ts +117 -0
- package/src/kernel/triggers.ts +86 -4
- package/src/manifest/diff.ts +37 -0
- package/src/manifest/types.ts +64 -2
- package/src/okid.bench.test.ts +64 -0
- package/src/okid.test.ts +338 -0
- package/src/okid.ts +245 -0
- package/src/plugins/anonymous.ts +19 -1
- package/src/plugins/auth/shared.ts +15 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/magic-link.ts +10 -8
- package/src/plugins/mcp-oauth.ts +208 -0
- package/src/plugins/oauth/flow-store.ts +117 -0
- package/src/plugins/oauth/link.ts +69 -0
- package/src/plugins/oauth/shared.ts +108 -0
- package/src/plugins/oauth/token-vault.ts +100 -0
- package/src/plugins/oauth.security.test.ts +535 -0
- package/src/plugins/oauth.ts +532 -0
- package/src/plugins/otp.ts +48 -6
- package/src/plugins/passkey.ts +20 -1
- package/src/plugins/two-factor.ts +11 -0
- package/src/plugins/username.ts +40 -7
- package/src/release/build-lib.ts +7 -1
- package/src/release/measure.ts +1 -0
- package/src/release/official-plugins.ts +4 -1
- package/src/runs/collect.ts +2 -1
- package/src/runs/drivers/files.ts +2 -1
- package/src/test/create-test-app.ts +114 -5
- package/src/test/export-bundle.test.ts +33 -0
- package/src/test/live-signals.test.ts +83 -0
- package/src/test/tenant-isolation.test.ts +175 -0
- package/src/testing.ts +26 -0
- package/src/upgrade/codemods.ts +1 -1
- package/site/content/docs/reference/migrating-environments.mdx +0 -158
package/src/cli/doctor.test.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
6
|
import type { Manifest } from "../manifest/types.ts";
|
|
7
7
|
import { runDoctor } from "./doctor.ts";
|
|
8
|
+
import { estimatePeakFds } from "./doctor-fd.ts";
|
|
8
9
|
|
|
9
10
|
describe("oke doctor", () => {
|
|
10
11
|
test("catches a missing secret", async () => {
|
|
@@ -210,3 +211,194 @@ describe("oke doctor", () => {
|
|
|
210
211
|
expect(findings.some((f) => f.code === "pii_ask")).toBe(false);
|
|
211
212
|
});
|
|
212
213
|
});
|
|
214
|
+
|
|
215
|
+
describe("doctor file_descriptor_limit", () => {
|
|
216
|
+
const base = {
|
|
217
|
+
secrets: [] as string[],
|
|
218
|
+
ports: [] as number[],
|
|
219
|
+
skipDbDrift: true,
|
|
220
|
+
expectedSchemaFingerprint: "same",
|
|
221
|
+
currentSchemaFingerprint: "same",
|
|
222
|
+
write: () => {},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
test("warns when soft limit is low relative to estimated need", async () => {
|
|
226
|
+
const { code, findings } = await runDoctor({
|
|
227
|
+
...base,
|
|
228
|
+
detectFdPressure: async () => ({ softLimit: 256, estimatedNeed: 400, headroom: -144 }),
|
|
229
|
+
});
|
|
230
|
+
expect(code).toBe(0);
|
|
231
|
+
expect(findings.some((f) => f.code === "file_descriptor_limit" && f.severity === "warn")).toBe(
|
|
232
|
+
true,
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("errors when soft limit below estimated need", async () => {
|
|
237
|
+
const { code, findings } = await runDoctor({
|
|
238
|
+
...base,
|
|
239
|
+
detectFdPressure: async () => ({ softLimit: 200, estimatedNeed: 400, headroom: -200 }),
|
|
240
|
+
});
|
|
241
|
+
expect(code).toBe(2);
|
|
242
|
+
const finding = findings.find((f) => f.code === "file_descriptor_limit");
|
|
243
|
+
expect(finding?.severity).toBe("error");
|
|
244
|
+
expect(finding?.message).toContain("file descriptor");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("no finding with ample headroom", async () => {
|
|
248
|
+
const { code, findings } = await runDoctor({
|
|
249
|
+
...base,
|
|
250
|
+
detectFdPressure: async () => ({ softLimit: 65536, estimatedNeed: 500, headroom: 65036 }),
|
|
251
|
+
});
|
|
252
|
+
expect(code).toBe(0);
|
|
253
|
+
expect(findings.some((f) => f.code === "file_descriptor_limit")).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("skips conservatively without manifest/live signals", async () => {
|
|
257
|
+
const findings: unknown[] = [];
|
|
258
|
+
const { code } = await runDoctor({
|
|
259
|
+
...base,
|
|
260
|
+
write: (t) => findings.push(t),
|
|
261
|
+
});
|
|
262
|
+
// Real probe runs; on typical dev limits it must not throw and must exit ok.
|
|
263
|
+
expect([0, 2]).toContain(code);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("estimatePeakFds counts live signals and http routes", async () => {
|
|
267
|
+
const manifest = {
|
|
268
|
+
oke: "1.0",
|
|
269
|
+
app: "t",
|
|
270
|
+
signals: {
|
|
271
|
+
tick: { delivery: "live" },
|
|
272
|
+
once: { delivery: "once" },
|
|
273
|
+
fan: { delivery: "broadcast" },
|
|
274
|
+
},
|
|
275
|
+
flows: {
|
|
276
|
+
sse: { trigger: { http: { method: "GET", path: "/live" } }, live: "tick" },
|
|
277
|
+
plain: { trigger: { http: { method: "POST", path: "/x" } } },
|
|
278
|
+
},
|
|
279
|
+
} as unknown as Manifest;
|
|
280
|
+
const need = estimatePeakFds(manifest);
|
|
281
|
+
expect(need).toBeGreaterThan(estimatePeakFds(null));
|
|
282
|
+
expect(need).toBeGreaterThanOrEqual(64 * 2 * 2); // live signal + SSE route, ≥2 fds/sub
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("estimatePeakFds subscriber term is linear in live signals (G3b-calibrated)", () => {
|
|
286
|
+
const one = {
|
|
287
|
+
oke: "1.0",
|
|
288
|
+
app: "t",
|
|
289
|
+
signals: { a: { delivery: "live" } },
|
|
290
|
+
flows: {},
|
|
291
|
+
} as unknown as Manifest;
|
|
292
|
+
const two = {
|
|
293
|
+
...one,
|
|
294
|
+
signals: { a: { delivery: "live" }, b: { delivery: "live" } },
|
|
295
|
+
} as unknown as Manifest;
|
|
296
|
+
const base = estimatePeakFds(null);
|
|
297
|
+
const deltaOne = estimatePeakFds(one) - base;
|
|
298
|
+
const deltaTwo = estimatePeakFds(two) - base;
|
|
299
|
+
// Each additional live signal adds the same subscriber-fd budget.
|
|
300
|
+
expect(deltaTwo).toBe(deltaOne * 2);
|
|
301
|
+
// G3b measured 1.53 fds/subscriber; budget must be at least 1 fd/sub.
|
|
302
|
+
expect(deltaOne).toBeGreaterThanOrEqual(64);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("doctor realtime checks", () => {
|
|
307
|
+
const base = {
|
|
308
|
+
secrets: [] as string[],
|
|
309
|
+
ports: [] as number[],
|
|
310
|
+
skipDbDrift: true,
|
|
311
|
+
write: () => {},
|
|
312
|
+
currentSchemaFingerprint: null,
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
test("cdc_outbox_backlog warn at >10k pending", async () => {
|
|
316
|
+
const { findings } = await runDoctor({
|
|
317
|
+
...base,
|
|
318
|
+
detectRealtimeMetrics: async () => ({
|
|
319
|
+
subscribers: 10,
|
|
320
|
+
queueDepth: 0,
|
|
321
|
+
fanout: { eventsIn: 1, eventsShed: 0, checksRun: 0, checkFailures: 0 },
|
|
322
|
+
outbox: { pending: 20_000, dispatchedOverCap: 0 },
|
|
323
|
+
}),
|
|
324
|
+
});
|
|
325
|
+
const finding = findings.find((f) => f.code === "cdc_outbox_backlog");
|
|
326
|
+
expect(finding?.severity).toBe("warn");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("cdc_outbox_backlog error at >100k pending", async () => {
|
|
330
|
+
const { code, findings } = await runDoctor({
|
|
331
|
+
...base,
|
|
332
|
+
detectRealtimeMetrics: async () => ({
|
|
333
|
+
subscribers: 10,
|
|
334
|
+
queueDepth: 0,
|
|
335
|
+
fanout: { eventsIn: 1, eventsShed: 0, checksRun: 0, checkFailures: 0 },
|
|
336
|
+
outbox: { pending: 150_000, dispatchedOverCap: 0 },
|
|
337
|
+
}),
|
|
338
|
+
});
|
|
339
|
+
expect(code).toBe(2);
|
|
340
|
+
expect(findings.find((f) => f.code === "cdc_outbox_backlog")?.severity).toBe("error");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("cdc_outbox_retention warns when delivered rows exceed maxCount cap", async () => {
|
|
344
|
+
const { findings } = await runDoctor({
|
|
345
|
+
...base,
|
|
346
|
+
detectRealtimeMetrics: async () => ({
|
|
347
|
+
subscribers: 10,
|
|
348
|
+
queueDepth: 0,
|
|
349
|
+
fanout: { eventsIn: 1, eventsShed: 0, checksRun: 0, checkFailures: 0 },
|
|
350
|
+
outbox: { pending: 0, dispatchedOverCap: 500 },
|
|
351
|
+
}),
|
|
352
|
+
});
|
|
353
|
+
expect(findings.some((f) => f.code === "cdc_outbox_retention")).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("live_subscriber_pressure warns above ~150 active subs", async () => {
|
|
357
|
+
const { findings } = await runDoctor({
|
|
358
|
+
...base,
|
|
359
|
+
detectRealtimeMetrics: async () => ({
|
|
360
|
+
subscribers: 200,
|
|
361
|
+
queueDepth: 0,
|
|
362
|
+
fanout: { eventsIn: 1, eventsShed: 0, checksRun: 0, checkFailures: 0 },
|
|
363
|
+
outbox: { unavailable: "no_bridge" },
|
|
364
|
+
}),
|
|
365
|
+
});
|
|
366
|
+
expect(findings.some((f) => f.code === "live_subscriber_pressure")).toBe(true);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("live_fanout_queue_saturated errors on near-cap depth plus shedding; no finding when healthy", async () => {
|
|
370
|
+
const saturated = await runDoctor({
|
|
371
|
+
...base,
|
|
372
|
+
detectRealtimeMetrics: async () => ({
|
|
373
|
+
subscribers: 100,
|
|
374
|
+
queueDepth: 9_000,
|
|
375
|
+
fanout: { eventsIn: 50_000, eventsShed: 12, checksRun: 40_000, checkFailures: 2 },
|
|
376
|
+
outbox: { unavailable: "no_bridge" },
|
|
377
|
+
}),
|
|
378
|
+
});
|
|
379
|
+
expect(saturated.findings.some((f) => f.code === "live_fanout_queue_saturated")).toBe(true);
|
|
380
|
+
|
|
381
|
+
const healthy = await runDoctor({
|
|
382
|
+
...base,
|
|
383
|
+
expectedSchemaFingerprint: "same",
|
|
384
|
+
currentSchemaFingerprint: "same",
|
|
385
|
+
detectRealtimeMetrics: async () => ({
|
|
386
|
+
subscribers: 100,
|
|
387
|
+
queueDepth: 4,
|
|
388
|
+
fanout: { eventsIn: 5_000, eventsShed: 0, checksRun: 4_800, checkFailures: 0 },
|
|
389
|
+
outbox: { unavailable: "no_bridge" },
|
|
390
|
+
}),
|
|
391
|
+
});
|
|
392
|
+
expect(healthy.findings).toHaveLength(0);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("no realtime findings when no bridge is bound (metrics null)", async () => {
|
|
396
|
+
const { findings } = await runDoctor({
|
|
397
|
+
...base,
|
|
398
|
+
detectRealtimeMetrics: async () => null,
|
|
399
|
+
});
|
|
400
|
+
expect(
|
|
401
|
+
findings.filter((f) => f.code.startsWith("cdc_") || f.code.startsWith("live_")),
|
|
402
|
+
).toHaveLength(0);
|
|
403
|
+
});
|
|
404
|
+
});
|
package/src/cli/doctor.ts
CHANGED
|
@@ -17,11 +17,24 @@ import { hasFlag, wantsJson } from "./args.ts";
|
|
|
17
17
|
import { checkManifestPiiAsks } from "./doctor-pii.ts";
|
|
18
18
|
import { EXIT_OK, EXIT_RUNTIME } from "./exit.ts";
|
|
19
19
|
import { loadManifest, loadOkeConfig } from "./load-config.ts";
|
|
20
|
+
import { OUTBOX_PRUNE_INTERVAL_MS } from "../drivers/cdc-outbox.ts";
|
|
20
21
|
import { isPortInUse } from "./ports.ts";
|
|
21
22
|
import { schemaFingerprint, readSchemaFingerprint } from "./schema.ts";
|
|
22
23
|
|
|
23
24
|
export { isPortInUse } from "./ports.ts";
|
|
24
25
|
|
|
26
|
+
/** Warn when the CDC outbox drain rate looks stalled below this backlog. */
|
|
27
|
+
const OUTBOX_BACKLOG_WARN = 10_000;
|
|
28
|
+
|
|
29
|
+
/** Error-severity outbox backlog — poller considered down. */
|
|
30
|
+
const OUTBOX_BACKLOG_ERROR = 100_000;
|
|
31
|
+
|
|
32
|
+
/** v1 documented fan-out ceiling per table (Hardening 2 / plan §Operations). */
|
|
33
|
+
const LIVE_SUBSCRIBER_SOFT_CAP = 150;
|
|
34
|
+
|
|
35
|
+
/** Queue-depth threshold where fan-out health findings start firing. */
|
|
36
|
+
const LIVE_FANOUT_QUEUE_WARN_DEPTH = 8_000;
|
|
37
|
+
|
|
25
38
|
/**
|
|
26
39
|
* `label → merged EnvDriverMap` pairs, in the fixed order shown by
|
|
27
40
|
* {@link formatDriversSummary} / the `drivers` JSON key.
|
|
@@ -84,7 +97,12 @@ export interface DoctorFinding {
|
|
|
84
97
|
| "tenancy"
|
|
85
98
|
| "driver"
|
|
86
99
|
| "pii_ask"
|
|
87
|
-
| "vault_master_key"
|
|
100
|
+
| "vault_master_key"
|
|
101
|
+
| "file_descriptor_limit"
|
|
102
|
+
| "cdc_outbox_backlog"
|
|
103
|
+
| "cdc_outbox_retention"
|
|
104
|
+
| "live_subscriber_pressure"
|
|
105
|
+
| "live_fanout_queue_saturated";
|
|
88
106
|
readonly severity: "error" | "warn";
|
|
89
107
|
readonly message: string;
|
|
90
108
|
}
|
|
@@ -138,8 +156,31 @@ export interface DoctorOptions {
|
|
|
138
156
|
readonly writeErr?: (text: string) => void;
|
|
139
157
|
/** Emit only JSON on stdout. */
|
|
140
158
|
readonly json?: boolean;
|
|
159
|
+
/**
|
|
160
|
+
* Inject the file-descriptor pressure probe (tests). When unset, the real
|
|
161
|
+
* probe runs only when a manifest was loaded (conservative skip otherwise).
|
|
162
|
+
*/
|
|
163
|
+
readonly detectFdPressure?: () => Promise<{
|
|
164
|
+
readonly softLimit: number;
|
|
165
|
+
readonly estimatedNeed: number;
|
|
166
|
+
readonly headroom: number;
|
|
167
|
+
}>;
|
|
168
|
+
/**
|
|
169
|
+
* Inject the realtime metrics probe (tests). When unset, the real probe
|
|
170
|
+
* reads the bound realtime bridge in-process (returns `null` — no findings
|
|
171
|
+
* — when this CLI process has none, e.g. the app runs in another host).
|
|
172
|
+
*/
|
|
173
|
+
readonly detectRealtimeMetrics?: () => Promise<RealtimeMetricsSnapshot | null>;
|
|
141
174
|
}
|
|
142
175
|
|
|
176
|
+
/** Realtime metrics shape used by the doctor checks (see {@link realtimeMetrics}). */
|
|
177
|
+
export type RealtimeMetricsSnapshot = {
|
|
178
|
+
subscribers: number;
|
|
179
|
+
queueDepth: number;
|
|
180
|
+
fanout: { eventsIn: number; eventsShed: number; checksRun: number; checkFailures: number };
|
|
181
|
+
outbox: { pending: number; dispatchedOverCap: number } | { unavailable: "no_bridge" };
|
|
182
|
+
};
|
|
183
|
+
|
|
143
184
|
/**
|
|
144
185
|
* Run doctor checks.
|
|
145
186
|
*
|
|
@@ -232,6 +273,93 @@ export async function runDoctor(options: DoctorOptions = {}): Promise<{
|
|
|
232
273
|
}
|
|
233
274
|
}
|
|
234
275
|
|
|
276
|
+
// File-descriptor headroom — live SSE holds one fd per open subscriber.
|
|
277
|
+
// Severity: error when the soft limit dips under ~60% of estimated peak
|
|
278
|
+
// need (exhaustion is imminent); warn when it is under 2× need.
|
|
279
|
+
const fd =
|
|
280
|
+
options.detectFdPressure !== undefined
|
|
281
|
+
? await options.detectFdPressure()
|
|
282
|
+
: manifest
|
|
283
|
+
? await (async () => {
|
|
284
|
+
const { checkFileDescriptorHeadroom } = await import("./doctor-fd.ts");
|
|
285
|
+
return checkFileDescriptorHeadroom(manifest);
|
|
286
|
+
})()
|
|
287
|
+
: null;
|
|
288
|
+
if (fd) {
|
|
289
|
+
if (fd.softLimit < fd.estimatedNeed * 0.6) {
|
|
290
|
+
findings.push({
|
|
291
|
+
code: "file_descriptor_limit",
|
|
292
|
+
severity: "error",
|
|
293
|
+
message: `file descriptor soft limit ${fd.softLimit} is below estimated peak need ${fd.estimatedNeed} (headroom ${fd.headroom}) — raise with \`ulimit -n\` or launchd Limits`,
|
|
294
|
+
});
|
|
295
|
+
} else if (fd.softLimit < fd.estimatedNeed * 2) {
|
|
296
|
+
findings.push({
|
|
297
|
+
code: "file_descriptor_limit",
|
|
298
|
+
severity: "warn",
|
|
299
|
+
message: `file descriptor soft limit ${fd.softLimit} leaves thin headroom over estimated peak need ${fd.estimatedNeed} (headroom ${fd.headroom}) — consider raising with \`ulimit -n\``,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Realtime health — CDC outbox backlog/retention + live fan-out pressure.
|
|
305
|
+
// Thresholds from the Realtime plan's operations section; all probes are
|
|
306
|
+
// injectable so tests never need a bound bridge.
|
|
307
|
+
if (options.detectRealtimeMetrics !== undefined || manifest !== undefined) {
|
|
308
|
+
const rt =
|
|
309
|
+
options.detectRealtimeMetrics !== undefined
|
|
310
|
+
? await options.detectRealtimeMetrics()
|
|
311
|
+
: await (async () => {
|
|
312
|
+
const { realtimeMetrics } = await import("../kernel/realtime-bind.ts");
|
|
313
|
+
return realtimeMetrics() as Promise<RealtimeMetricsSnapshot | null>;
|
|
314
|
+
})();
|
|
315
|
+
if (rt !== null) {
|
|
316
|
+
const outbox = rt.outbox;
|
|
317
|
+
if (!("unavailable" in outbox)) {
|
|
318
|
+
if (outbox.pending > OUTBOX_BACKLOG_ERROR) {
|
|
319
|
+
findings.push({
|
|
320
|
+
code: "cdc_outbox_backlog",
|
|
321
|
+
severity: "error",
|
|
322
|
+
message: `CDC outbox backlog ${outbox.pending} rows exceeds ${OUTBOX_BACKLOG_ERROR} — poller stalled or consumers down; check \`oke_cdc_outbox\` and runner metrics`,
|
|
323
|
+
});
|
|
324
|
+
} else if (outbox.pending > OUTBOX_BACKLOG_WARN) {
|
|
325
|
+
findings.push({
|
|
326
|
+
code: "cdc_outbox_backlog",
|
|
327
|
+
severity: "warn",
|
|
328
|
+
message: `CDC outbox backlog ${outbox.pending} rows exceeds ${OUTBOX_BACKLOG_WARN} — watch the poller drain rate`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (outbox.dispatchedOverCap > 0) {
|
|
332
|
+
findings.push({
|
|
333
|
+
code: "cdc_outbox_retention",
|
|
334
|
+
severity: "warn",
|
|
335
|
+
message: `${outbox.dispatchedOverCap} delivered outbox rows exceed the maxCount retention cap — pruner may be stuck; verify \`${OUTBOX_PRUNE_INTERVAL_MS}ms\` prune cadence`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (rt.subscribers > LIVE_SUBSCRIBER_SOFT_CAP) {
|
|
340
|
+
findings.push({
|
|
341
|
+
code: "live_subscriber_pressure",
|
|
342
|
+
severity: "warn",
|
|
343
|
+
message: `${rt.subscribers} active live subscribers exceeds the ~150 fan-out ceiling documented for v1 — expect rising p99 latency; identity dedup lands in Round 2+`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
const saturated = rt.queueDepth >= LIVE_FANOUT_QUEUE_WARN_DEPTH && rt.fanout.eventsShed > 0;
|
|
347
|
+
if (saturated) {
|
|
348
|
+
findings.push({
|
|
349
|
+
code: "live_fanout_queue_saturated",
|
|
350
|
+
severity: "error",
|
|
351
|
+
message: `fan-out queue depth ${rt.queueDepth} at cap with ${rt.fanout.eventsShed} events shed — subscribers are losing events now; raise pool concurrency or shed rate first`,
|
|
352
|
+
});
|
|
353
|
+
} else if (rt.queueDepth >= LIVE_FANOUT_QUEUE_WARN_DEPTH) {
|
|
354
|
+
findings.push({
|
|
355
|
+
code: "live_fanout_queue_saturated",
|
|
356
|
+
severity: "warn",
|
|
357
|
+
message: `fan-out queue depth ${rt.queueDepth} near cap (${rt.fanout.checksRun} checks in flight historically) — headroom thin before shedding begins`,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
235
363
|
const current =
|
|
236
364
|
options.currentSchemaFingerprint !== undefined
|
|
237
365
|
? options.currentSchemaFingerprint
|
package/src/client/create.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "./live.ts";
|
|
25
25
|
import type {
|
|
26
26
|
Client,
|
|
27
|
+
ClientHeaders,
|
|
27
28
|
ClientLive,
|
|
28
29
|
ClientOptions,
|
|
29
30
|
ClientRouteMap,
|
|
@@ -97,11 +98,13 @@ function buildClient(url: string, opts: ClientOptions = {}): Client {
|
|
|
97
98
|
const routes = opts.routes ?? flattenRoutes(opts.$routes);
|
|
98
99
|
const live = flattenLiveRoutes(opts.$routes);
|
|
99
100
|
const transport = createTransport(base, { ...opts, routes });
|
|
101
|
+
const perCallHeaders = createPerCallHeaders();
|
|
100
102
|
return proxy(transport, [], {
|
|
101
103
|
base,
|
|
102
104
|
opts,
|
|
103
105
|
liveBySignal: live.bySignal,
|
|
104
106
|
liveByFlow: live.byFlow,
|
|
107
|
+
perCallHeaders,
|
|
105
108
|
}) as Client;
|
|
106
109
|
}
|
|
107
110
|
|
|
@@ -110,8 +113,48 @@ type ProxyCtx = {
|
|
|
110
113
|
readonly opts: ClientOptions;
|
|
111
114
|
readonly liveBySignal: LiveRouteTable;
|
|
112
115
|
readonly liveByFlow: LiveByFlow;
|
|
116
|
+
/**
|
|
117
|
+
* Extra headers attached to the next transport call, then cleared — the
|
|
118
|
+
* `X-Oke-Mutation-Id` channel for optimistic dedupe (one-shot by design;
|
|
119
|
+
* a mutated header must not leak into unrelated calls).
|
|
120
|
+
*/
|
|
121
|
+
perCallHeaders: PerCallHeaders;
|
|
113
122
|
};
|
|
114
123
|
|
|
124
|
+
/**
|
|
125
|
+
* One-shot header bag: set before a mutation call, drained after it. Safe
|
|
126
|
+
* across concurrent calls — each `run` stages its own merge and restores the
|
|
127
|
+
* previous stage on completion (LIFO), so nested/parallel runs don't clobber.
|
|
128
|
+
*/
|
|
129
|
+
function createPerCallHeaders(): PerCallHeaders {
|
|
130
|
+
let extra: ClientHeaders | undefined;
|
|
131
|
+
return {
|
|
132
|
+
/** Stage headers consumed by the next transport call only. */
|
|
133
|
+
run<T>(headers: ClientHeaders | undefined, fn: () => Promise<T>): Promise<T> {
|
|
134
|
+
const prev = extra;
|
|
135
|
+
extra = headers === undefined ? prev : mergeHeaders(prev, headers);
|
|
136
|
+
return fn().finally(() => {
|
|
137
|
+
extra = prev;
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
/** Transport-side read (invoked inside `invoke`). */
|
|
141
|
+
read(): ClientHeaders | undefined {
|
|
142
|
+
return extra;
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Shallow-merge two header bags (later wins on key conflicts). */
|
|
148
|
+
function mergeHeaders(base: ClientHeaders | undefined, over: ClientHeaders): ClientHeaders {
|
|
149
|
+
if (base === undefined) return over;
|
|
150
|
+
if (!Array.isArray(base) && !Array.isArray(over)) {
|
|
151
|
+
return { ...base, ...over };
|
|
152
|
+
}
|
|
153
|
+
const toEntries = (h: ClientHeaders): [string, string][] =>
|
|
154
|
+
Array.isArray(h) ? h : Object.entries(h);
|
|
155
|
+
return [...toEntries(base), ...toEntries(over)];
|
|
156
|
+
}
|
|
157
|
+
|
|
115
158
|
/**
|
|
116
159
|
* Flatten `app.$routes` into the transport REST table (`unit.flow` → method/path).
|
|
117
160
|
* Entries without both method and path are omitted (RPC fallback).
|
|
@@ -162,7 +205,7 @@ function proxy(transport: Transport, path: readonly string[], ctx: ProxyCtx): un
|
|
|
162
205
|
}
|
|
163
206
|
const unit = path[0]!;
|
|
164
207
|
const flow = path.slice(1).join(".");
|
|
165
|
-
const result = await transport.call(`${unit}/${flow}`, input);
|
|
208
|
+
const result = await transport.call(`${unit}/${flow}`, input, ctx.perCallHeaders.read());
|
|
166
209
|
return attachPager(result, invoke, input);
|
|
167
210
|
};
|
|
168
211
|
const call = (a?: unknown, b?: unknown) => {
|
|
@@ -181,6 +224,13 @@ function proxy(transport: Transport, path: readonly string[], ctx: ProxyCtx): un
|
|
|
181
224
|
return new Proxy(call, {
|
|
182
225
|
get(_target, prop, receiver) {
|
|
183
226
|
if (typeof prop === "symbol") {
|
|
227
|
+
if (prop === TRANSPORT_BRAND) {
|
|
228
|
+
return {
|
|
229
|
+
base: ctx.base,
|
|
230
|
+
opts: ctx.opts,
|
|
231
|
+
perCallHeaders: ctx.perCallHeaders,
|
|
232
|
+
} satisfies TransportBag;
|
|
233
|
+
}
|
|
184
234
|
return Reflect.get(_target, prop, receiver);
|
|
185
235
|
}
|
|
186
236
|
if (prop === "then") return undefined;
|
|
@@ -192,6 +242,50 @@ function proxy(transport: Transport, path: readonly string[], ctx: ProxyCtx): un
|
|
|
192
242
|
});
|
|
193
243
|
}
|
|
194
244
|
|
|
245
|
+
/** Symbol brand exposing `{ base, opts }` from a client Proxy instance. */
|
|
246
|
+
const TRANSPORT_BRAND = Symbol("oke.transportBag");
|
|
247
|
+
|
|
248
|
+
/** One-shot header channel surface (see {@link createPerCallHeaders}). */
|
|
249
|
+
export interface PerCallHeaders {
|
|
250
|
+
/**
|
|
251
|
+
* Run `fn` with `headers` merged onto its transport call (and any pager
|
|
252
|
+
* walks the result spawns). Restores prior state afterwards.
|
|
253
|
+
*/
|
|
254
|
+
run<T>(headers: ClientHeaders | undefined, fn: () => Promise<T>): Promise<T>;
|
|
255
|
+
/** Transport-side read (invoked inside `invoke`). */
|
|
256
|
+
read(): ClientHeaders | undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Transport surface carried by a built client — read via {@link transportOf}. */
|
|
260
|
+
export interface TransportBag {
|
|
261
|
+
readonly base: string;
|
|
262
|
+
readonly opts: ClientOptions;
|
|
263
|
+
/** One-shot extra headers for a mutation (e.g. `X-Oke-Mutation-Id`). */
|
|
264
|
+
readonly perCallHeaders: PerCallHeaders;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Read the origin + client options from any {@link createClient} instance
|
|
269
|
+
* (symbol-brand channel on the root proxy — invisible to `get` traps beyond
|
|
270
|
+
* symbol reflection). The root proxy targets a callable, so both `function`
|
|
271
|
+
* and `object` receivers are accepted.
|
|
272
|
+
*
|
|
273
|
+
* @param api - Client instance or its typed surface
|
|
274
|
+
*/
|
|
275
|
+
export function transportOf(api: unknown): TransportBag | undefined {
|
|
276
|
+
if (typeof api !== "object" && typeof api !== "function") return undefined;
|
|
277
|
+
const bag = Reflect.get(api as object, TRANSPORT_BRAND) as unknown;
|
|
278
|
+
if (
|
|
279
|
+
bag !== null &&
|
|
280
|
+
typeof bag === "object" &&
|
|
281
|
+
"base" in bag &&
|
|
282
|
+
typeof (bag as { base: unknown }).base === "string"
|
|
283
|
+
) {
|
|
284
|
+
return bag as TransportBag;
|
|
285
|
+
}
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
195
289
|
function makeLive(ctx: ProxyCtx): ClientLive {
|
|
196
290
|
return ((signalOrName: unknown, inputOrHandlers: unknown, maybeHandlers?: unknown) => {
|
|
197
291
|
const name =
|
package/src/client/index.ts
CHANGED
|
@@ -13,11 +13,18 @@ export {
|
|
|
13
13
|
nextResubscribeDelay,
|
|
14
14
|
pickLiveExposure,
|
|
15
15
|
} from "./live.ts";
|
|
16
|
-
export { createClient, flattenRoutes } from "./create.ts";
|
|
17
|
-
export type { AppWithRoutes } from "./create.ts";
|
|
16
|
+
export { createClient, flattenRoutes, transportOf } from "./create.ts";
|
|
17
|
+
export type { AppWithRoutes, TransportBag } from "./create.ts";
|
|
18
18
|
export { isErrorCode, isFail, isOk, isTransportError } from "./errors.ts";
|
|
19
19
|
export { isPagerMeta, pagerLink } from "./pager.ts";
|
|
20
20
|
export type { ClientPager, PagerLink } from "./pager.ts";
|
|
21
|
+
export {
|
|
22
|
+
applyOptimisticPatch,
|
|
23
|
+
clearOptimisticPatch,
|
|
24
|
+
isStaleUpsert,
|
|
25
|
+
reduceLiveQueryRows,
|
|
26
|
+
} from "./use-live-query.ts";
|
|
27
|
+
export type { LiveQueryError, LiveQueryEvent } from "./use-live-query.ts";
|
|
21
28
|
export { createTransport } from "./transport.ts";
|
|
22
29
|
export type { Transport } from "./transport.ts";
|
|
23
30
|
export type {
|
package/src/client/transport.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Optional `routes` map switches to REST (`method` + path template).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { ClientEnvelope, ClientFetch, ClientOptions } from "./types.ts";
|
|
8
|
+
import type { ClientEnvelope, ClientFetch, ClientHeaders, ClientOptions } from "./types.ts";
|
|
9
9
|
|
|
10
10
|
/** Internal transport handle. */
|
|
11
11
|
export interface Transport {
|
|
@@ -14,8 +14,9 @@ export interface Transport {
|
|
|
14
14
|
*
|
|
15
15
|
* @param key - `unit/flow`
|
|
16
16
|
* @param input - JSON body / path-param source
|
|
17
|
+
* @param headers - Per-call extra headers (merged after static ones)
|
|
17
18
|
*/
|
|
18
|
-
call(key: string, input: unknown): Promise<ClientEnvelope>;
|
|
19
|
+
call(key: string, input: unknown, headers?: ClientHeaders): Promise<ClientEnvelope>;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -31,14 +32,14 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
|
|
|
31
32
|
const backoff = opts.retry?.backoff ?? 2;
|
|
32
33
|
|
|
33
34
|
return {
|
|
34
|
-
async call(key, input) {
|
|
35
|
+
async call(key, input, callHeaders) {
|
|
35
36
|
let refreshed = false;
|
|
36
37
|
let attempt = 0;
|
|
37
38
|
let delay = delay0;
|
|
38
39
|
|
|
39
40
|
for (;;) {
|
|
40
41
|
try {
|
|
41
|
-
const res = await once(base, key, input, opts, fetchFn);
|
|
42
|
+
const res = await once(base, key, input, opts, fetchFn, callHeaders);
|
|
42
43
|
if (res.status === 401 && opts.auth?.refresh && !refreshed) {
|
|
43
44
|
refreshed = true;
|
|
44
45
|
await opts.auth.refresh();
|
|
@@ -88,6 +89,7 @@ async function once(
|
|
|
88
89
|
input: unknown,
|
|
89
90
|
opts: ClientOptions,
|
|
90
91
|
fetchFn: ClientFetch,
|
|
92
|
+
callHeaders?: ClientHeaders,
|
|
91
93
|
): Promise<Response> {
|
|
92
94
|
const route = opts.routes?.[key.replace("/", ".")];
|
|
93
95
|
const { url, method, body } = route
|
|
@@ -101,6 +103,11 @@ async function once(
|
|
|
101
103
|
} else if (extra) {
|
|
102
104
|
for (const [k, v] of Object.entries(extra)) headers.set(k, v);
|
|
103
105
|
}
|
|
106
|
+
if (Array.isArray(callHeaders)) {
|
|
107
|
+
for (const [k, v] of callHeaders) headers.set(k, v);
|
|
108
|
+
} else if (callHeaders) {
|
|
109
|
+
for (const [k, v] of Object.entries(callHeaders)) headers.set(k, v);
|
|
110
|
+
}
|
|
104
111
|
if (body !== undefined && !headers.has("content-type")) {
|
|
105
112
|
headers.set("content-type", "application/json");
|
|
106
113
|
}
|