okengine 0.13.0 → 0.15.2
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/manifest.v1.schema.json +7 -1
- package/package.json +4 -5
- package/site/content/docs/ai/mcp.mdx +27 -2
- package/site/content/docs/elements/ai.mdx +59 -11
- package/site/content/docs/elements/channel.mdx +1 -0
- package/site/content/docs/elements/clock.mdx +30 -12
- package/site/content/docs/elements/flow.mdx +9 -3
- package/site/content/docs/elements/gate.mdx +25 -22
- package/site/content/docs/elements/signal.mdx +71 -25
- package/site/content/docs/elements/store.mdx +87 -28
- package/site/content/docs/elements/vault.mdx +1 -0
- package/site/content/docs/get-started/basic-usage.mdx +22 -13
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/get-started/installation.mdx +2 -2
- package/site/content/docs/get-started/introduction.mdx +11 -3
- package/site/content/docs/get-started/meta.json +1 -1
- package/site/content/docs/get-started/project-structure.mdx +932 -0
- package/site/content/docs/reference/cli.md +13 -10
- package/site/content/docs/reference/client.mdx +25 -21
- package/site/content/docs/reference/fx.mdx +27 -22
- package/site/content/docs/reference/security.md +1 -1
- package/src/auth/bindings.ts +14 -5
- package/src/auth/cross-plane.ts +10 -3
- package/src/cli/ai-setup/ai-setup.test.ts +62 -0
- package/src/cli/ai-setup/apply.ts +32 -1
- package/src/cli/ai-setup/recommend.test.ts +25 -0
- package/src/cli/ai-setup/recommend.ts +8 -3
- package/src/cli/attach-host-console.test.ts +3 -2
- package/src/cli/attach-host-console.ts +3 -0
- package/src/cli/build.test.ts +4 -2
- package/src/cli/db-seed.ts +48 -15
- package/src/cli/db.test.ts +104 -0
- package/src/cli/db.ts +122 -10
- package/src/cli/dev-controls.test.ts +20 -0
- package/src/cli/dev-controls.ts +13 -0
- package/src/cli/dev.test.ts +15 -4
- package/src/cli/dev.ts +102 -29
- package/src/cli/ensure-drizzle-config.ts +2 -1
- package/src/cli/tui/DevLive.tsx +12 -2
- package/src/cli/tui/keys.test.ts +2 -1
- package/src/cli/tui/keys.ts +3 -2
- package/src/compiler/aot.test.ts +1 -1
- package/src/compiler/effects-infer.ts +58 -3
- package/src/compiler/extract.test.ts +458 -29
- package/src/compiler/extract.ts +387 -69
- package/src/compiler/flow-path.test.ts +96 -0
- package/src/compiler/flow-path.ts +173 -0
- package/src/compiler/generate-adopt.test.ts +112 -3
- package/src/compiler/generate-adopt.ts +280 -26
- package/src/compiler/response.ts +45 -1
- package/src/compiler/schema-from-ast.ts +609 -0
- package/src/console/server/ai.ts +5 -2
- package/src/console/server/bind.ts +2 -2
- package/src/console/server/console.test.ts +3 -0
- package/src/console/server/flows.ts +121 -40
- package/src/console/server/invoke-user-flow.test.ts +3 -1
- package/src/console/server/invoke-user-flow.ts +17 -2
- package/src/console/server/iso-at.test.ts +9 -0
- package/src/console/server/iso-at.ts +11 -0
- package/src/console/server/security-headers.ts +6 -3
- package/src/console/server/serve.ts +17 -2
- package/src/console/server/spa-proxy.test.ts +1 -0
- package/src/console/server/sql-catalog.ts +147 -7
- package/src/console/server/sql-rls.test.ts +16 -0
- package/src/console/server/state.ts +8 -0
- package/src/console/server/store.test.ts +7 -4
- package/src/console/server/store.ts +142 -58
- package/src/console/ui-next/dist/assets/agent-disclosure-C0X1fbWF.js +1 -0
- package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +1 -0
- package/src/console/ui-next/dist/assets/{call-pii-button-bqkxMrJH.js → call-pii-button-DEDCl_j3.js} +1 -1
- package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +1 -0
- package/src/console/ui-next/dist/assets/{confirm-sheet-DDCRmG62.js → confirm-sheet-3ptDzXbz.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-oiRxPVsZ.js → duration-tone-sC3lGABz.js} +1 -1
- package/src/console/ui-next/dist/assets/{explorer-empty-HjTnVQoR.js → explorer-empty-BvYrygyO.js} +1 -1
- package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CvDPvveV.js → highlighted-json-CS_O8L-r.js} +1 -1
- package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +1 -0
- package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +66 -0
- package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +2 -0
- package/src/console/ui-next/dist/assets/{link-DF7SZ9Ek.js → link-COeyggt-.js} +1 -1
- package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +4 -0
- package/src/console/ui-next/dist/assets/react-D8E3mtu1.js +1 -0
- package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +18 -0
- package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +1 -0
- package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +54 -0
- package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-Cl79j_ku.js → use-vault-list-BLbZhzyF.js} +1 -1
- package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +2 -0
- package/src/console/ui-next/dist/index.html +6 -6
- package/src/console/ui-next/seed-invoke-host.ts +2 -0
- package/src/console/ui-next/src/client.ts +35 -7
- package/src/console/ui-next/src/components/motion/table/index.tsx +16 -0
- package/src/console/ui-next/src/components/motion/table/types.ts +4 -0
- package/src/console/ui-next/src/components/ui/kbd.tsx +2 -2
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
- package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
- package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/http-method.ts +19 -0
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
- package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +29 -2
- package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +10 -3
- package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
- package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
- package/src/console/ui-next/src/features/store/detail/browse-section.tsx +45 -1
- package/src/console/ui-next/src/features/store/detail/resource-panel.tsx +78 -16
- package/src/console/ui-next/src/features/store/detail/store-row-detail-sheet.tsx +155 -14
- package/src/console/ui-next/src/features/store/explorer/store-tree.tsx +33 -133
- package/src/console/ui-next/src/features/store/files/file-preview.tsx +4 -1
- package/src/console/ui-next/src/features/store/grid/rls-policy-sheet.tsx +326 -165
- package/src/console/ui-next/src/features/store/grid/sql-insert-sheet.tsx +6 -0
- package/src/console/ui-next/src/features/store/grid/store-data-grid.tsx +50 -3
- package/src/console/ui-next/src/features/store/lib/files-meta.test.ts +3 -0
- package/src/console/ui-next/src/features/store/lib/files-meta.ts +12 -0
- package/src/console/ui-next/src/features/store/lib/query-defaults.test.ts +25 -0
- package/src/console/ui-next/src/features/store/lib/query-defaults.ts +85 -3
- package/src/console/ui-next/src/features/store/lib/query-gate.ts +30 -4
- package/src/console/ui-next/src/features/store/lib/rls-policy.test.ts +139 -0
- package/src/console/ui-next/src/features/store/lib/rls-policy.ts +223 -21
- package/src/console/ui-next/src/features/store/lib/schema-graph.ts +4 -0
- package/src/console/ui-next/src/features/store/lib/sql-catalog.test.ts +24 -0
- package/src/console/ui-next/src/features/store/lib/sql-catalog.ts +19 -0
- package/src/console/ui-next/src/features/store/lib/store-tree.test.ts +6 -0
- package/src/console/ui-next/src/features/store/lib/store-tree.ts +13 -0
- package/src/console/ui-next/src/features/store/query/query-console.tsx +48 -14
- package/src/console/ui-next/src/features/store/query/query-gate-menu.tsx +19 -20
- package/src/console/ui-next/src/features/store/query/query-gate-parts.tsx +2 -1
- package/src/console/ui-next/src/features/store/query/query-results.tsx +110 -83
- package/src/console/ui-next/src/features/store/schema/schema-table-node.tsx +17 -0
- package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +112 -72
- package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
- package/src/console/ui-next/src/features/units/lib/contract-input.test.ts +206 -1
- package/src/console/ui-next/src/features/units/lib/contract-input.ts +55 -0
- package/src/console/ui-next/src/features/vault/lib/types.ts +1 -1
- package/src/docker/compose-health.test.ts +27 -0
- package/src/docker/compose-health.ts +19 -3
- package/src/docker/docker.test.ts +4 -1
- package/src/docker/dockerfile.ts +1 -1
- package/src/docker/recipes/pgdog.ts +2 -0
- package/src/drivers/ai-anthropic.ts +5 -0
- package/src/drivers/ai-ollama.ts +49 -30
- package/src/drivers/ai-openai-compatible.ts +57 -46
- package/src/drivers/ai-providers.test.ts +3 -0
- package/src/drivers/bun-native-completeness.test.ts +7 -9
- package/src/drivers/clock-postgres.ts +13 -9
- package/src/drivers/instances-postgres.ts +4 -6
- package/src/drivers/journal-postgres.ts +13 -9
- package/src/drivers/pg-rls.test.ts +87 -0
- package/src/drivers/pg-rls.ts +233 -7
- package/src/drivers/pglite.ts +26 -3
- package/src/drivers/postgres.test.ts +23 -0
- package/src/drivers/postgres.ts +133 -9
- package/src/drivers/redis.ts +11 -4
- package/src/drivers/signal-redis.ts +24 -14
- package/src/drivers/signal-types.ts +2 -1
- package/src/drivers/types.ts +10 -0
- package/src/elements/ai/declare.ts +109 -0
- package/src/elements/ai/errors.test.ts +5 -1
- package/src/elements/ai/errors.ts +30 -2
- package/src/elements/ai/eval.ts +4 -6
- package/src/elements/ai/mcp-client.test.ts +206 -0
- package/src/elements/ai/mcp-client.ts +362 -0
- package/src/elements/ai/mcp-http.ts +159 -0
- package/src/elements/ai/mcp-mock.ts +134 -0
- package/src/elements/ai/mcp-protocol.ts +234 -0
- package/src/elements/ai/mcp-stdio.test.ts +50 -0
- package/src/elements/ai/mcp-stdio.ts +212 -0
- package/src/elements/ai/mcp-transport.ts +70 -0
- package/src/elements/ai/runtime.ts +159 -29
- package/src/elements/ai.test.ts +139 -0
- package/src/elements/ai.ts +16 -0
- package/src/elements/clock/declare.ts +26 -1
- package/src/elements/clock/health.test.ts +43 -0
- package/src/elements/clock/runtime.ts +55 -2
- package/src/elements/clock/schedule.ts +71 -142
- package/src/elements/clock.test.ts +9 -40
- package/src/elements/clock.ts +6 -1
- package/src/elements/gate/boot.ts +5 -5
- package/src/elements/gate/declare.ts +36 -4
- package/src/elements/gate/flatten.ts +1 -1
- package/src/elements/gate/runtime.ts +1 -1
- package/src/elements/index.ts +2 -0
- package/src/elements/signal/declare.ts +2 -1
- package/src/elements/signal/runtime.ts +16 -1
- package/src/elements/signal.ts +1 -0
- package/src/elements/store/cache.test.ts +2 -0
- package/src/elements/store/cache.ts +3 -3
- package/src/elements/store/declare.ts +9 -6
- package/src/elements/store/emit-drizzle.ts +44 -8
- package/src/elements/store/resource.test.ts +1 -1
- package/src/elements/store/rls-identity.test.ts +95 -0
- package/src/elements/store/rls-identity.ts +116 -0
- package/src/elements/store/runtime.ts +4 -0
- package/src/elements/store/schema-decl.test.ts +37 -0
- package/src/elements/store/schema-decl.ts +161 -0
- package/src/elements/store/seed.ts +1 -1
- package/src/elements/store/sql-condition.test.ts +12 -0
- package/src/elements/store/sql-condition.ts +26 -2
- package/src/elements/store/sql-rls-isolation.test.ts +165 -0
- package/src/elements/store/sql-rls-stamp.test.ts +117 -0
- package/src/elements/store/sql-session.ts +81 -3
- package/src/elements/store/upsert-app.test.ts +1 -1
- package/src/elements/store.ts +14 -0
- package/src/elements/vault/builtin-adapter.ts +17 -0
- package/src/full.ts +2 -0
- package/src/http.ts +20 -3
- package/src/index.ts +14 -1
- package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
- package/src/kernel/adopt-routes.ts +30 -2
- package/src/kernel/app.ts +227 -77
- package/src/kernel/auto-cache.test.ts +8 -8
- package/src/kernel/auto-registry.test.ts +54 -9
- package/src/kernel/boot-bind/ai.ts +24 -0
- package/src/kernel/boot-bind/clock.ts +2 -0
- package/src/kernel/boot.test.ts +4 -4
- package/src/kernel/boot.ts +5 -3
- package/src/kernel/effects-stamping.test.ts +3 -3
- package/src/kernel/element-registries.ts +15 -5
- package/src/kernel/errors.ts +24 -0
- package/src/kernel/flow-units.ts +64 -0
- package/src/kernel/flow.ts +29 -17
- package/src/kernel/fx-dead-letters.test.ts +77 -0
- package/src/kernel/fx.test.ts +22 -0
- package/src/kernel/fx.ts +135 -6
- package/src/kernel/horizontal-child.ts +4 -4
- package/src/kernel/http-path-pending.ts +20 -0
- package/src/kernel/http-resource.ts +4 -0
- package/src/kernel/http-stream.test.ts +174 -0
- package/src/kernel/index.ts +2 -0
- package/src/kernel/pipeline.test.ts +2 -2
- package/src/kernel/plugin-elements.test.ts +1 -1
- package/src/kernel/plugin-needs.test.ts +1 -1
- package/src/kernel/ready.test.ts +2 -2
- package/src/kernel/resource-mount.test.ts +14 -0
- package/src/kernel/run-duration.test.ts +2 -2
- package/src/kernel/stamp-http.test.ts +79 -0
- package/src/kernel/stamp-http.ts +75 -0
- package/src/kernel/triggers.ts +52 -27
- package/src/manifest/mcp-ref.ts +88 -0
- package/src/manifest/types.ts +41 -2
- package/src/manifest/validate.test.ts +20 -0
- package/src/mcp/docs-server.ts +1 -1
- package/src/mcp/server.ts +1 -1
- package/src/plugins/compression.test.ts +21 -0
- package/src/plugins/compression.ts +1 -0
- package/src/release/measure.ts +2 -2
- package/src/runtime/bun.ts +41 -4
- package/src/runtime/json-code-block.test.ts +2 -2
- package/src/runtime/serve.test.ts +4 -4
- package/src/term.test.ts +52 -4
- package/src/term.ts +125 -17
- package/src/test/reset-element-registries.ts +10 -3
- package/src/cli/ask-seed.test.ts +0 -96
- package/src/cli/ask-seed.ts +0 -82
- package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
- package/src/console/ui-next/dist/assets/collapsible-DYb0xU8C.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dg8CTE29.js +0 -1
- package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
- package/src/console/ui-next/dist/assets/index-Bp-R7jtM.js +0 -66
- package/src/console/ui-next/dist/assets/index-D4Ldtj79.css +0 -2
- package/src/console/ui-next/dist/assets/observability-page-DAnpEaq1.js +0 -4
- package/src/console/ui-next/dist/assets/query-gate-parts-1m8m1iNp.js +0 -2
- package/src/console/ui-next/dist/assets/react-B1ML8gxg.js +0 -1
- package/src/console/ui-next/dist/assets/replica-lag-C8_BRt2x.js +0 -18
- package/src/console/ui-next/dist/assets/sheet-form-D-ata7jy.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-DO4IsVqv.js +0 -1
- package/src/console/ui-next/dist/assets/skeleton-CL_X0GCj.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-v3LXdYpr.js +0 -45
- package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
- package/src/console/ui-next/dist/assets/units-page-BtQ0bqMe.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-Ca-MvcmJ.js +0 -2
package/src/drivers/pg-rls.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Postgres row-level security — identifiers
|
|
2
|
+
* Postgres row-level security — identifiers, `CREATE POLICY` SQL,
|
|
3
|
+
* Drizzle emit, and Gate identity helpers (`oke.gate` / `oke.user` / `oke.has_scope`).
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
/** Policy command (`FOR`). */
|
|
@@ -31,6 +32,110 @@ export type SqlPolicyAlterSpec = {
|
|
|
31
32
|
const POLICY_COMMANDS = new Set<string>(["ALL", "SELECT", "INSERT", "UPDATE", "DELETE"]);
|
|
32
33
|
const POLICY_BEHAVIORS = new Set<string>(["PERMISSIVE", "RESTRICTIVE"]);
|
|
33
34
|
|
|
35
|
+
/** Drizzle / Postgres `TO` targets that must not be quoted. */
|
|
36
|
+
const SPECIAL_POLICY_ROLES = new Set(["public", "current_role", "current_user", "session_user"]);
|
|
37
|
+
|
|
38
|
+
/** Drivers that honor `SET LOCAL row_security` + `oke.*` GUCs. */
|
|
39
|
+
export const RLS_CONTEXT_DRIVERS = new Set<string>(["postgres", "pglite"]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Non-superuser role the stamp `SET LOCAL ROLE`s into so RLS applies.
|
|
43
|
+
* Table owners and PGlite's `postgres` superuser otherwise bypass policies.
|
|
44
|
+
*/
|
|
45
|
+
export const OKE_RLS_ROLE = "oke_app";
|
|
46
|
+
|
|
47
|
+
/** Live Gate principal stamped onto a SQL statement. */
|
|
48
|
+
export type RlsIdentity = {
|
|
49
|
+
readonly gate: string;
|
|
50
|
+
readonly userId: string;
|
|
51
|
+
readonly scopes: readonly string[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One statement at a time — Bun.SQL `unsafe` / PGlite `query` reject
|
|
56
|
+
* multi-command batches. Safe to run repeatedly.
|
|
57
|
+
*/
|
|
58
|
+
export const OKE_RLS_HELPER_STATEMENTS: readonly string[] = [
|
|
59
|
+
"CREATE SCHEMA IF NOT EXISTS oke",
|
|
60
|
+
// DB user is often `oke` — default search_path `"$user", public` then
|
|
61
|
+
// creates unqualified tables in schema `oke`. Domain tables belong in public.
|
|
62
|
+
"SET search_path TO public, oke",
|
|
63
|
+
`DO $oke_search_path$
|
|
64
|
+
BEGIN
|
|
65
|
+
EXECUTE 'ALTER ROLE CURRENT_USER SET search_path TO public, oke';
|
|
66
|
+
EXCEPTION
|
|
67
|
+
WHEN others THEN NULL;
|
|
68
|
+
END
|
|
69
|
+
$oke_search_path$`,
|
|
70
|
+
`DO $oke_rehome$
|
|
71
|
+
DECLARE r record;
|
|
72
|
+
BEGIN
|
|
73
|
+
FOR r IN
|
|
74
|
+
SELECT c.relname AS name
|
|
75
|
+
FROM pg_class c
|
|
76
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
77
|
+
WHERE n.nspname = 'oke' AND c.relkind = 'r'
|
|
78
|
+
LOOP
|
|
79
|
+
IF NOT EXISTS (
|
|
80
|
+
SELECT 1
|
|
81
|
+
FROM pg_class c2
|
|
82
|
+
JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
|
|
83
|
+
WHERE n2.nspname = 'public' AND c2.relname = r.name AND c2.relkind = 'r'
|
|
84
|
+
) THEN
|
|
85
|
+
EXECUTE format('ALTER TABLE oke.%I SET SCHEMA public', r.name);
|
|
86
|
+
END IF;
|
|
87
|
+
END LOOP;
|
|
88
|
+
END
|
|
89
|
+
$oke_rehome$`,
|
|
90
|
+
`CREATE OR REPLACE FUNCTION oke.gate() RETURNS text
|
|
91
|
+
LANGUAGE sql STABLE AS $$ SELECT current_setting('oke.gate', true) $$`,
|
|
92
|
+
`CREATE OR REPLACE FUNCTION oke.user() RETURNS text
|
|
93
|
+
LANGUAGE sql STABLE AS $$ SELECT current_setting('oke.user', true) $$`,
|
|
94
|
+
`CREATE OR REPLACE FUNCTION oke.has_scope(p_scope text) RETURNS boolean
|
|
95
|
+
LANGUAGE sql STABLE AS $$
|
|
96
|
+
SELECT CASE
|
|
97
|
+
WHEN current_setting('oke.scopes', true) IN ('') THEN false
|
|
98
|
+
ELSE jsonb_exists(current_setting('oke.scopes', true)::jsonb, p_scope)
|
|
99
|
+
END
|
|
100
|
+
$$`,
|
|
101
|
+
`DO $oke_rls_role$
|
|
102
|
+
BEGIN
|
|
103
|
+
CREATE ROLE ${OKE_RLS_ROLE} NOSUPERUSER NOBYPASSRLS NOLOGIN;
|
|
104
|
+
EXCEPTION
|
|
105
|
+
WHEN duplicate_object THEN NULL;
|
|
106
|
+
END
|
|
107
|
+
$oke_rls_role$`,
|
|
108
|
+
`GRANT USAGE ON SCHEMA public TO ${OKE_RLS_ROLE}`,
|
|
109
|
+
`GRANT USAGE ON SCHEMA oke TO ${OKE_RLS_ROLE}`,
|
|
110
|
+
`GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA oke TO ${OKE_RLS_ROLE}`,
|
|
111
|
+
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${OKE_RLS_ROLE}`,
|
|
112
|
+
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${OKE_RLS_ROLE}`,
|
|
113
|
+
`GRANT ${OKE_RLS_ROLE} TO CURRENT_USER`,
|
|
114
|
+
`DO $oke_app_search_path$
|
|
115
|
+
BEGIN
|
|
116
|
+
EXECUTE 'ALTER ROLE ${OKE_RLS_ROLE} SET search_path TO public, oke';
|
|
117
|
+
EXCEPTION
|
|
118
|
+
WHEN others THEN NULL;
|
|
119
|
+
END
|
|
120
|
+
$oke_app_search_path$`,
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* All {@link OKE_RLS_HELPER_STATEMENTS} as one script (PGlite `exec` / `oke db push`).
|
|
125
|
+
*/
|
|
126
|
+
export const OKE_RLS_HELPER_SQL = `${OKE_RLS_HELPER_STATEMENTS.join(";\n")};`;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Install `oke.*` helpers one statement at a time.
|
|
130
|
+
*
|
|
131
|
+
* @param exec - Statement runner
|
|
132
|
+
*/
|
|
133
|
+
export async function installOkeRlsHelpers(exec: (sql: string) => Promise<unknown>): Promise<void> {
|
|
134
|
+
for (const stmt of OKE_RLS_HELPER_STATEMENTS) {
|
|
135
|
+
await exec(stmt);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
34
139
|
/**
|
|
35
140
|
* Quote a Postgres identifier (`"`), stripping embedded quotes.
|
|
36
141
|
*
|
|
@@ -102,11 +207,38 @@ export function parseSqlPolicyRoles(raw: string): readonly string[] | null {
|
|
|
102
207
|
.filter((part) => part.length > 0);
|
|
103
208
|
if (parts.length === 0) return ["public"];
|
|
104
209
|
for (const part of parts) {
|
|
105
|
-
if (
|
|
210
|
+
if (!isPolicyRoleName(part)) return null;
|
|
106
211
|
}
|
|
107
212
|
return parts;
|
|
108
213
|
}
|
|
109
214
|
|
|
215
|
+
/**
|
|
216
|
+
* True when `name` is `public`, a Drizzle special role, or a simple identifier.
|
|
217
|
+
*
|
|
218
|
+
* @param name - Role token
|
|
219
|
+
*/
|
|
220
|
+
export function isPolicyRoleName(name: string): boolean {
|
|
221
|
+
return SPECIAL_POLICY_ROLES.has(name.toLowerCase()) || isPgIdent(name);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Format a `TO` role — special names stay unquoted.
|
|
226
|
+
*
|
|
227
|
+
* @param role - Role token
|
|
228
|
+
*/
|
|
229
|
+
export function formatPolicyRole(role: string): string {
|
|
230
|
+
return SPECIAL_POLICY_ROLES.has(role.toLowerCase()) ? role.toLowerCase() : quotePgIdent(role);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* JSON text stored in `oke.scopes` (sorted unique).
|
|
235
|
+
*
|
|
236
|
+
* @param scopes - Scope strings
|
|
237
|
+
*/
|
|
238
|
+
export function rlsScopesJson(scopes: readonly string[]): string {
|
|
239
|
+
return JSON.stringify([...new Set(scopes)].sort());
|
|
240
|
+
}
|
|
241
|
+
|
|
110
242
|
/**
|
|
111
243
|
* `CREATE POLICY` from a validated spec.
|
|
112
244
|
*
|
|
@@ -116,7 +248,7 @@ export function buildCreatePolicySql(spec: SqlPolicySpec): string {
|
|
|
116
248
|
const roles =
|
|
117
249
|
spec.roles.length === 0
|
|
118
250
|
? "public"
|
|
119
|
-
: spec.roles.map((role) => (role
|
|
251
|
+
: spec.roles.map((role) => formatPolicyRole(role)).join(", ");
|
|
120
252
|
let text = `CREATE POLICY ${quotePgIdent(spec.name)} ON ${quotePgIdent(spec.table)} AS ${spec.behavior} FOR ${spec.command} TO ${roles}`;
|
|
121
253
|
if (spec.using !== undefined && spec.using.trim() !== "") {
|
|
122
254
|
text += ` USING (${spec.using.trim()})`;
|
|
@@ -153,7 +285,7 @@ export function buildAlterPolicySql(spec: SqlPolicyAlterSpec): string {
|
|
|
153
285
|
const roles =
|
|
154
286
|
spec.roles.length === 0
|
|
155
287
|
? "public"
|
|
156
|
-
: spec.roles.map((role) => (role
|
|
288
|
+
: spec.roles.map((role) => formatPolicyRole(role)).join(", ");
|
|
157
289
|
parts.push(`TO ${roles}`);
|
|
158
290
|
}
|
|
159
291
|
if (spec.using !== undefined && spec.using.trim() !== "") {
|
|
@@ -189,9 +321,9 @@ export function parseSqlPolicySpec(raw: Readonly<Record<string, unknown>>): SqlP
|
|
|
189
321
|
const table = typeof raw.table === "string" ? raw.table.trim() : "";
|
|
190
322
|
if (!isPgPolicyName(name)) throw new Error(`invalid policy name "${name}"`);
|
|
191
323
|
if (!isPgIdent(table)) throw new Error(`invalid table name "${table}"`);
|
|
192
|
-
const command = parseSqlPolicyCommand(raw.command) ?? "SELECT";
|
|
193
|
-
const behavior = parseSqlPolicyBehavior(raw.behavior ?? raw.permissive) ?? "PERMISSIVE";
|
|
194
|
-
const rolesRaw =
|
|
324
|
+
const command = parseSqlPolicyCommand(raw.command) ?? parseSqlPolicyCommand(raw.for) ?? "SELECT";
|
|
325
|
+
const behavior = parseSqlPolicyBehavior(raw.behavior ?? raw.permissive ?? raw.as) ?? "PERMISSIVE";
|
|
326
|
+
const rolesRaw = rolesField(raw.roles ?? raw.to);
|
|
195
327
|
const roles = parseSqlPolicyRoles(rolesRaw);
|
|
196
328
|
if (!roles) throw new Error("invalid policy roles");
|
|
197
329
|
const using = typeof raw.using === "string" ? raw.using.trim() : "";
|
|
@@ -232,3 +364,97 @@ export function parseSqlPolicyRowId(id: string): { readonly table: string; reado
|
|
|
232
364
|
}
|
|
233
365
|
return { table: id.slice(0, cut), name: id.slice(cut + 1) };
|
|
234
366
|
}
|
|
367
|
+
|
|
368
|
+
function rolesField(raw: unknown): string {
|
|
369
|
+
if (typeof raw === "string") return raw;
|
|
370
|
+
if (Array.isArray(raw)) {
|
|
371
|
+
return raw.filter((part): part is string => typeof part === "string").join(", ");
|
|
372
|
+
}
|
|
373
|
+
return "public";
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Drizzle `pgPolicy(...)` source for emit / Console copy-as-code.
|
|
378
|
+
*
|
|
379
|
+
* @param spec - Validated policy
|
|
380
|
+
*/
|
|
381
|
+
export function emitPgPolicySource(spec: SqlPolicySpec): string {
|
|
382
|
+
const to =
|
|
383
|
+
spec.roles.length === 1
|
|
384
|
+
? JSON.stringify(formatEmitRole(spec.roles[0]!))
|
|
385
|
+
: `[${spec.roles.map((role) => JSON.stringify(formatEmitRole(role))).join(", ")}]`;
|
|
386
|
+
const lines = [
|
|
387
|
+
`pgPolicy(${JSON.stringify(spec.name)}, {`,
|
|
388
|
+
` as: ${JSON.stringify(spec.behavior.toLowerCase())},`,
|
|
389
|
+
` to: ${to},`,
|
|
390
|
+
` for: ${JSON.stringify(spec.command.toLowerCase())},`,
|
|
391
|
+
];
|
|
392
|
+
if (spec.using !== undefined && spec.using.trim() !== "") {
|
|
393
|
+
lines.push(` using: sql\`${escapeSqlTemplate(spec.using.trim())}\`,`);
|
|
394
|
+
}
|
|
395
|
+
if (spec.withCheck !== undefined && spec.withCheck.trim() !== "") {
|
|
396
|
+
lines.push(` withCheck: sql\`${escapeSqlTemplate(spec.withCheck.trim())}\`,`);
|
|
397
|
+
}
|
|
398
|
+
lines.push(`})`);
|
|
399
|
+
return lines.join("\n");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* `store.schema.policy(...)` source for Console copy-as-code.
|
|
404
|
+
*
|
|
405
|
+
* @param spec - Validated policy
|
|
406
|
+
*/
|
|
407
|
+
export function emitStoreSchemaPolicySource(spec: SqlPolicySpec): string {
|
|
408
|
+
const to =
|
|
409
|
+
spec.roles.length === 1
|
|
410
|
+
? JSON.stringify(formatEmitRole(spec.roles[0]!))
|
|
411
|
+
: `[${spec.roles.map((role) => JSON.stringify(formatEmitRole(role))).join(", ")}]`;
|
|
412
|
+
const lines = [
|
|
413
|
+
`store.schema.policy(${JSON.stringify(spec.name)}, {`,
|
|
414
|
+
` as: ${JSON.stringify(spec.behavior.toLowerCase())},`,
|
|
415
|
+
` to: ${to},`,
|
|
416
|
+
` for: ${JSON.stringify(spec.command.toLowerCase())},`,
|
|
417
|
+
];
|
|
418
|
+
if (spec.using !== undefined && spec.using.trim() !== "") {
|
|
419
|
+
lines.push(` using: ${JSON.stringify(spec.using.trim())},`);
|
|
420
|
+
}
|
|
421
|
+
if (spec.withCheck !== undefined && spec.withCheck.trim() !== "") {
|
|
422
|
+
lines.push(` withCheck: ${JSON.stringify(spec.withCheck.trim())},`);
|
|
423
|
+
}
|
|
424
|
+
lines.push(`})`);
|
|
425
|
+
return lines.join("\n");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function formatEmitRole(role: string): string {
|
|
429
|
+
return SPECIAL_POLICY_ROLES.has(role.toLowerCase()) ? role.toLowerCase() : role;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function escapeSqlTemplate(expr: string): string {
|
|
433
|
+
return expr.replaceAll("\\", "\\\\").replaceAll("`", "\\`");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** One statement in the RLS identity prelude (never a multi-command batch). */
|
|
437
|
+
export type RlsPreludeStatement = {
|
|
438
|
+
readonly sql: string;
|
|
439
|
+
readonly params?: readonly string[];
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Per-statement identity frame (no user SQL, no `BEGIN`).
|
|
444
|
+
*
|
|
445
|
+
* The caller pins these to one backend connection via `SqlConnection.transaction`.
|
|
446
|
+
* PGlite's `query` path is prepared statements and rejects `BEGIN; SET; SELECT`
|
|
447
|
+
* as one script.
|
|
448
|
+
*
|
|
449
|
+
* @param identity - Gate principal
|
|
450
|
+
*/
|
|
451
|
+
export function buildRlsIdentityPreludeSql(identity: RlsIdentity): readonly RlsPreludeStatement[] {
|
|
452
|
+
return [
|
|
453
|
+
{ sql: `SET LOCAL ROLE ${OKE_RLS_ROLE}` },
|
|
454
|
+
{ sql: "SET LOCAL row_security = on" },
|
|
455
|
+
{
|
|
456
|
+
sql: `SELECT set_config('oke.gate', ?, true), set_config('oke.user', ?, true), set_config('oke.scopes', ?, true)`,
|
|
457
|
+
params: [identity.gate, identity.userId, rlsScopesJson(identity.scopes)],
|
|
458
|
+
},
|
|
459
|
+
];
|
|
460
|
+
}
|
package/src/drivers/pglite.ts
CHANGED
|
@@ -77,21 +77,44 @@ export async function connectPglite(options: SqlConnectOptions = {}): Promise<Sq
|
|
|
77
77
|
resolved === "memory"
|
|
78
78
|
? await PGlite.create({ extensions: { vector } })
|
|
79
79
|
: await PGlite.create(resolved, { extensions: { vector } });
|
|
80
|
-
|
|
80
|
+
const connection: SqlConnection = {
|
|
81
81
|
driverId: "pglite",
|
|
82
82
|
role,
|
|
83
83
|
async query(sql, params = []) {
|
|
84
|
-
const rs = await db.query<SqlRow>(toPostgresParams(sql), [...params]);
|
|
84
|
+
const rs = await db.query<SqlRow>(toPostgresParams(sql, params), [...params]);
|
|
85
85
|
return rs.rows;
|
|
86
86
|
},
|
|
87
87
|
async exec(sql, params = []) {
|
|
88
|
-
|
|
88
|
+
// Prepared `query` rejects multi-command scripts (`BEGIN; SET; …`).
|
|
89
|
+
// Param-less SQL uses simple-protocol `exec` (helpers, BEGIN, SET).
|
|
90
|
+
if (params.length === 0) {
|
|
91
|
+
const results = await db.exec(sql);
|
|
92
|
+
const last = results[results.length - 1];
|
|
93
|
+
return { changes: last?.affectedRows ?? 0 };
|
|
94
|
+
}
|
|
95
|
+
const rs = await db.query<SqlRow>(toPostgresParams(sql, params), [...params]);
|
|
89
96
|
return { changes: rs.affectedRows ?? 0 };
|
|
90
97
|
},
|
|
98
|
+
async transaction(fn) {
|
|
99
|
+
await db.exec("BEGIN");
|
|
100
|
+
try {
|
|
101
|
+
const result = await fn(connection);
|
|
102
|
+
await db.exec("COMMIT");
|
|
103
|
+
return result;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
try {
|
|
106
|
+
await db.exec("ROLLBACK");
|
|
107
|
+
} catch {
|
|
108
|
+
// Already aborted.
|
|
109
|
+
}
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
},
|
|
91
113
|
async close() {
|
|
92
114
|
await db.close();
|
|
93
115
|
},
|
|
94
116
|
};
|
|
117
|
+
return connection;
|
|
95
118
|
}
|
|
96
119
|
|
|
97
120
|
/** Protocol-named pglite driver. */
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres placeholder conversion.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { toPostgresParams } from "./postgres.ts";
|
|
7
|
+
|
|
8
|
+
describe("toPostgresParams", () => {
|
|
9
|
+
test("rewrites ? only when values are bound", () => {
|
|
10
|
+
expect(toPostgresParams("SELECT * FROM t WHERE id = ?", ["a"])).toBe(
|
|
11
|
+
"SELECT * FROM t WHERE id = $1",
|
|
12
|
+
);
|
|
13
|
+
expect(toPostgresParams("INSERT INTO t (a, b) VALUES (?, ?)", [1, 2])).toBe(
|
|
14
|
+
"INSERT INTO t (a, b) VALUES ($1, $2)",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("leaves jsonb ? operators unchanged when there are no values", () => {
|
|
19
|
+
const sql = "SELECT current_setting('oke.scopes', true)::jsonb ? p_scope";
|
|
20
|
+
expect(toPostgresParams(sql)).toBe(sql);
|
|
21
|
+
expect(toPostgresParams(sql, [])).toBe(sql);
|
|
22
|
+
});
|
|
23
|
+
});
|
package/src/drivers/postgres.ts
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
* Protocol-named: Neon, Supabase, RDS, Timescale all speak postgres.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { SqlConnectOptions, SqlConnection, SqlDriver, SqlRow } from "./types.ts";
|
|
7
|
+
import type { SqlConnectOptions, SqlConnection, SqlDriver, SqlRole, SqlRow } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** Bun.SQL client checked out via {@link PostgresClientLike.reserve}. */
|
|
10
|
+
export interface PostgresReservedClient extends PostgresClientLike {
|
|
11
|
+
/** Return the connection to the pool. */
|
|
12
|
+
release(): void;
|
|
13
|
+
}
|
|
8
14
|
|
|
9
15
|
/** Minimal surface we use from Bun.SQL (and test fakes). */
|
|
10
16
|
export interface PostgresClientLike {
|
|
@@ -12,15 +18,98 @@ export interface PostgresClientLike {
|
|
|
12
18
|
sql: string,
|
|
13
19
|
values?: unknown[],
|
|
14
20
|
): PromiseLike<SqlRow[] | { length: number; changes?: number } | SqlRow[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Pin one pooled TCP connection. Required in front of PgDog — `begin()`
|
|
23
|
+
* then `unsafe()` on the parent client can checkout a second slot and
|
|
24
|
+
* deadlock the pool (`checkout timeout`).
|
|
25
|
+
*/
|
|
26
|
+
reserve?(): Promise<PostgresReservedClient>;
|
|
27
|
+
/**
|
|
28
|
+
* Reserve one pooled connection for a transaction (Bun.SQL).
|
|
29
|
+
*
|
|
30
|
+
* @param fn - Work on the reserved client
|
|
31
|
+
*/
|
|
32
|
+
begin?<T>(fn: (tx: PostgresClientLike) => Promise<T> | T): Promise<T>;
|
|
15
33
|
close?(options?: { timeout?: number }): Promise<void>;
|
|
16
34
|
}
|
|
17
35
|
|
|
36
|
+
/** Cap shared Bun.SQL pools so one process cannot outrun PgDog (default 10). */
|
|
37
|
+
export const POSTGRES_POOL_MAX = 8;
|
|
38
|
+
|
|
39
|
+
const sharedClients = new Map<string, PostgresClientLike>();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the Postgres URL (injected, `DATABASE_URL`, then localhost).
|
|
43
|
+
*
|
|
44
|
+
* @param url - Explicit URL
|
|
45
|
+
*/
|
|
46
|
+
export function resolvePostgresUrl(url?: string): string {
|
|
47
|
+
return url ?? process.env.DATABASE_URL ?? "postgres://localhost:5432/oke";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One Bun.SQL pool per URL for store / journal / clock / vault / console.
|
|
52
|
+
*
|
|
53
|
+
* @param url - Connection URL
|
|
54
|
+
*/
|
|
55
|
+
export function sharedPostgresClient(url?: string): PostgresClientLike {
|
|
56
|
+
const key = resolvePostgresUrl(url);
|
|
57
|
+
const existing = sharedClients.get(key);
|
|
58
|
+
if (existing) return existing;
|
|
59
|
+
const created = new Bun.SQL(key, { max: POSTGRES_POOL_MAX }) as unknown as PostgresClientLike;
|
|
60
|
+
sharedClients.set(key, created);
|
|
61
|
+
return created;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Pin one pooled connection, `BEGIN`, run `fn`, `COMMIT`/`ROLLBACK`, release.
|
|
66
|
+
*
|
|
67
|
+
* Prefer `reserve()` — Bun.SQL `begin()` then `unsafe()` on the parent
|
|
68
|
+
* client can checkout a second slot and deadlock PgDog.
|
|
69
|
+
*
|
|
70
|
+
* @param client - Pool or test fake
|
|
71
|
+
* @param fn - Work on the pinned client
|
|
72
|
+
*/
|
|
73
|
+
export async function withPinnedPostgres<T>(
|
|
74
|
+
client: PostgresClientLike,
|
|
75
|
+
fn: (tx: PostgresClientLike) => Promise<T>,
|
|
76
|
+
): Promise<T> {
|
|
77
|
+
if (typeof client.reserve === "function") {
|
|
78
|
+
const reserved = await client.reserve();
|
|
79
|
+
try {
|
|
80
|
+
await reserved.unsafe("BEGIN");
|
|
81
|
+
try {
|
|
82
|
+
const result = await fn(reserved);
|
|
83
|
+
await reserved.unsafe("COMMIT");
|
|
84
|
+
return result;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
try {
|
|
87
|
+
await reserved.unsafe("ROLLBACK");
|
|
88
|
+
} catch {
|
|
89
|
+
// Already aborted.
|
|
90
|
+
}
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
reserved.release();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (typeof client.begin === "function") {
|
|
98
|
+
return client.begin(fn);
|
|
99
|
+
}
|
|
100
|
+
throw new Error("postgres client needs reserve() or begin() to pin a transaction");
|
|
101
|
+
}
|
|
102
|
+
|
|
18
103
|
/**
|
|
19
104
|
* Convert `?` placeholders to Postgres `$1…$n`.
|
|
105
|
+
* Leaves SQL unchanged when there are no bound values so operators
|
|
106
|
+
* like `jsonb ? key` are not rewritten.
|
|
20
107
|
*
|
|
21
108
|
* @param sql - SQL with `?` placeholders
|
|
109
|
+
* @param values - Bound values (empty skips conversion)
|
|
22
110
|
*/
|
|
23
|
-
export function toPostgresParams(sql: string): string {
|
|
111
|
+
export function toPostgresParams(sql: string, values: readonly unknown[] = []): string {
|
|
112
|
+
if (values.length === 0) return sql;
|
|
24
113
|
let i = 0;
|
|
25
114
|
return sql.replace(/\?/g, () => `$${++i}`);
|
|
26
115
|
}
|
|
@@ -32,21 +121,34 @@ export function toPostgresParams(sql: string): string {
|
|
|
32
121
|
*/
|
|
33
122
|
export async function connectPostgres(options: SqlConnectOptions = {}): Promise<SqlConnection> {
|
|
34
123
|
const role = options.role ?? "primary";
|
|
35
|
-
const client
|
|
36
|
-
|
|
37
|
-
|
|
124
|
+
const injected = options.client as PostgresClientLike | undefined;
|
|
125
|
+
const client = injected ?? sharedPostgresClient(options.url);
|
|
126
|
+
return wrapPostgresClient(client, role, { shared: injected === undefined });
|
|
127
|
+
}
|
|
38
128
|
|
|
39
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Bind a Bun.SQL-compatible client as {@link SqlConnection}.
|
|
131
|
+
*
|
|
132
|
+
* @param client - Pool or reserved transaction client
|
|
133
|
+
* @param role - Primary vs replica
|
|
134
|
+
* @param opts - Shared-pool / already-pinned flags
|
|
135
|
+
*/
|
|
136
|
+
function wrapPostgresClient(
|
|
137
|
+
client: PostgresClientLike,
|
|
138
|
+
role: SqlRole,
|
|
139
|
+
opts: { readonly shared?: boolean; readonly pinned?: boolean } = {},
|
|
140
|
+
): SqlConnection {
|
|
141
|
+
const connection: SqlConnection = {
|
|
40
142
|
driverId: "postgres",
|
|
41
143
|
role,
|
|
42
144
|
async query(sql, params = []) {
|
|
43
|
-
const pg = toPostgresParams(sql);
|
|
145
|
+
const pg = toPostgresParams(sql, params);
|
|
44
146
|
const result = await client.unsafe(pg, [...params]);
|
|
45
147
|
if (Array.isArray(result)) return result as SqlRow[];
|
|
46
148
|
return Array.from(result as ArrayLike<SqlRow>);
|
|
47
149
|
},
|
|
48
150
|
async exec(sql, params = []) {
|
|
49
|
-
const pg = toPostgresParams(sql);
|
|
151
|
+
const pg = toPostgresParams(sql, params);
|
|
50
152
|
const result = await client.unsafe(pg, [...params]);
|
|
51
153
|
if (
|
|
52
154
|
result &&
|
|
@@ -61,10 +163,15 @@ export async function connectPostgres(options: SqlConnectOptions = {}): Promise<
|
|
|
61
163
|
}
|
|
62
164
|
return { changes: 0 };
|
|
63
165
|
},
|
|
166
|
+
async transaction(fn) {
|
|
167
|
+
if (opts.pinned) return fn(connection);
|
|
168
|
+
return withPinnedPostgres(client, (tx) => fn(wrapPostgresClient(tx, role, { pinned: true })));
|
|
169
|
+
},
|
|
64
170
|
async close() {
|
|
65
|
-
await client.close?.();
|
|
171
|
+
if (!opts.shared) await client.close?.();
|
|
66
172
|
},
|
|
67
173
|
};
|
|
174
|
+
return connection;
|
|
68
175
|
}
|
|
69
176
|
|
|
70
177
|
/**
|
|
@@ -82,10 +189,27 @@ export function createPostgresFakeClient(): PostgresClientLike & {
|
|
|
82
189
|
|
|
83
190
|
return {
|
|
84
191
|
tables,
|
|
192
|
+
async begin(fn) {
|
|
193
|
+
return fn(this);
|
|
194
|
+
},
|
|
195
|
+
async reserve() {
|
|
196
|
+
return Object.assign(this, {
|
|
197
|
+
release() {
|
|
198
|
+
/* fake has no pool */
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
},
|
|
85
202
|
async unsafe(sql, values = []) {
|
|
86
203
|
const text = sql.trim();
|
|
87
204
|
// Accept both ? (pre-conversion) and $n forms.
|
|
88
205
|
const normalised = text.replace(/\$\d+/g, "?");
|
|
206
|
+
if (
|
|
207
|
+
/^(begin|commit|rollback|set\b|select set_config|create\b|grant\b|do\b|alter\b)\b/i.test(
|
|
208
|
+
normalised,
|
|
209
|
+
)
|
|
210
|
+
) {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
89
213
|
|
|
90
214
|
const create =
|
|
91
215
|
/^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*\((.+)\)\s*$/i.exec(
|
package/src/drivers/redis.ts
CHANGED
|
@@ -140,11 +140,19 @@ function ttlToSeconds(ttl: string): number {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/** Bun 1.4 typed methods — `@types/bun` may lag the runtime. */
|
|
144
|
+
interface BunRedisEval {
|
|
145
|
+
eval(script: string, numkeys: number, ...keysAndArgs: string[]): Promise<unknown>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function typedRedis(redis: Bun.RedisClient): Bun.RedisClient & BunRedisEval {
|
|
149
|
+
return redis as Bun.RedisClient & BunRedisEval;
|
|
150
|
+
}
|
|
151
|
+
|
|
143
152
|
/**
|
|
144
153
|
* Bind {@link Bun.RedisClient} / {@link Bun.redis} to {@link KvClientLike}.
|
|
145
154
|
*
|
|
146
|
-
* Uses typed `scan` (Bun ≥1.
|
|
147
|
-
* has no typed `eval` yet (platform limitation, not an OKE stub).
|
|
155
|
+
* Uses typed `scan` / `eval` (Bun ≥1.4). `send` remains the escape hatch.
|
|
148
156
|
*
|
|
149
157
|
* @param url - Optional Redis URL
|
|
150
158
|
*/
|
|
@@ -174,8 +182,7 @@ export function createBunRedisClient(url?: string): KvClientLike {
|
|
|
174
182
|
return redis.scan(cursor);
|
|
175
183
|
},
|
|
176
184
|
async eval(script, numkeys, ...keysAndArgs) {
|
|
177
|
-
|
|
178
|
-
return redis.send("EVAL", [script, String(numkeys), ...keysAndArgs]);
|
|
185
|
+
return typedRedis(redis).eval(script, numkeys, ...keysAndArgs);
|
|
179
186
|
},
|
|
180
187
|
send: (command, args) => redis.send(command, args),
|
|
181
188
|
};
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* exactly-once / fan-out physics stay correct under `drain()`.
|
|
8
8
|
*
|
|
9
9
|
* Production bind: {@link createBunSignalRedisClient} (typed `publish` /
|
|
10
|
-
* `subscribe
|
|
10
|
+
* `subscribe` / `xadd` / `xreadgroup` / `xack` / `xgroup` on Bun ≥1.4).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { createSignalEngine } from "./signal-engine.ts";
|
|
@@ -18,12 +18,23 @@ import type {
|
|
|
18
18
|
SignalRedisClientLike,
|
|
19
19
|
} from "./signal-types.ts";
|
|
20
20
|
|
|
21
|
+
/** Bun 1.4 typed stream methods — `@types/bun` may lag the runtime. */
|
|
22
|
+
interface BunRedisStreams {
|
|
23
|
+
xadd(key: string, ...args: (string | number)[]): Promise<unknown>;
|
|
24
|
+
xreadgroup(...args: (string | number)[]): Promise<unknown>;
|
|
25
|
+
xack(key: string, group: string, id: string): Promise<unknown>;
|
|
26
|
+
xgroup(subcommand: string, ...args: (string | number)[]): Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function typedStreams(redis: Bun.RedisClient): Bun.RedisClient & BunRedisStreams {
|
|
30
|
+
return redis as Bun.RedisClient & BunRedisStreams;
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
/**
|
|
22
34
|
* Bind {@link Bun.RedisClient} to {@link SignalRedisClientLike}.
|
|
23
35
|
*
|
|
24
|
-
* Streams
|
|
25
|
-
*
|
|
26
|
-
* Pub/sub uses typed `publish` / `subscribe` (gap closed vs earlier fake-only).
|
|
36
|
+
* Streams use typed `xadd` / `xgroup` / `xreadgroup` / `xack` (Bun ≥1.4).
|
|
37
|
+
* Pub/sub uses typed `publish` / `subscribe`.
|
|
27
38
|
*
|
|
28
39
|
* @param url - Optional Redis URL
|
|
29
40
|
*/
|
|
@@ -41,37 +52,36 @@ export function createBunSignalRedisClient(url?: string): SignalRedisClientLike
|
|
|
41
52
|
|
|
42
53
|
return {
|
|
43
54
|
async xadd(key, id, fields) {
|
|
44
|
-
const
|
|
55
|
+
const pairs: string[] = [];
|
|
45
56
|
for (const [k, v] of Object.entries(fields)) {
|
|
46
|
-
|
|
57
|
+
pairs.push(k, v);
|
|
47
58
|
}
|
|
48
|
-
return String(await redis.
|
|
59
|
+
return String(await typedStreams(redis).xadd(key, id, ...pairs));
|
|
49
60
|
},
|
|
50
61
|
async xgroupCreate(key, group, id, opts) {
|
|
51
|
-
const
|
|
52
|
-
if (opts?.mkstream) args.push("MKSTREAM");
|
|
62
|
+
const extra = opts?.mkstream ? (["MKSTREAM"] as const) : [];
|
|
53
63
|
try {
|
|
54
|
-
await redis.
|
|
64
|
+
await typedStreams(redis).xgroup("CREATE", key, group, id, ...extra);
|
|
55
65
|
} catch (err) {
|
|
56
66
|
const msg = err instanceof Error ? err.message : String(err);
|
|
57
67
|
if (!/BUSYGROUP/i.test(msg)) throw err;
|
|
58
68
|
}
|
|
59
69
|
},
|
|
60
70
|
async xreadgroup(group, consumer, key, count) {
|
|
61
|
-
const reply = await redis.
|
|
71
|
+
const reply = await typedStreams(redis).xreadgroup(
|
|
62
72
|
"GROUP",
|
|
63
73
|
group,
|
|
64
74
|
consumer,
|
|
65
75
|
"COUNT",
|
|
66
|
-
|
|
76
|
+
count,
|
|
67
77
|
"STREAMS",
|
|
68
78
|
key,
|
|
69
79
|
">",
|
|
70
|
-
|
|
80
|
+
);
|
|
71
81
|
return parseXreadgroupReply(reply);
|
|
72
82
|
},
|
|
73
83
|
async xack(key, group, id) {
|
|
74
|
-
return Number(await redis.
|
|
84
|
+
return Number(await typedStreams(redis).xack(key, group, id));
|
|
75
85
|
},
|
|
76
86
|
async publish(channel, message) {
|
|
77
87
|
return redis.publish(channel, message);
|
|
@@ -69,8 +69,9 @@ export interface SignalMessage {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/** Dead-letter entry with full attempt history. */
|
|
72
|
-
export interface DeadLetter extends SignalMessage {
|
|
72
|
+
export interface DeadLetter<T = unknown> extends Omit<SignalMessage, "payload" | "status"> {
|
|
73
73
|
readonly status: "dead";
|
|
74
|
+
readonly payload: T;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/** Per-subscriber lag / errors (broadcast physics). */
|