okengine 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +7 -0
- package/manifest.v1.schema.json +44 -0
- package/package.json +32 -19
- package/site/content/docs/ai/mcp.mdx +14 -10
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/elements/ai/agents.mdx +61 -13
- package/site/content/docs/elements/ai/decide.mdx +536 -0
- package/site/content/docs/elements/ai/events.mdx +373 -0
- package/site/content/docs/elements/ai/index.mdx +9 -3
- package/site/content/docs/elements/ai/meta.json +1 -1
- package/site/content/docs/elements/ai/prompts.mdx +4 -2
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/gate/auth.mdx +4 -1
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/elements/store/index.mdx +1 -1
- package/site/content/docs/elements/store/kv.mdx +5 -5
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/recipes/dragonfly.mdx +5 -5
- package/site/content/docs/recipes/index.mdx +6 -6
- package/site/content/docs/recipes/postgres.mdx +12 -8
- package/site/content/docs/recipes/redis.mdx +4 -4
- package/site/content/docs/reference/cli.mdx +2 -1
- package/site/content/docs/reference/configuration.mdx +9 -7
- package/site/content/docs/reference/fx.mdx +19 -10
- package/src/auth/api-key-sql.test.ts +44 -0
- package/src/auth/api-key-sql.ts +1 -0
- package/src/auth/api-keys.ts +30 -12
- package/src/auth/config.ts +6 -2
- package/src/auth/gate-auth.test.ts +20 -0
- package/src/auth/tenants.ts +17 -0
- package/src/cli/agents-md.test.ts +25 -0
- package/src/cli/ask-vault-gaps.test.ts +38 -1
- package/src/cli/ask-vault-gaps.ts +35 -2
- package/src/cli/decide.ts +174 -0
- package/src/cli/decision-lock-watch.test.ts +45 -0
- package/src/cli/decision-lock-watch.ts +51 -0
- package/src/cli/dev-app-runner.ts +34 -8
- package/src/cli/dev-auth-secret.test.ts +49 -0
- package/src/cli/dev-auth-secret.ts +49 -0
- package/src/cli/dev-hot.test.ts +41 -0
- package/src/cli/dev-hot.ts +48 -0
- package/src/cli/dev.test.ts +1 -1
- package/src/cli/dev.ts +24 -6
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/eval.ts +142 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/load-config.images.test.ts +2 -2
- package/src/cli/load-config.ts +1 -1
- package/src/cli/manifest-pr-diff.ts +16 -0
- package/src/cli/mcp-from-console.ts +48 -0
- package/src/cli/registry.ts +35 -1
- package/src/client/agent.ts +307 -0
- package/src/client/transport.test.ts +20 -0
- package/src/client/transport.ts +2 -1
- package/src/client/types.ts +1 -1
- package/src/client-react/index.ts +7 -1
- package/src/client-react/use-agent-run.test.ts +271 -0
- package/src/client-react/use-agent-run.ts +193 -0
- package/src/compiler/ai-approval.test.ts +36 -0
- package/src/compiler/ai-repair.test.ts +22 -0
- package/src/compiler/decisions.extract.test.ts +164 -0
- package/src/compiler/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +114 -17
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +279 -13
- package/src/compiler/fx-index.ts +58 -4
- package/src/compiler/response.ts +35 -2
- package/src/config/index.ts +21 -3
- package/src/console/index.ts +1 -1
- package/src/console/server/ai-runs-flows.ts +269 -0
- package/src/console/server/ai-runs.test.ts +323 -0
- package/src/console/server/ai-runs.ts +562 -0
- package/src/console/server/ai.ts +12 -1
- package/src/console/server/app.ts +10 -1
- package/src/console/server/decisions-flows.ts +157 -0
- package/src/console/server/decisions.test.ts +66 -0
- package/src/console/server/decisions.ts +229 -0
- package/src/console/server/flows.ts +50 -2
- package/src/console/server/invoke-user-flow.ts +9 -0
- package/src/console/server/runs-ingest.ts +16 -0
- package/src/console/server/serve.ts +5 -0
- package/src/console/server/state.ts +25 -1
- package/src/console/server/store-stats.test.ts +47 -0
- package/src/console/server/store-stats.ts +92 -18
- package/src/console/ui-next/dist/assets/access-page-C_N4qvai.js +4 -0
- package/src/console/ui-next/dist/assets/{agent-disclosure-Bkohc_8X.js → agent-disclosure-Cq1TxniW.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-rL0lHxat.js → cache-glyph-BaI4uxZz.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-Deo4PP18.js → call-pii-button-DNyK7IIY.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-CoJ6amHf.js → collapsible-CRhBJMZR.js} +1 -1
- package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
- package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
- package/src/console/ui-next/dist/assets/decisions-page-D9twj8Xy.js +1 -0
- package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
- package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
- package/src/console/ui-next/dist/assets/duration-tone-CsHWlVPU.js +9 -0
- package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-ZaTJAhR9.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CPYBzDh0.js → highlighted-json-DQ-EMAfz.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DTPevKmM.js → http-method-Cn_Fl79s.js} +1 -1
- package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
- package/src/console/ui-next/dist/assets/index-CmRFeWdT.js +58 -0
- package/src/console/ui-next/dist/assets/observability-page-Cn6R_dXz.js +8 -0
- package/src/console/ui-next/dist/assets/{replica-lag-BM27lXld.js → replica-lag-Bx6EZhSe.js} +7 -7
- package/src/console/ui-next/dist/assets/request-meta-DmdiUrfh.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-8P6vGXnn.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-BfGvpFSv.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-CWdUHbc0.js +55 -0
- package/src/console/ui-next/dist/assets/units-page-CZOFZzay.js +1 -0
- package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
- package/src/console/ui-next/dist/assets/vault-page-ki_ishmX.js +2 -0
- package/src/console/ui-next/dist/index.html +4 -3
- package/src/console/ui-next/seed-parked-approval.ts +126 -0
- package/src/console/ui-next/src/client.ts +224 -1
- package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
- package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
- package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
- package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
- package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
- package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
- package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
- package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
- package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
- package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
- package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
- package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
- package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
- package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
- package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
- package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
- package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
- package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
- package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
- package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
- package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
- package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
- package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
- package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
- package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
- package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +6 -2
- package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
- package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
- package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
- package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
- package/src/console/ui-next/src/router.tsx +17 -0
- package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
- package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
- package/src/console/ui-next/vite.config.ts +9 -0
- package/src/docker/recipes/dragonfly.ts +3 -2
- package/src/docker/recipes/redis.ts +1 -1
- package/src/drivers/ai-anthropic.ts +212 -21
- package/src/drivers/ai-mock.ts +34 -1
- package/src/drivers/ai-openai-compatible.ts +67 -26
- package/src/drivers/ai-preconnect.ts +18 -0
- package/src/drivers/ai-stream.test.ts +62 -0
- package/src/drivers/ai-types.ts +20 -0
- package/src/drivers/journal-postgres.test.ts +22 -1
- package/src/drivers/journal-postgres.ts +511 -13
- package/src/drivers/memory-ddl.test.ts +18 -0
- package/src/drivers/memory.ts +21 -1
- package/src/drivers/pg-rls.test.ts +31 -0
- package/src/drivers/pg-rls.ts +15 -0
- package/src/drivers/postgres.test.ts +9 -0
- package/src/drivers/postgres.ts +49 -11
- package/src/drivers/signal-compete.test.ts +234 -0
- package/src/drivers/signal-postgres.ts +39 -19
- package/src/drivers/signal-redis.ts +167 -11
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/agent-event-slot.ts +27 -0
- package/src/elements/ai/approval-http.ts +207 -0
- package/src/elements/ai/approval.test.ts +741 -0
- package/src/elements/ai/approval.ts +228 -0
- package/src/elements/ai/decisions/bind.ts +163 -0
- package/src/elements/ai/decisions/certificate.test.ts +291 -0
- package/src/elements/ai/decisions/certificate.ts +377 -0
- package/src/elements/ai/decisions/certify.ts +314 -0
- package/src/elements/ai/decisions/decide.test.ts +953 -0
- package/src/elements/ai/decisions/e2e.test.ts +399 -0
- package/src/elements/ai/decisions/export.test.ts +120 -0
- package/src/elements/ai/decisions/export.ts +146 -0
- package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
- package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
- package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
- package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
- package/src/elements/ai/decisions/http.test.ts +154 -0
- package/src/elements/ai/decisions/http.ts +240 -0
- package/src/elements/ai/decisions/labels.test.ts +282 -0
- package/src/elements/ai/decisions/labels.ts +257 -0
- package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
- package/src/elements/ai/decisions/openrouter.ts +36 -0
- package/src/elements/ai/decisions/provider.ts +102 -0
- package/src/elements/ai/decisions/typesafe.ts +36 -0
- package/src/elements/ai/declare.ts +252 -1
- package/src/elements/ai/events.test.ts +379 -0
- package/src/elements/ai/events.ts +138 -0
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/run-events.test.ts +461 -0
- package/src/elements/ai/run-events.ts +522 -0
- package/src/elements/ai/runtime.ts +1187 -105
- package/src/elements/ai/stream-turn.ts +214 -0
- package/src/elements/ai/stream.live.test.ts +76 -0
- package/src/elements/ai/subagent.test.ts +136 -0
- package/src/elements/ai.test.ts +497 -3
- package/src/elements/ai.ts +16 -1
- package/src/elements/channel/runtime.ts +11 -0
- package/src/elements/channel/sql-ledger.ts +251 -0
- package/src/elements/clock/durable.ts +27 -1
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache.test.ts +120 -15
- package/src/elements/store/cache.ts +200 -39
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/sql-session.test.ts +53 -0
- package/src/elements/store/sql-session.ts +206 -10
- package/src/elements/store.ts +1 -0
- package/src/i18n/catalogs/ar.ts +5 -0
- package/src/i18n/catalogs/en.ts +5 -0
- package/src/index.ts +2 -0
- package/src/kernel/agent-event-store.ts +448 -0
- package/src/kernel/app.ts +135 -17
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/boot-bind/ai.ts +3 -0
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +61 -25
- package/src/kernel/boot-bind/store.test.ts +26 -0
- package/src/kernel/boot-bind/store.ts +82 -6
- package/src/kernel/boot.ts +31 -6
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/capability.ts +3 -0
- package/src/kernel/decision-label-store.ts +441 -0
- package/src/kernel/effects.test.ts +2 -1
- package/src/kernel/effects.ts +13 -1
- package/src/kernel/element-registries.ts +3 -0
- package/src/kernel/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +20 -0
- package/src/kernel/errors.ts +10 -0
- package/src/kernel/flow.test.ts +2 -3
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +765 -0
- package/src/kernel/fx-sql-handle.ts +46 -7
- package/src/kernel/fx.test.ts +13 -8
- package/src/kernel/fx.ts +339 -45
- package/src/kernel/http-frame.test.ts +94 -0
- package/src/kernel/http-frame.ts +203 -0
- package/src/kernel/idempotency.test.ts +2 -1
- package/src/kernel/journal.test.ts +114 -0
- package/src/kernel/journal.ts +471 -51
- package/src/kernel/json-result.ts +10 -0
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.test.ts +45 -0
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/sse-id.ts +32 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/manifest/diff.ts +1 -0
- package/src/manifest/types.ts +29 -2
- package/src/mcp/ai-tools.test.ts +202 -0
- package/src/mcp/authorization.ts +66 -0
- package/src/mcp/docs-server.ts +19 -2
- package/src/mcp/mcp.test.ts +83 -0
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +23 -2
- package/src/mcp/session.ts +3 -0
- package/src/mcp/tools.ts +168 -0
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/release/http-graph.test.ts +2 -0
- package/src/release/http-graph.ts +5 -0
- package/src/release/measure.ts +39 -14
- package/src/runs/collect.ts +4 -1
- package/src/runs/parquet.test.ts +27 -1
- package/src/runs/parquet.ts +19 -0
- package/src/runs/types.ts +31 -0
- package/src/runtime/json-code-block.test.ts +106 -47
- package/src/runtime/json-code-block.ts +1209 -46
- package/src/term.test.ts +13 -0
- package/src/term.ts +81 -49
- package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
- package/src/console/ui-next/dist/assets/access-page-Ze6ckjdd.js +0 -4
- package/src/console/ui-next/dist/assets/copy-inline-button-CWG5_ZOh.js +0 -1
- package/src/console/ui-next/dist/assets/detail-header-b_ZzIsmU.js +0 -1
- package/src/console/ui-next/dist/assets/dropdown-menu-nwXEO1Ac.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +0 -1
- package/src/console/ui-next/dist/assets/explorer-empty-C8sSCqYT.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-D2E7BtKK.js +0 -1
- package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
- package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +0 -58
- package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +0 -4
- package/src/console/ui-next/dist/assets/request-meta-BjFT7DNl.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +0 -41
- package/src/console/ui-next/dist/assets/trace-detail-sheet-Cmitq3da.js +0 -2
- package/src/console/ui-next/dist/assets/tree-expand-toggle-CbDIB-7x.js +0 -55
- package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +0 -1
- package/src/console/ui-next/dist/assets/use-vault-list-Cs45Czkt.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-BOMr0go9.js +0 -2
package/src/drivers/memory.ts
CHANGED
|
@@ -503,7 +503,7 @@ function createMemorySqlConnection(role: "primary" | "replica"): SqlConnection {
|
|
|
503
503
|
}
|
|
504
504
|
|
|
505
505
|
const create =
|
|
506
|
-
/^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*\((.+)\)\s*$/
|
|
506
|
+
/^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*\((.+)\)\s*$/is.exec(
|
|
507
507
|
text,
|
|
508
508
|
);
|
|
509
509
|
if (create) {
|
|
@@ -519,6 +519,26 @@ function createMemorySqlConnection(role: "primary" | "replica"): SqlConnection {
|
|
|
519
519
|
return { changes: 0 };
|
|
520
520
|
}
|
|
521
521
|
|
|
522
|
+
const addColumn =
|
|
523
|
+
/^ALTER\s+TABLE\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s+ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\b/is.exec(
|
|
524
|
+
text,
|
|
525
|
+
);
|
|
526
|
+
if (addColumn) {
|
|
527
|
+
const name = parseIdent(addColumn[1]!);
|
|
528
|
+
const column = parseIdent(addColumn[2]!);
|
|
529
|
+
const table = tables.get(name);
|
|
530
|
+
if (table && !table.columns.includes(column)) table.columns.push(column);
|
|
531
|
+
return { changes: 0 };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Scan results do not depend on indexes. Identity and vault DDL still
|
|
535
|
+
// issue CREATE INDEX IF NOT EXISTS on the shared memory connection.
|
|
536
|
+
const createIndex =
|
|
537
|
+
/^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?:"?[a-zA-Z_][a-zA-Z0-9_]*"?)\s+ON\s+/is.exec(
|
|
538
|
+
text,
|
|
539
|
+
);
|
|
540
|
+
if (createIndex) return { changes: 0 };
|
|
541
|
+
|
|
522
542
|
const insert =
|
|
523
543
|
/^INSERT\s+INTO\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)\s*$/i.exec(
|
|
524
544
|
text,
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
buildRlsIdentityPreludeSql,
|
|
6
6
|
emitPgPolicySource,
|
|
7
7
|
formatPolicyRole,
|
|
8
|
+
OKE_PG_STAT_STATEMENTS_SQL,
|
|
8
9
|
OKE_RLS_HELPER_STATEMENTS,
|
|
9
10
|
OKE_RLS_ROLE,
|
|
10
11
|
parseSqlPolicySpec,
|
|
@@ -149,6 +150,36 @@ describe("OKE_RLS_HELPER_STATEMENTS", () => {
|
|
|
149
150
|
expect(hasScope).not.toMatch(/jsonb\s+\?/);
|
|
150
151
|
});
|
|
151
152
|
|
|
153
|
+
test("initial oke SQL creates pg_stat_statements before helpers", async () => {
|
|
154
|
+
const ran: string[] = [];
|
|
155
|
+
const { installOkeRlsHelpers } = await import("./pg-rls.ts");
|
|
156
|
+
await installOkeRlsHelpers(async (sql) => {
|
|
157
|
+
ran.push(sql);
|
|
158
|
+
if (sql.startsWith("CREATE EXTENSION")) return;
|
|
159
|
+
if (sql.includes("pg_vault") || sql.includes("vault.")) {
|
|
160
|
+
throw new Error("stop before vault attach");
|
|
161
|
+
}
|
|
162
|
+
}).catch(() => undefined);
|
|
163
|
+
expect(ran[0]).toBe(OKE_PG_STAT_STATEMENTS_SQL);
|
|
164
|
+
expect(ran[1]).toBe(OKE_RLS_HELPER_STATEMENTS[0]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a rejected pg_stat_statements create still installs helpers", async () => {
|
|
168
|
+
const ran: string[] = [];
|
|
169
|
+
const { installOkeRlsHelpers } = await import("./pg-rls.ts");
|
|
170
|
+
await installOkeRlsHelpers(async (sql) => {
|
|
171
|
+
ran.push(sql);
|
|
172
|
+
if (sql.startsWith("CREATE EXTENSION")) {
|
|
173
|
+
throw new Error('extension "pg_stat_statements" is not available');
|
|
174
|
+
}
|
|
175
|
+
if (sql.includes("CREATE OR REPLACE FUNCTION oke.gate")) {
|
|
176
|
+
throw new Error("stop");
|
|
177
|
+
}
|
|
178
|
+
}).catch(() => undefined);
|
|
179
|
+
expect(ran[0]).toBe(OKE_PG_STAT_STATEMENTS_SQL);
|
|
180
|
+
expect(ran.some((sql) => sql.includes("CREATE SCHEMA IF NOT EXISTS oke"))).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
152
183
|
test("pins search_path to public so schema oke is not the table home", () => {
|
|
153
184
|
expect(OKE_RLS_HELPER_STATEMENTS).toContain("SET search_path TO public, oke");
|
|
154
185
|
expect(
|
package/src/drivers/pg-rls.ts
CHANGED
|
@@ -135,12 +135,27 @@ $oke_app_search_path$`,
|
|
|
135
135
|
*/
|
|
136
136
|
export const OKE_RLS_HELPER_SQL = `${OKE_RLS_HELPER_STATEMENTS.join(";\n")};`;
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Enable query telemetry on postgres. Preload is still required
|
|
140
|
+
* (`shared_preload_libraries=pg_stat_statements`). PGlite and Cockroach
|
|
141
|
+
* reject this; callers swallow that failure so the rest of the oke SQL runs.
|
|
142
|
+
*/
|
|
143
|
+
export const OKE_PG_STAT_STATEMENTS_SQL = "CREATE EXTENSION IF NOT EXISTS pg_stat_statements";
|
|
144
|
+
|
|
138
145
|
/**
|
|
139
146
|
* Install `oke.*` helpers one statement at a time.
|
|
140
147
|
*
|
|
148
|
+
* `pg_stat_statements` is created first when the engine allows it, so Store
|
|
149
|
+
* performance does not depend on a later manual `CREATE EXTENSION`.
|
|
150
|
+
*
|
|
141
151
|
* @param exec - Statement runner
|
|
142
152
|
*/
|
|
143
153
|
export async function installOkeRlsHelpers(exec: (sql: string) => Promise<unknown>): Promise<void> {
|
|
154
|
+
try {
|
|
155
|
+
await exec(OKE_PG_STAT_STATEMENTS_SQL);
|
|
156
|
+
} catch {
|
|
157
|
+
// Not preloaded, or this engine has no pg_stat_statements (pglite, cockroach).
|
|
158
|
+
}
|
|
144
159
|
for (const stmt of OKE_RLS_HELPER_STATEMENTS) {
|
|
145
160
|
await exec(stmt);
|
|
146
161
|
}
|
|
@@ -38,6 +38,15 @@ describe("toPostgresParams", () => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
describe("closeSharedPostgresClients", () => {
|
|
41
|
+
test("a different max is a different pool on the same URL", () => {
|
|
42
|
+
const url = "postgres://127.0.0.1:9/oke-pool-max-shared-test";
|
|
43
|
+
const small = sharedPostgresClient(url, 8);
|
|
44
|
+
const large = sharedPostgresClient(url, 20);
|
|
45
|
+
expect(large).not.toBe(small);
|
|
46
|
+
expect(sharedPostgresClient(url, 20)).toBe(large);
|
|
47
|
+
expect(sharedPostgresClient(url)).toBe(small);
|
|
48
|
+
});
|
|
49
|
+
|
|
41
50
|
test("drops cached pools so the next checkout is a new client", async () => {
|
|
42
51
|
const url = "postgres://127.0.0.1:9/oke-close-shared-test";
|
|
43
52
|
const first = sharedPostgresClient(url);
|
package/src/drivers/postgres.ts
CHANGED
|
@@ -43,7 +43,27 @@ type SharedPostgresEntry = {
|
|
|
43
43
|
readonly guarded: PostgresClientLike;
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Process-wide pool registry.
|
|
48
|
+
*
|
|
49
|
+
* `bun --hot` re-evaluates this module on each save. A module-local `Map`
|
|
50
|
+
* would drop the only handle to the previous Bun.SQL pools and leave them
|
|
51
|
+
* connected. PgDog then queues every checkout — Console login included —
|
|
52
|
+
* until its server pool times out.
|
|
53
|
+
*/
|
|
54
|
+
const SHARED_POSTGRES_CLIENTS = Symbol.for("oke.sharedPostgresClients");
|
|
55
|
+
|
|
56
|
+
function sharedClientMap(): Map<string, SharedPostgresEntry> {
|
|
57
|
+
const g = globalThis as typeof globalThis & {
|
|
58
|
+
[SHARED_POSTGRES_CLIENTS]?: Map<string, SharedPostgresEntry>;
|
|
59
|
+
};
|
|
60
|
+
let map = g[SHARED_POSTGRES_CLIENTS];
|
|
61
|
+
if (!map) {
|
|
62
|
+
map = new Map();
|
|
63
|
+
g[SHARED_POSTGRES_CLIENTS] = map;
|
|
64
|
+
}
|
|
65
|
+
return map;
|
|
66
|
+
}
|
|
47
67
|
|
|
48
68
|
/**
|
|
49
69
|
* When true, shared pools stay closed and every query fails fast — no Bun.SQL
|
|
@@ -160,21 +180,38 @@ function guardSharedPostgresClient(raw: PostgresClientLike): PostgresClientLike
|
|
|
160
180
|
}
|
|
161
181
|
|
|
162
182
|
/**
|
|
163
|
-
*
|
|
183
|
+
* Bun.SQL pool size. Unset, non-finite, or below 1 keeps {@link POSTGRES_POOL_MAX}.
|
|
184
|
+
* A configured max is not capped — callers that need a larger pool ask for it.
|
|
185
|
+
*
|
|
186
|
+
* @param poolMax - Requested `max`
|
|
187
|
+
*/
|
|
188
|
+
function postgresPoolMax(poolMax: number | undefined): number {
|
|
189
|
+
if (poolMax === undefined || !Number.isFinite(poolMax) || poolMax < 1) return POSTGRES_POOL_MAX;
|
|
190
|
+
return Math.floor(poolMax);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* One Bun.SQL pool per URL and `max` for store / journal / clock / vault / console.
|
|
164
195
|
*
|
|
165
196
|
* Returns a pause-aware client — holders (including fleet store wrappers)
|
|
166
197
|
* fail soft after {@link pauseSharedPostgresClients} instead of reconnecting.
|
|
167
198
|
*
|
|
168
199
|
* @param url - Connection URL
|
|
200
|
+
* @param poolMax - Bun.SQL `max`. Omitted uses {@link POSTGRES_POOL_MAX}. A
|
|
201
|
+
* different max is a different pool — a store pin of 20 must not reuse a
|
|
202
|
+
* pool another subsystem already opened at the default.
|
|
169
203
|
*/
|
|
170
|
-
export function sharedPostgresClient(url?: string): PostgresClientLike {
|
|
204
|
+
export function sharedPostgresClient(url?: string, poolMax?: number): PostgresClientLike {
|
|
171
205
|
assertSharedPostgresReady();
|
|
172
|
-
const
|
|
173
|
-
const
|
|
206
|
+
const resolved = resolvePostgresUrl(url);
|
|
207
|
+
const max = postgresPoolMax(poolMax);
|
|
208
|
+
const key = `${resolved}\0${String(max)}`;
|
|
209
|
+
const clients = sharedClientMap();
|
|
210
|
+
const existing = clients.get(key);
|
|
174
211
|
if (existing) return existing.guarded;
|
|
175
|
-
const raw = new Bun.SQL(
|
|
212
|
+
const raw = new Bun.SQL(resolved, { max }) as unknown as PostgresClientLike;
|
|
176
213
|
const guarded = guardSharedPostgresClient(raw);
|
|
177
|
-
|
|
214
|
+
clients.set(key, { raw, guarded });
|
|
178
215
|
return guarded;
|
|
179
216
|
}
|
|
180
217
|
|
|
@@ -185,8 +222,8 @@ export function sharedPostgresClient(url?: string): PostgresClientLike {
|
|
|
185
222
|
* not stop Console from opening a fresh pool on the next poll.
|
|
186
223
|
*/
|
|
187
224
|
export async function closeSharedPostgresClients(): Promise<void> {
|
|
188
|
-
const clients = [...
|
|
189
|
-
|
|
225
|
+
const clients = [...sharedClientMap().values()];
|
|
226
|
+
sharedClientMap().clear();
|
|
190
227
|
await Promise.all(
|
|
191
228
|
clients.map(async (entry) => {
|
|
192
229
|
try {
|
|
@@ -283,12 +320,13 @@ export async function connectPostgres(options: SqlConnectOptions = {}): Promise<
|
|
|
283
320
|
// must not share a pooled client — Bun raises ERR_POSTGRES_UNSAFE_TRANSACTION
|
|
284
321
|
// when raw statements interleave with open transaction state on the shared
|
|
285
322
|
// pool. Opt in explicitly; default behavior is unchanged.
|
|
286
|
-
const
|
|
323
|
+
const requestedMax = options.pool?.max;
|
|
324
|
+
const dedicated = !injected && requestedMax === 1;
|
|
287
325
|
const client =
|
|
288
326
|
injected ??
|
|
289
327
|
(dedicated
|
|
290
328
|
? dedicatedPostgresClient(resolvePostgresUrl(options.url))
|
|
291
|
-
: sharedPostgresClient(options.url));
|
|
329
|
+
: sharedPostgresClient(options.url, requestedMax));
|
|
292
330
|
return wrapPostgresClient(client, role, {
|
|
293
331
|
shared: injected === undefined && !dedicated,
|
|
294
332
|
...(options.url !== undefined ? { url: options.url } : {}),
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two consumers, one bus: postgres SKIP LOCKED and redis consumer groups.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { signal } from "../elements/signal.ts";
|
|
7
|
+
import {
|
|
8
|
+
openPostgresChannelLedger,
|
|
9
|
+
type ChannelLedgerSql,
|
|
10
|
+
} from "../elements/channel/sql-ledger.ts";
|
|
11
|
+
import { createPostgresSignalFake, openPostgresSignal } from "./signal-postgres.ts";
|
|
12
|
+
import {
|
|
13
|
+
createBunSignalRedisClient,
|
|
14
|
+
createSignalRedisFake,
|
|
15
|
+
openRedisSignal,
|
|
16
|
+
} from "./signal-redis.ts";
|
|
17
|
+
|
|
18
|
+
function decls(name: string, retries = 3) {
|
|
19
|
+
const decl = signal.once(name, { retries, deadLetter: true });
|
|
20
|
+
return new Map([[decl.name, decl]]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("postgres signal competing consumers", () => {
|
|
24
|
+
test("one message is delivered once across two buses", async () => {
|
|
25
|
+
const sql = createPostgresSignalFake();
|
|
26
|
+
const name = "order-placed";
|
|
27
|
+
const a = await openPostgresSignal({ signals: decls(name), sql });
|
|
28
|
+
const b = await openPostgresSignal({ signals: decls(name), sql });
|
|
29
|
+
const got: string[] = [];
|
|
30
|
+
await a.subscribe(name, "a", async () => {
|
|
31
|
+
got.push("a");
|
|
32
|
+
});
|
|
33
|
+
await b.subscribe(name, "b", async () => {
|
|
34
|
+
got.push("b");
|
|
35
|
+
});
|
|
36
|
+
await a.emit(name, { id: "1" });
|
|
37
|
+
await Bun.sleep(5);
|
|
38
|
+
await a.drain();
|
|
39
|
+
await b.drain();
|
|
40
|
+
expect(got).toHaveLength(1);
|
|
41
|
+
await a.close();
|
|
42
|
+
await b.close();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("redis streams competing consumers", () => {
|
|
47
|
+
test("one message is delivered once across two groups", async () => {
|
|
48
|
+
const redis = createSignalRedisFake();
|
|
49
|
+
const name = "order-placed";
|
|
50
|
+
const shared = { signals: decls(name), redis, compete: true as const };
|
|
51
|
+
const a = await openRedisSignal({ ...shared, consumerId: "a" });
|
|
52
|
+
const b = await openRedisSignal({ ...shared, consumerId: "b" });
|
|
53
|
+
const got: string[] = [];
|
|
54
|
+
await a.subscribe(name, "a", async () => {
|
|
55
|
+
got.push("a");
|
|
56
|
+
});
|
|
57
|
+
await b.subscribe(name, "b", async () => {
|
|
58
|
+
got.push("b");
|
|
59
|
+
});
|
|
60
|
+
await a.emit(name, { id: "1" });
|
|
61
|
+
await a.drain();
|
|
62
|
+
await b.drain();
|
|
63
|
+
expect(got).toHaveLength(1);
|
|
64
|
+
await a.close();
|
|
65
|
+
await b.close();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a failed handler leaves the entry pending for redelivery", async () => {
|
|
69
|
+
const redis = createSignalRedisFake();
|
|
70
|
+
const name = "flaky";
|
|
71
|
+
const bus = await openRedisSignal({
|
|
72
|
+
signals: decls(name, 2),
|
|
73
|
+
redis,
|
|
74
|
+
compete: true,
|
|
75
|
+
consumerId: "a",
|
|
76
|
+
});
|
|
77
|
+
let calls = 0;
|
|
78
|
+
await bus.subscribe(name, "a", async () => {
|
|
79
|
+
calls += 1;
|
|
80
|
+
if (calls === 1) throw new Error("boom");
|
|
81
|
+
});
|
|
82
|
+
await bus.emit(name, { id: "1" });
|
|
83
|
+
await bus.drain();
|
|
84
|
+
expect(calls).toBe(1);
|
|
85
|
+
await bus.drain();
|
|
86
|
+
expect(calls).toBe(2);
|
|
87
|
+
await bus.close();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("another consumer reclaims an unacked entry", async () => {
|
|
91
|
+
const redis = createSignalRedisFake();
|
|
92
|
+
const name = "handoff";
|
|
93
|
+
const shared = { signals: decls(name, 3), redis, compete: true as const };
|
|
94
|
+
const a = await openRedisSignal({ ...shared, consumerId: "a" });
|
|
95
|
+
const b = await openRedisSignal({ ...shared, consumerId: "b" });
|
|
96
|
+
let aCalls = 0;
|
|
97
|
+
const got: string[] = [];
|
|
98
|
+
await a.subscribe(name, "a", async () => {
|
|
99
|
+
aCalls += 1;
|
|
100
|
+
throw new Error("down");
|
|
101
|
+
});
|
|
102
|
+
await b.subscribe(name, "b", async () => {
|
|
103
|
+
got.push("b");
|
|
104
|
+
});
|
|
105
|
+
await a.emit(name, { id: "1" });
|
|
106
|
+
await a.drain();
|
|
107
|
+
await b.drain();
|
|
108
|
+
expect(aCalls).toBe(1);
|
|
109
|
+
expect(got).toEqual(["b"]);
|
|
110
|
+
await a.close();
|
|
111
|
+
await b.close();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test.skipIf(!process.env.REDIS_URL)("REDIS_URL: two clients ack once", async () => {
|
|
115
|
+
const url = process.env.REDIS_URL;
|
|
116
|
+
const name = `oke-compete-${Date.now()}`;
|
|
117
|
+
const redisA = createBunSignalRedisClient(url);
|
|
118
|
+
const redisB = createBunSignalRedisClient(url);
|
|
119
|
+
const a = await openRedisSignal({
|
|
120
|
+
signals: decls(name),
|
|
121
|
+
redis: redisA,
|
|
122
|
+
compete: true,
|
|
123
|
+
consumerId: "a",
|
|
124
|
+
});
|
|
125
|
+
const b = await openRedisSignal({
|
|
126
|
+
signals: decls(name),
|
|
127
|
+
redis: redisB,
|
|
128
|
+
compete: true,
|
|
129
|
+
consumerId: "b",
|
|
130
|
+
});
|
|
131
|
+
const got: string[] = [];
|
|
132
|
+
await a.subscribe(name, "a", async () => {
|
|
133
|
+
got.push("a");
|
|
134
|
+
});
|
|
135
|
+
await b.subscribe(name, "b", async () => {
|
|
136
|
+
got.push("b");
|
|
137
|
+
});
|
|
138
|
+
await a.emit(name, { id: "1" });
|
|
139
|
+
await a.drain();
|
|
140
|
+
await b.drain();
|
|
141
|
+
expect(got).toHaveLength(1);
|
|
142
|
+
await a.close();
|
|
143
|
+
await b.close();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
function memoryLedgerSql(): ChannelLedgerSql {
|
|
148
|
+
const consent = new Map<string, { subject: string; medium: string; at: number }>();
|
|
149
|
+
const bounce = new Map<string, { subject: string; medium: string; at: number }>();
|
|
150
|
+
const receipts = new Map<string, string>();
|
|
151
|
+
|
|
152
|
+
function pair(params: readonly unknown[]): string {
|
|
153
|
+
return `${String(params[0])}\0${String(params[1])}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
async query(sql) {
|
|
158
|
+
if (sql.includes("oke_channel_consent")) {
|
|
159
|
+
return [...consent.values()];
|
|
160
|
+
}
|
|
161
|
+
if (sql.includes("oke_channel_bounce")) {
|
|
162
|
+
return [...bounce.values()];
|
|
163
|
+
}
|
|
164
|
+
if (sql.includes("oke_channel_receipt")) {
|
|
165
|
+
return [...receipts.entries()].map(([id, body]) => ({ id, body }));
|
|
166
|
+
}
|
|
167
|
+
return [];
|
|
168
|
+
},
|
|
169
|
+
async exec(sql, params = []) {
|
|
170
|
+
if (sql.startsWith("CREATE")) return { changes: 0 };
|
|
171
|
+
if (sql.includes("INSERT INTO oke_channel_consent")) {
|
|
172
|
+
consent.set(pair(params), {
|
|
173
|
+
subject: String(params[0]),
|
|
174
|
+
medium: String(params[1]),
|
|
175
|
+
at: Number(params[2]),
|
|
176
|
+
});
|
|
177
|
+
return { changes: 1 };
|
|
178
|
+
}
|
|
179
|
+
if (sql.includes("DELETE FROM oke_channel_consent")) {
|
|
180
|
+
consent.delete(pair(params));
|
|
181
|
+
return { changes: 1 };
|
|
182
|
+
}
|
|
183
|
+
if (sql.includes("INSERT INTO oke_channel_bounce")) {
|
|
184
|
+
bounce.set(pair(params), {
|
|
185
|
+
subject: String(params[0]),
|
|
186
|
+
medium: String(params[1]),
|
|
187
|
+
at: Number(params[2]),
|
|
188
|
+
});
|
|
189
|
+
return { changes: 1 };
|
|
190
|
+
}
|
|
191
|
+
if (sql.includes("DELETE FROM oke_channel_bounce")) {
|
|
192
|
+
bounce.delete(pair(params));
|
|
193
|
+
return { changes: 1 };
|
|
194
|
+
}
|
|
195
|
+
if (sql.includes("INSERT INTO oke_channel_receipt")) {
|
|
196
|
+
receipts.set(String(params[0]), String(params[1]));
|
|
197
|
+
return { changes: 1 };
|
|
198
|
+
}
|
|
199
|
+
if (sql.includes("UPDATE oke_channel_receipt")) {
|
|
200
|
+
receipts.set(String(params[1]), String(params[0]));
|
|
201
|
+
return { changes: 1 };
|
|
202
|
+
}
|
|
203
|
+
return { changes: 0 };
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
describe("channel ledger across instances", () => {
|
|
209
|
+
test("consent and suppression written on A are visible to B", async () => {
|
|
210
|
+
const sql = memoryLedgerSql();
|
|
211
|
+
const a = await openPostgresChannelLedger(sql);
|
|
212
|
+
const b = await openPostgresChannelLedger(sql);
|
|
213
|
+
a.suppression.optOut("a@b.c", "email");
|
|
214
|
+
a.suppression.addPriorBounce("c@d.e", "sms");
|
|
215
|
+
a.receipts.record({
|
|
216
|
+
id: "r1",
|
|
217
|
+
template: "note",
|
|
218
|
+
to: "a@b.c",
|
|
219
|
+
medium: "email",
|
|
220
|
+
status: "sent",
|
|
221
|
+
attempts: [],
|
|
222
|
+
at: 1,
|
|
223
|
+
});
|
|
224
|
+
await a.flush();
|
|
225
|
+
await b.reload();
|
|
226
|
+
expect(b.consent.isOptedOut("a@b.c", "email")).toBe(true);
|
|
227
|
+
expect(b.suppression.isSuppressed("c@d.e", "sms")).toEqual({
|
|
228
|
+
suppressed: true,
|
|
229
|
+
reason: "prior-bounce",
|
|
230
|
+
});
|
|
231
|
+
expect(b.receipts.all()).toHaveLength(1);
|
|
232
|
+
expect(b.receipts.all()[0]?.id).toBe("r1");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* `postgres` signal driver — transactional emit (dual-write fix).
|
|
3
3
|
*
|
|
4
4
|
* `fx.emit` inserts into the outbox on the caller's connection. Consumers
|
|
5
|
-
* claim with `FOR UPDATE SKIP LOCKED
|
|
5
|
+
* claim with `FOR UPDATE SKIP LOCKED`. Wakeups use `LISTEN` / `NOTIFY` when
|
|
6
|
+
* the client has them; otherwise the boot scheduler polls `drain`.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { SignalDecl } from "../elements/signal/declare.ts";
|
|
@@ -65,18 +66,19 @@ export interface PostgresSignalSql {
|
|
|
65
66
|
begin<T>(fn: (sql: PostgresSignalSql) => Promise<T>): Promise<T>;
|
|
66
67
|
/**
|
|
67
68
|
* LISTEN channel; returns unsubscribe.
|
|
69
|
+
* Optional — Bun.SQL has no listen API. Omit it and poll {@link SignalBus.drain}.
|
|
68
70
|
*
|
|
69
71
|
* @param channel - Channel name
|
|
70
72
|
* @param onNotify - Payload callback
|
|
71
73
|
*/
|
|
72
|
-
listen(channel: string, onNotify: (payload: string) => void): Promise<() => void>;
|
|
74
|
+
listen?(channel: string, onNotify: (payload: string) => void): Promise<() => void>;
|
|
73
75
|
/**
|
|
74
|
-
* NOTIFY channel.
|
|
76
|
+
* NOTIFY channel. Optional when the client cannot notify.
|
|
75
77
|
*
|
|
76
78
|
* @param channel - Channel name
|
|
77
79
|
* @param payload - Payload
|
|
78
80
|
*/
|
|
79
|
-
notify(channel: string, payload: string): Promise<void>;
|
|
81
|
+
notify?(channel: string, payload: string): Promise<void>;
|
|
80
82
|
close(): Promise<void>;
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -205,6 +207,9 @@ export function createPostgresSignalFake(options?: {
|
|
|
205
207
|
return true;
|
|
206
208
|
});
|
|
207
209
|
if (!row) return [];
|
|
210
|
+
// Hold the row before yielding so a concurrent claim skips it.
|
|
211
|
+
row.status = "inflight";
|
|
212
|
+
if (row.locked_by === null) row.locked_by = "claimed";
|
|
208
213
|
return [{ ...row }];
|
|
209
214
|
}
|
|
210
215
|
|
|
@@ -565,9 +570,18 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
565
570
|
return n;
|
|
566
571
|
}
|
|
567
572
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
573
|
+
if (typeof sql.listen === "function") {
|
|
574
|
+
unlisten = await sql.listen(CHANNEL, () => {
|
|
575
|
+
void drainQuiet();
|
|
576
|
+
});
|
|
577
|
+
} else if ((options.pollMs ?? 0) > 0) {
|
|
578
|
+
const timer = setInterval(() => {
|
|
579
|
+
void drainQuiet();
|
|
580
|
+
}, options.pollMs);
|
|
581
|
+
unlisten = () => {
|
|
582
|
+
clearInterval(timer);
|
|
583
|
+
};
|
|
584
|
+
}
|
|
571
585
|
|
|
572
586
|
function requireDecl(name: string): SignalDecl {
|
|
573
587
|
const decl = signals.get(name);
|
|
@@ -663,7 +677,9 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
663
677
|
await insertEmit(tx, e.signal, e.payload, e.options);
|
|
664
678
|
}
|
|
665
679
|
});
|
|
666
|
-
|
|
680
|
+
if (typeof sql.notify === "function") {
|
|
681
|
+
await sql.notify(CHANNEL, "commit");
|
|
682
|
+
}
|
|
667
683
|
},
|
|
668
684
|
async rollback() {
|
|
669
685
|
if (finished) throw new Error("transaction finished");
|
|
@@ -827,20 +843,24 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
827
843
|
for (const consumer of consumers) {
|
|
828
844
|
const decl = signals.get(consumer.signal);
|
|
829
845
|
if (decl?.delivery !== "once") continue;
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const attempts = Number(
|
|
846
|
+
const row = await sql.begin(async (tx) => {
|
|
847
|
+
const claimed = await tx.query(CLAIM_ONCE_SQL, [consumer.signal, t, t, t]);
|
|
848
|
+
const hit = claimed[0];
|
|
849
|
+
if (!hit) return undefined;
|
|
850
|
+
const attempts = Number(hit.attempts) + 1;
|
|
835
851
|
const leaseExpiresAt = t + leaseMs;
|
|
836
|
-
await
|
|
852
|
+
await tx.exec(
|
|
837
853
|
`UPDATE oke_signal_messages SET status = 'inflight', locked_by = ?, attempts = ?, lease_expires_at = ? WHERE id = ?`,
|
|
838
|
-
[consumer.subscriberId, attempts, leaseExpiresAt,
|
|
854
|
+
[consumer.subscriberId, attempts, leaseExpiresAt, hit.id],
|
|
839
855
|
);
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
856
|
+
hit.locked_by = consumer.subscriberId;
|
|
857
|
+
hit.attempts = attempts;
|
|
858
|
+
hit.status = "inflight";
|
|
859
|
+
hit.lease_expires_at = leaseExpiresAt;
|
|
860
|
+
return hit;
|
|
861
|
+
});
|
|
862
|
+
if (row) {
|
|
863
|
+
progress = true;
|
|
844
864
|
await deliverOnce(row, consumer);
|
|
845
865
|
}
|
|
846
866
|
}
|