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
|
@@ -7,32 +7,37 @@
|
|
|
7
7
|
* binding stays in `on(http.resource(path, resource.all()))` — the factory
|
|
8
8
|
* itself registers no triggers.
|
|
9
9
|
*
|
|
10
|
-
* List URL
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `?col=not.eq.x` · `?or=(…)` / `?and=(…)` (nested `not.and` / `not.or`) ·
|
|
14
|
-
* `?order=col.desc,…` · `?select=id,title`.
|
|
15
|
-
* Every surface is whitelisted by a ColumnScope (`"all" | Column[] | "none"`).
|
|
10
|
+
* List URL grammar and parsing live in `list-query.ts` (shared with live
|
|
11
|
+
* query windows and hand-written flows); this factory resolves its
|
|
12
|
+
* declarative scopes and wires the parsed page into `fx.store(db)`.
|
|
16
13
|
*/
|
|
17
14
|
|
|
18
|
-
import { flow, type FlowDef, type FlowErrorMap } from "../../kernel/flow.ts";
|
|
15
|
+
import { flow, type AnyFlowDef, type FlowDef, type FlowErrorMap } from "../../kernel/flow.ts";
|
|
19
16
|
import { fail } from "../../kernel/errors.ts";
|
|
20
17
|
import type { Fx } from "../../kernel/fx.ts";
|
|
21
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
openLiveStream,
|
|
20
|
+
realtimeBridgeRuntime,
|
|
21
|
+
type LiveColumnKind,
|
|
22
|
+
} from "../../kernel/realtime-bind.ts";
|
|
23
|
+
import { compileWhere } from "./sql-condition.ts";
|
|
22
24
|
import { z } from "zod";
|
|
23
25
|
import type { SqlStoreDecl } from "./declare.ts";
|
|
24
26
|
import type { SqlRow } from "../../drivers/types.ts";
|
|
25
|
-
import { resolveColumns, resolveTableName
|
|
27
|
+
import { resolveColumns, resolveTableName } from "./table.ts";
|
|
28
|
+
import { registerPendingResourceLive } from "../../kernel/resource-live.ts";
|
|
26
29
|
import type { SqlPageOptions } from "./sql-session.ts";
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
import {
|
|
31
|
+
encodeCursor,
|
|
32
|
+
encodeOffsetCursor,
|
|
33
|
+
leafOp,
|
|
34
|
+
parseListQuery,
|
|
35
|
+
resolveListScope,
|
|
36
|
+
type ResolvedListConfig,
|
|
37
|
+
} from "./list-query.ts";
|
|
38
|
+
|
|
39
|
+
export type { ColumnScope, ListCountMode, ListPageMode } from "./list-query.ts";
|
|
40
|
+
export type { ResolvedListConfig } from "./list-query.ts";
|
|
36
41
|
|
|
37
42
|
/** Options for {@link resource}. */
|
|
38
43
|
export interface ResourceOptions {
|
|
@@ -59,12 +64,19 @@ export interface ResourceOptions {
|
|
|
59
64
|
* onto `store.resource`.
|
|
60
65
|
*/
|
|
61
66
|
readonly breaking?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Enable the live query stack: compiler synthesizes an internal Signal
|
|
69
|
+
* (`oke/live/sql:<table>`), wires CDC fan-out, and mounts
|
|
70
|
+
* `GET <path>/live` when used with `http.resource`. Requires RLS-capable
|
|
71
|
+
* SQL drivers (postgres / pglite).
|
|
72
|
+
*/
|
|
73
|
+
readonly live?: boolean;
|
|
62
74
|
}
|
|
63
75
|
|
|
64
76
|
/** List options on {@link ResourceOptions}. */
|
|
65
77
|
export interface ResourceListOptions {
|
|
66
78
|
/** Pagination; default `"cursor"` when `cursor` columns are set. */
|
|
67
|
-
readonly mode?: ListPageMode;
|
|
79
|
+
readonly mode?: import("./list-query.ts").ListPageMode;
|
|
68
80
|
/** Keyset columns (cursor mode). */
|
|
69
81
|
readonly cursor?: readonly unknown[];
|
|
70
82
|
/** Default sort when no `?order=` (default `"desc"`). */
|
|
@@ -74,15 +86,15 @@ export interface ResourceListOptions {
|
|
|
74
86
|
/** Hard cap on `?limit=` (default 100). */
|
|
75
87
|
readonly maxLimit?: number;
|
|
76
88
|
/** Offset-only `COUNT(*)` (default `"exact"`). */
|
|
77
|
-
readonly count?: ListCountMode;
|
|
89
|
+
readonly count?: import("./list-query.ts").ListCountMode;
|
|
78
90
|
/** Substring search columns (`?search=` / `?q=`). Default `"none"`. */
|
|
79
|
-
readonly search?: ColumnScope;
|
|
91
|
+
readonly search?: import("./list-query.ts").ColumnScope;
|
|
80
92
|
/** Filter grammar columns (`?col=op.value`). Default `"none"`. */
|
|
81
|
-
readonly filter?: ColumnScope;
|
|
93
|
+
readonly filter?: import("./list-query.ts").ColumnScope;
|
|
82
94
|
/** `?order=` columns. Default: cursor columns, else `"all"`. */
|
|
83
|
-
readonly order?: ColumnScope;
|
|
95
|
+
readonly order?: import("./list-query.ts").ColumnScope;
|
|
84
96
|
/** `?select=` projection columns (runtime only). Default `"all"`. */
|
|
85
|
-
readonly select?: ColumnScope;
|
|
97
|
+
readonly select?: import("./list-query.ts").ColumnScope;
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
/** One CRUD op bundle returned by {@link resource}. */
|
|
@@ -92,6 +104,8 @@ export interface ResourceFlowDefs {
|
|
|
92
104
|
readonly get: FlowDef<any, any, any>;
|
|
93
105
|
readonly update: FlowDef<any, any, any>;
|
|
94
106
|
readonly remove: FlowDef<any, any, any>;
|
|
107
|
+
/** Live surface when `live: true` — mounted by `http.resource(…, …all())`. */
|
|
108
|
+
readonly live?: ResourceLiveSurface;
|
|
95
109
|
}
|
|
96
110
|
|
|
97
111
|
/** Column lookup for {@link ResourceDef.page}. */
|
|
@@ -102,6 +116,22 @@ export interface ResourceColumns {
|
|
|
102
116
|
readonly sqlNameOf: (key: string) => string | undefined;
|
|
103
117
|
}
|
|
104
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Live query surface synthesized for `store.resource(…, { live: true })`.
|
|
121
|
+
*
|
|
122
|
+
* The signal carries classified {@link LiveQueryEvent}-shaped payloads;
|
|
123
|
+
* `flow` is a `delivery: "live"` SSE consumer (`fx.live` physics) that the
|
|
124
|
+
* app mounts on `GET <resource>/live`. The realtime bridge publishes to the
|
|
125
|
+
* same signal name, so transport, retention, and Last-Event-ID resume are
|
|
126
|
+
* the already-proven Signal stack.
|
|
127
|
+
*/
|
|
128
|
+
export interface ResourceLiveSurface {
|
|
129
|
+
/** Internal signal name — `oke/live/sql:<table>`. */
|
|
130
|
+
readonly signal: string;
|
|
131
|
+
/** Default subscribe path — `<mount path>/live` is derived at mount. */
|
|
132
|
+
readonly flow: AnyFlowDef;
|
|
133
|
+
}
|
|
134
|
+
|
|
105
135
|
/** A resource factory result — FlowDefs plus introspection for `page`. */
|
|
106
136
|
export interface ResourceDef extends ResourceColumns {
|
|
107
137
|
readonly list: FlowDef<any, any, any>;
|
|
@@ -119,398 +149,17 @@ export interface ResourceDef extends ResourceColumns {
|
|
|
119
149
|
readonly maxLimit: number;
|
|
120
150
|
/** Resolved list config (defaults applied). */
|
|
121
151
|
readonly listConfig: ResolvedListConfig;
|
|
152
|
+
/**
|
|
153
|
+
* Live query surface when `live: true` — signal name + SSE Flow to mount.
|
|
154
|
+
* Absent otherwise.
|
|
155
|
+
*/
|
|
156
|
+
readonly live?: ResourceLiveSurface;
|
|
122
157
|
/** All five ops for `http.resource(path, resource.all())`. */
|
|
123
158
|
all(): ResourceFlowDefs;
|
|
124
159
|
/** Input for {@link SqlStoreHandle.page} from validated list input. */
|
|
125
160
|
page(input: unknown): SqlPageOptions & { readonly meta: Record<string, unknown> };
|
|
126
161
|
}
|
|
127
162
|
|
|
128
|
-
/** List config after defaults are applied. */
|
|
129
|
-
export interface ResolvedListConfig {
|
|
130
|
-
readonly mode: ListPageMode;
|
|
131
|
-
readonly cursor: readonly ResolvedColumn[];
|
|
132
|
-
readonly direction: "asc" | "desc";
|
|
133
|
-
readonly limit: number;
|
|
134
|
-
readonly maxLimit: number;
|
|
135
|
-
readonly count: ListCountMode;
|
|
136
|
-
readonly search: readonly ResolvedColumn[];
|
|
137
|
-
readonly filter: readonly ResolvedColumn[] | "none";
|
|
138
|
-
readonly order: readonly ResolvedColumn[] | "none";
|
|
139
|
-
readonly select: readonly ResolvedColumn[] | "all";
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/* ———————————————————————————— drizzle-op builders (no drizzle import) ——— */
|
|
143
|
-
|
|
144
|
-
/** Build a Drizzle-shaped `SQL` leaf/group without importing `drizzle-orm`. */
|
|
145
|
-
interface SqlOp {
|
|
146
|
-
readonly queryChunks: readonly unknown[];
|
|
147
|
-
}
|
|
148
|
-
function strChunk(text: string): unknown {
|
|
149
|
-
return { constructor: { name: "StringChunk" }, value: [text] };
|
|
150
|
-
}
|
|
151
|
-
function paramChunk(value: unknown): unknown {
|
|
152
|
-
return { value };
|
|
153
|
-
}
|
|
154
|
-
function leafOp(column: unknown, op: string, value: unknown): SqlOp {
|
|
155
|
-
if (op === "is null" || op === "is not null") {
|
|
156
|
-
return { queryChunks: [column, strChunk(` ${op}`)] };
|
|
157
|
-
}
|
|
158
|
-
if (op === "in") {
|
|
159
|
-
const values = (value as readonly unknown[]).map(paramChunk);
|
|
160
|
-
return { queryChunks: [column, strChunk(" in "), values] };
|
|
161
|
-
}
|
|
162
|
-
return { queryChunks: [column, strChunk(` ${op} `), paramChunk(value)] };
|
|
163
|
-
}
|
|
164
|
-
function groupOp(joiner: "and" | "or", parts: readonly SqlOp[]): SqlOp {
|
|
165
|
-
const chunks: unknown[] = [];
|
|
166
|
-
parts.forEach((part, i) => {
|
|
167
|
-
if (i > 0) chunks.push(strChunk(` ${joiner} `));
|
|
168
|
-
chunks.push(part);
|
|
169
|
-
});
|
|
170
|
-
return { queryChunks: chunks };
|
|
171
|
-
}
|
|
172
|
-
function ascOp(column: unknown): SqlOp {
|
|
173
|
-
return { queryChunks: [column, strChunk(" asc")] };
|
|
174
|
-
}
|
|
175
|
-
function descOp(column: unknown): SqlOp {
|
|
176
|
-
return { queryChunks: [column, strChunk(" desc")] };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Recursive keyset predicate for "rows strictly after the cursor point". */
|
|
180
|
-
function keysetAfter(
|
|
181
|
-
columns: readonly unknown[],
|
|
182
|
-
values: readonly unknown[],
|
|
183
|
-
descending: boolean,
|
|
184
|
-
): SqlOp | undefined {
|
|
185
|
-
const cmp = descending ? "<" : ">";
|
|
186
|
-
const build = (i: number): SqlOp | undefined => {
|
|
187
|
-
if (i >= columns.length) return undefined;
|
|
188
|
-
const col = columns[i]!;
|
|
189
|
-
const rest = build(i + 1);
|
|
190
|
-
const tail =
|
|
191
|
-
rest === undefined ? undefined : groupOp("and", [leafOp(col, "=", values[i]), rest]);
|
|
192
|
-
return tail === undefined
|
|
193
|
-
? leafOp(col, cmp, values[i])
|
|
194
|
-
: groupOp("or", [leafOp(col, cmp, values[i]), tail]);
|
|
195
|
-
};
|
|
196
|
-
return build(0);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/* —————————————————————————————— cursor codec —————————————————————————— */
|
|
200
|
-
|
|
201
|
-
/** Keyset token direction — `?cursor=` carries either side. */
|
|
202
|
-
type CursorDir = "after" | "before";
|
|
203
|
-
|
|
204
|
-
function encodeOffsetCursor(offset: number): string {
|
|
205
|
-
return btoa(JSON.stringify({ k: "off", o: offset }));
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function decodeOffsetCursor(raw: string): number | null {
|
|
209
|
-
try {
|
|
210
|
-
const value: unknown = JSON.parse(atob(raw));
|
|
211
|
-
if (
|
|
212
|
-
value !== null &&
|
|
213
|
-
typeof value === "object" &&
|
|
214
|
-
"k" in value &&
|
|
215
|
-
value.k === "off" &&
|
|
216
|
-
"o" in value &&
|
|
217
|
-
typeof value.o === "number" &&
|
|
218
|
-
Number.isInteger(value.o) &&
|
|
219
|
-
value.o >= 0
|
|
220
|
-
) {
|
|
221
|
-
return value.o;
|
|
222
|
-
}
|
|
223
|
-
return null;
|
|
224
|
-
} catch {
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function encodeCursor(values: readonly unknown[], dir: CursorDir): string {
|
|
230
|
-
return btoa(JSON.stringify({ v: values, d: dir }));
|
|
231
|
-
}
|
|
232
|
-
function decodeCursor(
|
|
233
|
-
raw: string,
|
|
234
|
-
arity: number,
|
|
235
|
-
): { readonly values: readonly unknown[]; readonly dir: CursorDir } | null {
|
|
236
|
-
try {
|
|
237
|
-
const value: unknown = JSON.parse(atob(raw));
|
|
238
|
-
if (Array.isArray(value) && value.length === arity) {
|
|
239
|
-
return { values: value, dir: "after" };
|
|
240
|
-
}
|
|
241
|
-
if (
|
|
242
|
-
value !== null &&
|
|
243
|
-
typeof value === "object" &&
|
|
244
|
-
"v" in value &&
|
|
245
|
-
"d" in value &&
|
|
246
|
-
Array.isArray(value.v) &&
|
|
247
|
-
value.v.length === arity &&
|
|
248
|
-
(value.d === "after" || value.d === "before")
|
|
249
|
-
) {
|
|
250
|
-
return { values: value.v as unknown[], dir: value.d };
|
|
251
|
-
}
|
|
252
|
-
return null;
|
|
253
|
-
} catch {
|
|
254
|
-
return null;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function flipOrder(op: SqlOp): SqlOp {
|
|
259
|
-
return {
|
|
260
|
-
queryChunks: op.queryChunks.map((chunk) => {
|
|
261
|
-
if (
|
|
262
|
-
chunk !== null &&
|
|
263
|
-
typeof chunk === "object" &&
|
|
264
|
-
"value" in chunk &&
|
|
265
|
-
Array.isArray((chunk as { value: unknown }).value)
|
|
266
|
-
) {
|
|
267
|
-
const text = (chunk as { value: string[] }).value[0];
|
|
268
|
-
if (text === " asc") return strChunk(" desc");
|
|
269
|
-
if (text === " desc") return strChunk(" asc");
|
|
270
|
-
}
|
|
271
|
-
return chunk;
|
|
272
|
-
}),
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/* —————————————————————————————— URL parsing ——————————————————————————— */
|
|
277
|
-
|
|
278
|
-
const RESERVED_PARAMS = new Set([
|
|
279
|
-
"cursor",
|
|
280
|
-
"limit",
|
|
281
|
-
"offset",
|
|
282
|
-
"search",
|
|
283
|
-
"q",
|
|
284
|
-
"order",
|
|
285
|
-
"select",
|
|
286
|
-
"or",
|
|
287
|
-
"and",
|
|
288
|
-
]);
|
|
289
|
-
|
|
290
|
-
const FILTER_OPS = new Set(["eq", "neq", "gt", "gte", "lt", "lte", "like", "ilike", "in", "is"]);
|
|
291
|
-
|
|
292
|
-
function badInput(message: string, path: string) {
|
|
293
|
-
return validationFailure([{ message, path: [path] }]);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function parseGroupLead(
|
|
297
|
-
t: string,
|
|
298
|
-
): { readonly negated: boolean; readonly joiner: "and" | "or"; readonly body: string } | undefined {
|
|
299
|
-
if (t.startsWith("not.and(")) return { negated: true, joiner: "and", body: t.slice(7) };
|
|
300
|
-
if (t.startsWith("not.or(")) return { negated: true, joiner: "or", body: t.slice(6) };
|
|
301
|
-
if (t.startsWith("and(")) return { negated: false, joiner: "and", body: t.slice(3) };
|
|
302
|
-
if (t.startsWith("or(")) return { negated: false, joiner: "or", body: t.slice(2) };
|
|
303
|
-
return undefined;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function negateTermString(term: string): string {
|
|
307
|
-
const t = term.trim();
|
|
308
|
-
if (t.startsWith("not.and(")) return `and${t.slice(7)}`;
|
|
309
|
-
if (t.startsWith("not.or(")) return `or${t.slice(6)}`;
|
|
310
|
-
if (t.startsWith("and(")) return `not.and${t.slice(3)}`;
|
|
311
|
-
if (t.startsWith("or(")) return `not.or${t.slice(2)}`;
|
|
312
|
-
const dot = t.indexOf(".");
|
|
313
|
-
if (dot <= 0) return t;
|
|
314
|
-
return `${t.slice(0, dot)}.${invertOpString(t.slice(dot + 1))}`;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/** Invert a PostgREST op string (`not.eq.x` → `neq.x`) so SQL stays a leaf. */
|
|
318
|
-
function invertOpString(raw: string): string {
|
|
319
|
-
let rest = raw.trim();
|
|
320
|
-
if (rest.startsWith("not.")) return rest.slice(4);
|
|
321
|
-
const dot = rest.indexOf(".");
|
|
322
|
-
if (dot <= 0) return raw;
|
|
323
|
-
const op = rest.slice(0, dot);
|
|
324
|
-
const value = rest.slice(dot + 1);
|
|
325
|
-
if (op === "eq") return `neq.${value}`;
|
|
326
|
-
if (op === "neq") return `eq.${value}`;
|
|
327
|
-
if (op === "gt") return `lte.${value}`;
|
|
328
|
-
if (op === "gte") return `lt.${value}`;
|
|
329
|
-
if (op === "lt") return `gte.${value}`;
|
|
330
|
-
if (op === "lte") return `gt.${value}`;
|
|
331
|
-
if (op === "is" && (value === "null" || value === "unknown")) return "is.not_null";
|
|
332
|
-
if (op === "is" && value === "not_null") return "is.null";
|
|
333
|
-
if (op === "is" && value === "true") return "is.false";
|
|
334
|
-
if (op === "is" && value === "false") return "is.true";
|
|
335
|
-
return raw;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function splitTopLevel(inner: string): string[] {
|
|
339
|
-
const parts: string[] = [];
|
|
340
|
-
let buf = "";
|
|
341
|
-
let depth = 0;
|
|
342
|
-
let quote = false;
|
|
343
|
-
for (const ch of inner) {
|
|
344
|
-
if (quote) {
|
|
345
|
-
if (ch === '"') quote = false;
|
|
346
|
-
buf += ch;
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
if (ch === '"') {
|
|
350
|
-
quote = true;
|
|
351
|
-
buf += ch;
|
|
352
|
-
continue;
|
|
353
|
-
}
|
|
354
|
-
if (ch === "(") {
|
|
355
|
-
depth += 1;
|
|
356
|
-
buf += ch;
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
if (ch === ")") {
|
|
360
|
-
depth -= 1;
|
|
361
|
-
buf += ch;
|
|
362
|
-
continue;
|
|
363
|
-
}
|
|
364
|
-
if (ch === "," && depth === 0) {
|
|
365
|
-
parts.push(buf.trim());
|
|
366
|
-
buf = "";
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
buf += ch;
|
|
370
|
-
}
|
|
371
|
-
if (buf.trim().length > 0) parts.push(buf.trim());
|
|
372
|
-
return parts;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function unquote(raw: string): string {
|
|
376
|
-
const t = raw.trim();
|
|
377
|
-
if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) return t.slice(1, -1);
|
|
378
|
-
return t;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/** Parse `eq.x` / `not.eq.x` / `in.(a,"b,c")` / `is.null` into a drizzle op. */
|
|
382
|
-
function filterOp(
|
|
383
|
-
column: unknown,
|
|
384
|
-
key: string,
|
|
385
|
-
raw: string,
|
|
386
|
-
): SqlOp | { failure: ReturnType<typeof badInput> } {
|
|
387
|
-
let rest = raw.trim();
|
|
388
|
-
if (rest.startsWith("not.")) rest = invertOpString(rest);
|
|
389
|
-
const dot = rest.indexOf(".");
|
|
390
|
-
if (dot <= 0) return { failure: badInput(`expected "op.value" (e.g. ${key}=eq.x)`, key) };
|
|
391
|
-
const op = rest.slice(0, dot);
|
|
392
|
-
const value = rest.slice(dot + 1);
|
|
393
|
-
if (!FILTER_OPS.has(op)) {
|
|
394
|
-
return { failure: badInput(`unsupported filter op "${op}"`, key) };
|
|
395
|
-
}
|
|
396
|
-
let leaf: SqlOp | { failure: ReturnType<typeof badInput> };
|
|
397
|
-
switch (op) {
|
|
398
|
-
case "eq":
|
|
399
|
-
leaf = leafOp(column, "=", unquote(value));
|
|
400
|
-
break;
|
|
401
|
-
case "neq":
|
|
402
|
-
leaf = leafOp(column, "!=", unquote(value));
|
|
403
|
-
break;
|
|
404
|
-
case "gt":
|
|
405
|
-
leaf = leafOp(column, ">", unquote(value));
|
|
406
|
-
break;
|
|
407
|
-
case "gte":
|
|
408
|
-
leaf = leafOp(column, ">=", unquote(value));
|
|
409
|
-
break;
|
|
410
|
-
case "lt":
|
|
411
|
-
leaf = leafOp(column, "<", unquote(value));
|
|
412
|
-
break;
|
|
413
|
-
case "lte":
|
|
414
|
-
leaf = leafOp(column, "<=", unquote(value));
|
|
415
|
-
break;
|
|
416
|
-
case "like":
|
|
417
|
-
leaf = leafOp(column, "like", unquote(value).replaceAll("*", "%"));
|
|
418
|
-
break;
|
|
419
|
-
case "ilike":
|
|
420
|
-
leaf = leafOp(column, "ilike", unquote(value).replaceAll("*", "%"));
|
|
421
|
-
break;
|
|
422
|
-
case "is": {
|
|
423
|
-
if (value === "null" || value === "unknown") leaf = leafOp(column, "is null", undefined);
|
|
424
|
-
else if (value === "not_null") leaf = leafOp(column, "is not null", undefined);
|
|
425
|
-
else if (value === "true") leaf = leafOp(column, "=", true);
|
|
426
|
-
else if (value === "false") leaf = leafOp(column, "=", false);
|
|
427
|
-
else leaf = { failure: badInput(`is expects null|not_null|true|false|unknown`, key) };
|
|
428
|
-
break;
|
|
429
|
-
}
|
|
430
|
-
case "in": {
|
|
431
|
-
const match = /^\((.*)\)$/.exec(value);
|
|
432
|
-
if (!match) {
|
|
433
|
-
leaf = { failure: badInput(`in expects (a,b,c)`, key) };
|
|
434
|
-
break;
|
|
435
|
-
}
|
|
436
|
-
const inner = match[1]!.trim();
|
|
437
|
-
const values = inner.length === 0 ? [] : splitTopLevel(inner).map(unquote);
|
|
438
|
-
if (values.length === 0) {
|
|
439
|
-
leaf = { failure: badInput(`in expects at least one value`, key) };
|
|
440
|
-
break;
|
|
441
|
-
}
|
|
442
|
-
leaf = leafOp(column, "in", values);
|
|
443
|
-
break;
|
|
444
|
-
}
|
|
445
|
-
default:
|
|
446
|
-
leaf = { failure: badInput(`unsupported filter op "${op}"`, key) };
|
|
447
|
-
}
|
|
448
|
-
return leaf;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/** Split `or=(a.ilike.*x*,b.eq.1)` inner list on commas (nested-safe). */
|
|
452
|
-
function groupInner(raw: string): readonly string[] | null {
|
|
453
|
-
const match = /^\((.*)\)$/.exec(raw.trim());
|
|
454
|
-
if (!match) return null;
|
|
455
|
-
const inner = match[1]!.trim();
|
|
456
|
-
return inner.length === 0 ? [] : splitTopLevel(inner);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/** One `col.op.value` or nested `not.and(…)` term inside `or=` / `and=`. */
|
|
460
|
-
function groupedFilterTerm(
|
|
461
|
-
tableColumns: Readonly<Record<string, unknown>>,
|
|
462
|
-
term: string,
|
|
463
|
-
joiner: "or" | "and",
|
|
464
|
-
): SqlOp | { failure: ReturnType<typeof badInput> } {
|
|
465
|
-
const t = term.trim();
|
|
466
|
-
const group = parseGroupLead(t);
|
|
467
|
-
if (group) {
|
|
468
|
-
const inner = groupInner(group.body);
|
|
469
|
-
if (inner === null || inner.length === 0) {
|
|
470
|
-
return { failure: badInput(`${group.joiner} expects (col.op.value,…)`, joiner) };
|
|
471
|
-
}
|
|
472
|
-
const innerJoiner = group.negated ? (group.joiner === "and" ? "or" : "and") : group.joiner;
|
|
473
|
-
const parts: SqlOp[] = [];
|
|
474
|
-
for (const piece of inner) {
|
|
475
|
-
const next = group.negated ? negateTermString(piece) : piece;
|
|
476
|
-
const op = groupedFilterTerm(tableColumns, next, innerJoiner);
|
|
477
|
-
if ("failure" in op) return op;
|
|
478
|
-
parts.push(op);
|
|
479
|
-
}
|
|
480
|
-
return parts.length === 1 ? parts[0]! : groupOp(innerJoiner, parts);
|
|
481
|
-
}
|
|
482
|
-
const dot = t.indexOf(".");
|
|
483
|
-
if (dot <= 0) {
|
|
484
|
-
return { failure: badInput(`expected "col.op.value" inside ${joiner}=(…)`, joiner) };
|
|
485
|
-
}
|
|
486
|
-
const col = tableColumns[t.slice(0, dot)];
|
|
487
|
-
if (col === undefined) {
|
|
488
|
-
return { failure: badInput(`unknown column "${t.slice(0, dot)}"`, joiner) };
|
|
489
|
-
}
|
|
490
|
-
return filterOp(col, joiner, t.slice(dot + 1));
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/* —————————————————————————————— the factory ——————————————————————————— */
|
|
494
|
-
|
|
495
|
-
function scopeColumns(
|
|
496
|
-
scope: ColumnScope,
|
|
497
|
-
columns: readonly ResolvedColumn[],
|
|
498
|
-
tableColumns: Readonly<Record<string, unknown>>,
|
|
499
|
-
): readonly ResolvedColumn[] | "none" | "all" {
|
|
500
|
-
if (scope === "none") return "none";
|
|
501
|
-
if (scope === "all") return columns;
|
|
502
|
-
const allowed = new Set(
|
|
503
|
-
scope
|
|
504
|
-
.map((c) =>
|
|
505
|
-
columns.find(
|
|
506
|
-
(col) => tableColumns[col.key] === c || col.sqlName === (c as { name?: string }).name,
|
|
507
|
-
),
|
|
508
|
-
)
|
|
509
|
-
.filter((c): c is ResolvedColumn => c !== undefined),
|
|
510
|
-
);
|
|
511
|
-
return columns.filter((c) => allowed.has(c));
|
|
512
|
-
}
|
|
513
|
-
|
|
514
163
|
/**
|
|
515
164
|
* Build a CRUD + list resource over a sql table. Returns FlowDefs plus
|
|
516
165
|
* `.all()` for `on(http.resource(path, resource.all()))`.
|
|
@@ -536,285 +185,24 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
|
|
|
536
185
|
const idKey = idColumn?.key ?? "id";
|
|
537
186
|
const idDrizzleCol = idColumn !== undefined ? tableColumns[idColumn.key] : tableColumns.id;
|
|
538
187
|
|
|
539
|
-
const
|
|
540
|
-
const
|
|
541
|
-
const resolvedCursor =
|
|
542
|
-
|
|
543
|
-
columns.find(
|
|
544
|
-
(col) => tableColumns[col.key] === c || col.sqlName === (c as { name?: string }).name,
|
|
545
|
-
),
|
|
546
|
-
)
|
|
547
|
-
.filter((c): c is ResolvedColumn => c !== undefined);
|
|
548
|
-
const mode: ListPageMode = list.mode ?? (resolvedCursor.length > 0 ? "cursor" : "offset");
|
|
549
|
-
const direction = list.direction ?? "desc";
|
|
550
|
-
const limit = list.limit ?? 20;
|
|
551
|
-
const maxLimit = list.maxLimit ?? 100;
|
|
552
|
-
const countMode = list.count ?? "exact";
|
|
553
|
-
|
|
554
|
-
const searchCols = scopeColumns(list.search ?? "none", columns, tableColumns);
|
|
555
|
-
const filterCols = scopeColumns(list.filter ?? "none", columns, tableColumns);
|
|
556
|
-
const orderScope =
|
|
557
|
-
list.order ?? (resolvedCursor.length > 0 ? (cursorCols as readonly unknown[]) : "all");
|
|
558
|
-
const orderCols = scopeColumns(orderScope, columns, tableColumns);
|
|
559
|
-
const selectScope = list.select ?? "all";
|
|
560
|
-
|
|
561
|
-
const filterAllowed = new Set(
|
|
562
|
-
filterCols === "none" || filterCols === "all" ? [] : filterCols.map((c) => c.key),
|
|
563
|
-
);
|
|
564
|
-
const orderAllowed = new Set(
|
|
565
|
-
orderCols === "none" || orderCols === "all" ? [] : orderCols.map((c) => c.key),
|
|
566
|
-
);
|
|
567
|
-
const searchList = searchCols === "none" || searchCols === "all" ? [] : searchCols;
|
|
568
|
-
|
|
569
|
-
const config: ResolvedListConfig = {
|
|
570
|
-
mode,
|
|
571
|
-
cursor: resolvedCursor,
|
|
572
|
-
direction,
|
|
573
|
-
limit,
|
|
574
|
-
maxLimit,
|
|
575
|
-
count: countMode,
|
|
576
|
-
search: searchCols === "none" || searchCols === "all" ? [] : searchCols,
|
|
577
|
-
filter: filterCols === "none" ? "none" : filterCols === "all" ? columns : filterCols,
|
|
578
|
-
order: orderCols === "none" ? "none" : orderCols === "all" ? columns : orderCols,
|
|
579
|
-
select:
|
|
580
|
-
selectScope === "all"
|
|
581
|
-
? "all"
|
|
582
|
-
: (scopeColumns(selectScope, columns, tableColumns) as readonly ResolvedColumn[]),
|
|
583
|
-
};
|
|
188
|
+
const scope = resolveListScope(table, options.list);
|
|
189
|
+
const config = scope.listConfig;
|
|
190
|
+
const { mode, limit, maxLimit, cursorColumns: resolvedCursor } = scope;
|
|
191
|
+
const countMode = scope.count;
|
|
584
192
|
|
|
585
193
|
const errors = options.errors ?? ({ NotFound: {} as never } as FlowErrorMap);
|
|
586
194
|
const breaking = options.breaking === true;
|
|
587
195
|
|
|
588
|
-
/** Parse validated list input → page options or a validation failure. */
|
|
589
|
-
function parseList(input: unknown):
|
|
590
|
-
| {
|
|
591
|
-
ok: true;
|
|
592
|
-
page: SqlPageOptions;
|
|
593
|
-
meta: Record<string, unknown>;
|
|
594
|
-
select?: readonly ResolvedColumn[];
|
|
595
|
-
cursorDir?: CursorDir;
|
|
596
|
-
}
|
|
597
|
-
| { ok: false; failure: unknown } {
|
|
598
|
-
const query = (input ?? {}) as Record<string, unknown>;
|
|
599
|
-
const str = (k: string): string | undefined => {
|
|
600
|
-
const v = query[k];
|
|
601
|
-
if (typeof v === "string" && v.length > 0) return v;
|
|
602
|
-
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
|
603
|
-
return undefined;
|
|
604
|
-
};
|
|
605
|
-
|
|
606
|
-
// limit
|
|
607
|
-
let pageLimit = limit;
|
|
608
|
-
const rawLimit = str("limit");
|
|
609
|
-
if (rawLimit !== undefined) {
|
|
610
|
-
const n = Number(rawLimit);
|
|
611
|
-
if (!Number.isInteger(n) || n < 1) {
|
|
612
|
-
return { ok: false, failure: badInput("limit must be a positive integer", "limit") };
|
|
613
|
-
}
|
|
614
|
-
pageLimit = Math.min(n, maxLimit);
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
const wheres: unknown[] = [];
|
|
618
|
-
let meta: Record<string, unknown> = {};
|
|
619
|
-
|
|
620
|
-
// search
|
|
621
|
-
const search = str("search");
|
|
622
|
-
const q = str("q");
|
|
623
|
-
if (search !== undefined && q !== undefined) {
|
|
624
|
-
return { ok: false, failure: badInput("use either search or q, not both", "search") };
|
|
625
|
-
}
|
|
626
|
-
const searchTerm = search ?? q;
|
|
627
|
-
if (searchTerm !== undefined) {
|
|
628
|
-
if (searchList.length === 0) {
|
|
629
|
-
return {
|
|
630
|
-
ok: false,
|
|
631
|
-
failure: badInput("search is not enabled for this resource", "search"),
|
|
632
|
-
};
|
|
633
|
-
}
|
|
634
|
-
const pattern = `%${searchTerm}%`;
|
|
635
|
-
const parts = searchList
|
|
636
|
-
.map((c) => tableColumns[c.key])
|
|
637
|
-
.filter((c): c is unknown => c !== undefined)
|
|
638
|
-
.map((c) => leafOp(c, "like", pattern) as SqlOp);
|
|
639
|
-
wheres.push(parts.length === 1 ? parts[0] : groupOp("or", parts));
|
|
640
|
-
meta.search = searchTerm;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// column filters
|
|
644
|
-
if (config.filter === "none") {
|
|
645
|
-
for (const key of Object.keys(query)) {
|
|
646
|
-
if (!RESERVED_PARAMS.has(key) && key !== "headers" && key !== "cookie") {
|
|
647
|
-
return { ok: false, failure: badInput(`unknown list param "${key}"`, key) };
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
} else {
|
|
651
|
-
for (const [key, value] of Object.entries(query)) {
|
|
652
|
-
if (RESERVED_PARAMS.has(key) || key === "headers" || key === "cookie") continue;
|
|
653
|
-
if (typeof value !== "string") continue;
|
|
654
|
-
const col = tableColumns[key];
|
|
655
|
-
const resolved = columns.find((c) => c.key === key);
|
|
656
|
-
if (col === undefined || resolved === undefined || !filterAllowed.has(key)) {
|
|
657
|
-
return { ok: false, failure: badInput(`unknown or unfilterable column "${key}"`, key) };
|
|
658
|
-
}
|
|
659
|
-
const op = filterOp(col, key, value);
|
|
660
|
-
if ("failure" in op) return { ok: false, failure: op.failure };
|
|
661
|
-
wheres.push(op);
|
|
662
|
-
}
|
|
663
|
-
// or= / and= groups (one level)
|
|
664
|
-
for (const joiner of ["or", "and"] as const) {
|
|
665
|
-
const rawGroup = str(joiner);
|
|
666
|
-
if (rawGroup === undefined) continue;
|
|
667
|
-
const inner = groupInner(rawGroup);
|
|
668
|
-
if (inner === null || inner.length === 0) {
|
|
669
|
-
return { ok: false, failure: badInput(`${joiner} expects (col.op.value,…)`, joiner) };
|
|
670
|
-
}
|
|
671
|
-
const parts: SqlOp[] = [];
|
|
672
|
-
for (const term of inner) {
|
|
673
|
-
const op = groupedFilterTerm(tableColumns, term, joiner);
|
|
674
|
-
if ("failure" in op) return { ok: false, failure: op.failure };
|
|
675
|
-
parts.push(op);
|
|
676
|
-
}
|
|
677
|
-
wheres.push(parts.length === 1 ? parts[0] : groupOp(joiner, parts));
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
// order
|
|
682
|
-
let orders: SqlOp[] | undefined;
|
|
683
|
-
const rawOrder = str("order");
|
|
684
|
-
if (rawOrder !== undefined) {
|
|
685
|
-
if (config.order === "none") {
|
|
686
|
-
return { ok: false, failure: badInput("order is not enabled for this resource", "order") };
|
|
687
|
-
}
|
|
688
|
-
orders = [];
|
|
689
|
-
for (const term of rawOrder.split(",")) {
|
|
690
|
-
const [key, dir] = term.split(".");
|
|
691
|
-
if (!key || (dir !== undefined && dir !== "asc" && dir !== "desc")) {
|
|
692
|
-
return { ok: false, failure: badInput(`bad order term "${term}"`, "order") };
|
|
693
|
-
}
|
|
694
|
-
const col = tableColumns[key];
|
|
695
|
-
const resolved = columns.find((c) => c.key === key);
|
|
696
|
-
if (col === undefined || resolved === undefined || !orderAllowed.has(key)) {
|
|
697
|
-
return {
|
|
698
|
-
ok: false,
|
|
699
|
-
failure: badInput(`unknown or unorderable column "${key}"`, "order"),
|
|
700
|
-
};
|
|
701
|
-
}
|
|
702
|
-
orders.push(dir === "asc" ? ascOp(col) : descOp(col));
|
|
703
|
-
}
|
|
704
|
-
meta.order = rawOrder;
|
|
705
|
-
} else if (resolvedCursor.length > 0) {
|
|
706
|
-
orders = resolvedCursor.map((c) => {
|
|
707
|
-
const col = tableColumns[c.key];
|
|
708
|
-
return direction === "asc" ? ascOp(col) : descOp(col);
|
|
709
|
-
});
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// select projection
|
|
713
|
-
let select: ResolvedColumn[] | undefined;
|
|
714
|
-
const rawSelect = str("select");
|
|
715
|
-
if (rawSelect !== undefined) {
|
|
716
|
-
if (config.select !== "all") {
|
|
717
|
-
return {
|
|
718
|
-
ok: false,
|
|
719
|
-
failure: badInput("select is not enabled for this resource", "select"),
|
|
720
|
-
};
|
|
721
|
-
}
|
|
722
|
-
select = [];
|
|
723
|
-
for (const key of rawSelect.split(",")) {
|
|
724
|
-
const resolved = columns.find((c) => c.key === key);
|
|
725
|
-
if (resolved === undefined) {
|
|
726
|
-
return { ok: false, failure: badInput(`unknown column "${key}"`, "select") };
|
|
727
|
-
}
|
|
728
|
-
select.push(resolved);
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
// pagination
|
|
733
|
-
let offset: number | undefined;
|
|
734
|
-
let after: unknown;
|
|
735
|
-
let before: unknown;
|
|
736
|
-
let cursorDir: CursorDir | undefined;
|
|
737
|
-
let orderBy = orders;
|
|
738
|
-
if (mode === "offset") {
|
|
739
|
-
const rawCursor = str("cursor");
|
|
740
|
-
const rawOffset = str("offset");
|
|
741
|
-
if (rawCursor !== undefined) {
|
|
742
|
-
const fromCursor = decodeOffsetCursor(rawCursor);
|
|
743
|
-
if (fromCursor === null) {
|
|
744
|
-
return { ok: false, failure: badInput("invalid cursor", "cursor") };
|
|
745
|
-
}
|
|
746
|
-
offset = fromCursor;
|
|
747
|
-
} else if (rawOffset !== undefined) {
|
|
748
|
-
const n = Number(rawOffset);
|
|
749
|
-
if (!Number.isInteger(n) || n < 0) {
|
|
750
|
-
return {
|
|
751
|
-
ok: false,
|
|
752
|
-
failure: badInput("offset must be a non-negative integer", "offset"),
|
|
753
|
-
};
|
|
754
|
-
}
|
|
755
|
-
offset = n;
|
|
756
|
-
}
|
|
757
|
-
} else {
|
|
758
|
-
const rawCursor = str("cursor");
|
|
759
|
-
if (rawCursor !== undefined) {
|
|
760
|
-
if (resolvedCursor.length === 0) {
|
|
761
|
-
return { ok: false, failure: badInput("cursor pagination is not configured", "cursor") };
|
|
762
|
-
}
|
|
763
|
-
const decoded = decodeCursor(rawCursor, resolvedCursor.length);
|
|
764
|
-
if (decoded === null) {
|
|
765
|
-
return { ok: false, failure: badInput("invalid cursor", "cursor") };
|
|
766
|
-
}
|
|
767
|
-
cursorDir = decoded.dir;
|
|
768
|
-
const cols = resolvedCursor.map((c) => tableColumns[c.key]);
|
|
769
|
-
if (decoded.dir === "before") {
|
|
770
|
-
before = keysetAfter(cols, decoded.values, direction !== "desc");
|
|
771
|
-
orderBy = (orders ?? []).map(flipOrder);
|
|
772
|
-
} else {
|
|
773
|
-
after = keysetAfter(cols, decoded.values, direction === "desc");
|
|
774
|
-
}
|
|
775
|
-
meta.cursor = rawCursor;
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
const where =
|
|
780
|
-
wheres.length === 0
|
|
781
|
-
? undefined
|
|
782
|
-
: wheres.length === 1
|
|
783
|
-
? wheres[0]
|
|
784
|
-
: groupOp("and", wheres as SqlOp[]);
|
|
785
|
-
return {
|
|
786
|
-
ok: true,
|
|
787
|
-
page: {
|
|
788
|
-
where,
|
|
789
|
-
orderBy,
|
|
790
|
-
limit: pageLimit + (mode === "cursor" ? 1 : 0),
|
|
791
|
-
offset,
|
|
792
|
-
after,
|
|
793
|
-
before,
|
|
794
|
-
},
|
|
795
|
-
meta: { mode, limit: pageLimit, ...meta },
|
|
796
|
-
...(select !== undefined ? { select } : {}),
|
|
797
|
-
...(cursorDir !== undefined ? { cursorDir } : {}),
|
|
798
|
-
};
|
|
799
|
-
}
|
|
800
|
-
|
|
801
196
|
/** Run the list query and shape rows + meta. */
|
|
802
197
|
async function runList(
|
|
803
198
|
input: unknown,
|
|
804
199
|
fx: Fx,
|
|
805
200
|
): Promise<{ data: SqlRow[]; meta: Record<string, unknown> } | { failure: unknown }> {
|
|
806
|
-
const parsed =
|
|
201
|
+
const parsed = parseListQuery(input, scope.query);
|
|
807
202
|
if (!parsed.ok) return { failure: parsed.failure };
|
|
808
203
|
const store = fx.store(db) as {
|
|
809
204
|
page(t: unknown, o: SqlPageOptions): Promise<SqlRow[]>;
|
|
810
205
|
count(t: unknown, w?: unknown): Promise<number>;
|
|
811
|
-
select(columns?: unknown): {
|
|
812
|
-
from(t: unknown): {
|
|
813
|
-
where(w: unknown): {
|
|
814
|
-
orderBy(...o: unknown[]): { limit(n: number): Promise<SqlRow[]> };
|
|
815
|
-
};
|
|
816
|
-
};
|
|
817
|
-
};
|
|
818
206
|
};
|
|
819
207
|
const rows = await store.page(table, parsed.page);
|
|
820
208
|
const meta: Record<string, unknown> = { ...parsed.meta };
|
|
@@ -874,7 +262,8 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
|
|
|
874
262
|
const listFlow = flow("list", {
|
|
875
263
|
...(breaking ? { breaking: true as const } : {}),
|
|
876
264
|
// Loose record so the HTTP AoT infers `query` and lets every list URL
|
|
877
|
-
// key through; real validation happens in
|
|
265
|
+
// key through; real validation happens in parseListQuery (PostgREST
|
|
266
|
+
// grammar).
|
|
878
267
|
in: z.record(z.string(), z.unknown()) as never,
|
|
879
268
|
effects: { reads: [db.ref] },
|
|
880
269
|
do: async (input, fx) => {
|
|
@@ -973,7 +362,83 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
|
|
|
973
362
|
remove: removeFlow as FlowDef<any, any, any>,
|
|
974
363
|
};
|
|
975
364
|
|
|
976
|
-
|
|
365
|
+
// Live query surface — internal signal name + SSE stream Flow. Developers
|
|
366
|
+
// mount it via `http.resource` (or a manual `.live()` GET); the realtime
|
|
367
|
+
// bridge classifies CDC events per subscriber and pushes through this
|
|
368
|
+
// flow. Classified events are per-identity RLS verdicts, so delivery is
|
|
369
|
+
// direct per-subscriber push — never a shared signal tape.
|
|
370
|
+
//
|
|
371
|
+
// `live: false` is the explicit per-table opt-out for the project-wide
|
|
372
|
+
// `store.live` default — never builds a surface. `live: true` builds it
|
|
373
|
+
// immediately. When `live` is omitted the surface is deferred: it is
|
|
374
|
+
// recorded as a pending live default and drained by `oke()` only when
|
|
375
|
+
// `store: { live: true }` is set.
|
|
376
|
+
const buildLiveSurface = (): ResourceLiveSurface => {
|
|
377
|
+
const signalName = `oke/live/sql:${tableName}`;
|
|
378
|
+
const pkColumn = idColumn?.sqlName ?? idKey;
|
|
379
|
+
const columnKinds: Record<string, LiveColumnKind> = {};
|
|
380
|
+
for (const c of columns) {
|
|
381
|
+
const kind: LiveColumnKind | undefined =
|
|
382
|
+
c.sqlType === "BOOLEAN"
|
|
383
|
+
? "boolean"
|
|
384
|
+
: c.sqlType === "INTEGER" || c.sqlType === "BIGINT" || c.sqlType === "REAL"
|
|
385
|
+
? "number"
|
|
386
|
+
: c.sqlType === "JSONB"
|
|
387
|
+
? undefined
|
|
388
|
+
: "string";
|
|
389
|
+
if (kind !== undefined) columnKinds[c.key] = kind;
|
|
390
|
+
}
|
|
391
|
+
const liveFlow = flow(`_live_${tableName}`, {
|
|
392
|
+
...(options.out !== undefined ? { out: options.out } : {}),
|
|
393
|
+
effects: { reads: [db.ref, `signal:${signalName}`] },
|
|
394
|
+
do: async (input: unknown, fx: Fx) => {
|
|
395
|
+
const bridge = realtimeBridgeRuntime();
|
|
396
|
+
if (!bridge) {
|
|
397
|
+
throw new Error(
|
|
398
|
+
`live query for "${tableName}" requires an RLS-capable SQL driver (postgres / pglite)`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
// Same PostgREST window as the list Flow — filters decide whether
|
|
402
|
+
// a row belongs to this live query ("query exit"), RLS decides
|
|
403
|
+
// visibility. Pagination cursors don't gate membership.
|
|
404
|
+
const parsed = parseListQuery(input, scope.query);
|
|
405
|
+
if (!parsed.ok) return parsed.failure;
|
|
406
|
+
let whereSql: string | undefined;
|
|
407
|
+
let whereParams: readonly unknown[] = [];
|
|
408
|
+
const pageWhere = parsed.page.where;
|
|
409
|
+
if (pageWhere !== undefined) {
|
|
410
|
+
const compiled = compileWhere(pageWhere);
|
|
411
|
+
if (compiled.clause !== "") {
|
|
412
|
+
whereSql = compiled.clause;
|
|
413
|
+
whereParams = compiled.params;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const identity = fx.rlsIdentity;
|
|
417
|
+
if (!identity) {
|
|
418
|
+
throw new Error(`live query for "${tableName}" requires a gated identity`);
|
|
419
|
+
}
|
|
420
|
+
const stream = openLiveStream(fx.id(), {
|
|
421
|
+
table: tableName,
|
|
422
|
+
identity,
|
|
423
|
+
pkColumn,
|
|
424
|
+
...(whereSql !== undefined ? { whereSql } : {}),
|
|
425
|
+
...(whereParams.length > 0 ? { whereParams } : {}),
|
|
426
|
+
...(Object.keys(columnKinds).length > 0 ? { tableColumns: columnKinds } : {}),
|
|
427
|
+
});
|
|
428
|
+
return fx.json.stream(stream.chunks);
|
|
429
|
+
},
|
|
430
|
+
}) as unknown as AnyFlowDef;
|
|
431
|
+
return { signal: signalName, flow: liveFlow };
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// Immediate vs deferred live surface:
|
|
435
|
+
// - `live: true` — build now (today's explicit opt-in, unchanged).
|
|
436
|
+
// - `live: false` — the per-table opt-out for the project default.
|
|
437
|
+
// - omitted — record a pending default surface, drained by `oke()` when
|
|
438
|
+
// `store: { live: true }` is set; otherwise discarded (today's behavior).
|
|
439
|
+
const signalName = `oke/live/sql:${tableName}`;
|
|
440
|
+
let liveSurface: ResourceLiveSurface | undefined;
|
|
441
|
+
const resolved: ResourceDef = {
|
|
977
442
|
...defs,
|
|
978
443
|
table: tableName,
|
|
979
444
|
idKey,
|
|
@@ -984,9 +449,24 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
|
|
|
984
449
|
sqlNameOf(key) {
|
|
985
450
|
return columns.find((c) => c.key === key)?.sqlName;
|
|
986
451
|
},
|
|
987
|
-
all: () =>
|
|
452
|
+
all: () => {
|
|
453
|
+
// Live surface rides along so `http.resource(path, notesR.all())` can
|
|
454
|
+
// mount `GET <path>/live` from the resource even when the caller uses
|
|
455
|
+
// `.all()` (existing docs examples pass `.all()` directly). Prefer a
|
|
456
|
+
// drain-stamped surface, then the immediate closure surface.
|
|
457
|
+
const bag = defs as ResourceFlowDefs;
|
|
458
|
+
const stamped = (bag as { readonly live?: ResourceLiveSurface }).live;
|
|
459
|
+
const effective = stamped ?? liveSurface;
|
|
460
|
+
if (effective) {
|
|
461
|
+
Object.defineProperty(bag, "live", {
|
|
462
|
+
value: effective,
|
|
463
|
+
enumerable: true,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
return bag;
|
|
467
|
+
},
|
|
988
468
|
page(input) {
|
|
989
|
-
const parsed =
|
|
469
|
+
const parsed = parseListQuery(input, scope.query);
|
|
990
470
|
if (!parsed.ok) {
|
|
991
471
|
throw new TypeError(
|
|
992
472
|
`resource.page: invalid list input — ${JSON.stringify(parsed.failure)}`,
|
|
@@ -995,4 +475,33 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
|
|
|
995
475
|
return { ...parsed.page, meta: parsed.meta };
|
|
996
476
|
},
|
|
997
477
|
};
|
|
478
|
+
// Table-level `store.schema.live(false)` is the explicit per-table opt-out
|
|
479
|
+
// from the project-wide `store.live` default. It only cancels the DEFAULT —
|
|
480
|
+
// an explicit `options.live: true` on the resource still wins so today's
|
|
481
|
+
// opt-in behavior is unchanged.
|
|
482
|
+
const tableLiveOptedOut =
|
|
483
|
+
table !== null &&
|
|
484
|
+
typeof table === "object" &&
|
|
485
|
+
(table as { readonly live?: boolean }).live === false;
|
|
486
|
+
|
|
487
|
+
if (options.live === true) {
|
|
488
|
+
liveSurface = buildLiveSurface();
|
|
489
|
+
} else if (options.live !== false && !tableLiveOptedOut) {
|
|
490
|
+
const pending: { readonly signalName: string } = { signalName };
|
|
491
|
+
Object.defineProperty(defs, "pendingLive", { value: pending, configurable: true });
|
|
492
|
+
registerPendingResourceLive({
|
|
493
|
+
signalName,
|
|
494
|
+
def: resolved as object,
|
|
495
|
+
bag: defs as object,
|
|
496
|
+
build: buildLiveSurface,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (liveSurface) {
|
|
501
|
+
Object.defineProperty(resolved, "live", {
|
|
502
|
+
value: liveSurface,
|
|
503
|
+
enumerable: true,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return resolved;
|
|
998
507
|
}
|