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
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PostgREST-shaped list grammar — URL parsing and page compilation,
|
|
3
|
+
* table-agnostic and factory-independent.
|
|
4
|
+
*
|
|
5
|
+
* Extracted verbatim from the `store.resource` factory closure so any
|
|
6
|
+
* surface (resource lists, live query windows, hand-written flows via
|
|
7
|
+
* `liveQuery`) parses the exact same grammar. Every config knob is
|
|
8
|
+
* whitelisted by a ColumnScope (`"all" | Column[] | "none"`), applied at
|
|
9
|
+
* {@link resolveListScope} time.
|
|
10
|
+
*
|
|
11
|
+
* List URL (UTF-8 values — English, Arabic, …):
|
|
12
|
+
* `?cursor=` / `?offset=` / `?limit=` · `?search=` (`?q=` alias) ·
|
|
13
|
+
* `?col=eq.x|neq|gt|gte|lt|lte|like.*p*|ilike.*p*|in.(a,b)|is.null|not_null|true|false` ·
|
|
14
|
+
* `?col=not.eq.x` · `?or=(…)` / `?and=(…)` (nested `not.and` / `not.or`) ·
|
|
15
|
+
* `?order=col.desc,…` · `?select=id,title`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { validationFailure } from "../../validation/standard-schema.ts";
|
|
19
|
+
import type { SqlPageOptions } from "./sql-session.ts";
|
|
20
|
+
import { resolveColumns, resolveTableName, type ResolvedColumn } from "./table.ts";
|
|
21
|
+
|
|
22
|
+
/** Whitelist for one list concern (`search` / `filter` / `order` / `select`). */
|
|
23
|
+
export type ColumnScope = "all" | readonly unknown[] | "none";
|
|
24
|
+
|
|
25
|
+
/** Pagination mode for a resource list. */
|
|
26
|
+
export type ListPageMode = "cursor" | "offset";
|
|
27
|
+
|
|
28
|
+
/** Offset-mode `COUNT(*)` policy. */
|
|
29
|
+
export type ListCountMode = "exact" | "none";
|
|
30
|
+
|
|
31
|
+
/** Declarative list surface (pre-resolution). */
|
|
32
|
+
export interface ListOptions {
|
|
33
|
+
/** Pagination; default `"cursor"` when `cursor` columns are set. */
|
|
34
|
+
readonly mode?: ListPageMode;
|
|
35
|
+
/** Keyset columns (cursor mode). */
|
|
36
|
+
readonly cursor?: readonly unknown[];
|
|
37
|
+
/** Default sort when no `?order=` (default `"desc"`). */
|
|
38
|
+
readonly direction?: "asc" | "desc";
|
|
39
|
+
/** Default page size (default 20). */
|
|
40
|
+
readonly limit?: number;
|
|
41
|
+
/** Hard cap on `?limit=` (default 100). */
|
|
42
|
+
readonly maxLimit?: number;
|
|
43
|
+
/** Offset-only `COUNT(*)` (default `"exact"`). */
|
|
44
|
+
readonly count?: ListCountMode;
|
|
45
|
+
/** Substring search columns (`?search=` / `?q=`). Default `"none"`. */
|
|
46
|
+
readonly search?: ColumnScope;
|
|
47
|
+
/** Filter grammar columns (`?col=op.value`). Default `"none"`. */
|
|
48
|
+
readonly filter?: ColumnScope;
|
|
49
|
+
/** `?order=` columns. Default: cursor columns, else `"all"`. */
|
|
50
|
+
readonly order?: ColumnScope;
|
|
51
|
+
/** `?select=` projection columns (runtime only). Default `"all"`. */
|
|
52
|
+
readonly select?: ColumnScope;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** List config after defaults are applied. */
|
|
56
|
+
export interface ResolvedListConfig {
|
|
57
|
+
readonly mode: ListPageMode;
|
|
58
|
+
readonly cursor: readonly ResolvedColumn[];
|
|
59
|
+
readonly direction: "asc" | "desc";
|
|
60
|
+
readonly limit: number;
|
|
61
|
+
readonly maxLimit: number;
|
|
62
|
+
readonly count: ListCountMode;
|
|
63
|
+
readonly search: readonly ResolvedColumn[];
|
|
64
|
+
readonly filter: readonly ResolvedColumn[] | "none";
|
|
65
|
+
readonly order: readonly ResolvedColumn[] | "none";
|
|
66
|
+
readonly select: readonly ResolvedColumn[] | "all";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Fully-resolved parse-time scope for one list surface. */
|
|
70
|
+
export interface ListQueryScope {
|
|
71
|
+
readonly mode: ListPageMode;
|
|
72
|
+
readonly direction: "asc" | "desc";
|
|
73
|
+
readonly limit: number;
|
|
74
|
+
readonly maxLimit: number;
|
|
75
|
+
readonly columns: readonly ResolvedColumn[];
|
|
76
|
+
readonly tableColumns: Readonly<Record<string, unknown>>;
|
|
77
|
+
readonly searchColumns: readonly ResolvedColumn[];
|
|
78
|
+
/** `"none"` rejects the filter grammar entirely (unknown params fail). */
|
|
79
|
+
readonly filterColumns: readonly ResolvedColumn[] | "none";
|
|
80
|
+
readonly orderColumns: readonly ResolvedColumn[] | "none";
|
|
81
|
+
readonly selectColumns: readonly ResolvedColumn[] | "all";
|
|
82
|
+
readonly cursorColumns: readonly ResolvedColumn[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Result of {@link resolveListScope} — everything a list surface needs. */
|
|
86
|
+
export interface ResolvedListScope {
|
|
87
|
+
readonly mode: ListPageMode;
|
|
88
|
+
readonly direction: "asc" | "desc";
|
|
89
|
+
readonly limit: number;
|
|
90
|
+
readonly maxLimit: number;
|
|
91
|
+
readonly count: ListCountMode;
|
|
92
|
+
/** Keyset columns (cursor mode), resolved against the table. */
|
|
93
|
+
readonly cursorColumns: readonly ResolvedColumn[];
|
|
94
|
+
/** Public introspection shape (defaults applied). */
|
|
95
|
+
readonly listConfig: ResolvedListConfig;
|
|
96
|
+
/** Parse-time scope for {@link parseListQuery}. */
|
|
97
|
+
readonly query: ListQueryScope;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ———————————————————————————— drizzle-op builders (no drizzle import) ——— */
|
|
101
|
+
|
|
102
|
+
/** Build a Drizzle-shaped `SQL` leaf/group without importing `drizzle-orm`. */
|
|
103
|
+
export interface SqlOp {
|
|
104
|
+
readonly queryChunks: readonly unknown[];
|
|
105
|
+
}
|
|
106
|
+
function strChunk(text: string): unknown {
|
|
107
|
+
return { constructor: { name: "StringChunk" }, value: [text] };
|
|
108
|
+
}
|
|
109
|
+
function paramChunk(value: unknown): unknown {
|
|
110
|
+
return { value };
|
|
111
|
+
}
|
|
112
|
+
export function leafOp(column: unknown, op: string, value: unknown): SqlOp {
|
|
113
|
+
if (op === "is null" || op === "is not null") {
|
|
114
|
+
return { queryChunks: [column, strChunk(` ${op}`)] };
|
|
115
|
+
}
|
|
116
|
+
if (op === "in") {
|
|
117
|
+
const values = (value as readonly unknown[]).map(paramChunk);
|
|
118
|
+
return { queryChunks: [column, strChunk(" in "), values] };
|
|
119
|
+
}
|
|
120
|
+
return { queryChunks: [column, strChunk(` ${op} `), paramChunk(value)] };
|
|
121
|
+
}
|
|
122
|
+
function groupOp(joiner: "and" | "or", parts: readonly SqlOp[]): SqlOp {
|
|
123
|
+
const chunks: unknown[] = [];
|
|
124
|
+
parts.forEach((part, i) => {
|
|
125
|
+
if (i > 0) chunks.push(strChunk(` ${joiner} `));
|
|
126
|
+
chunks.push(part);
|
|
127
|
+
});
|
|
128
|
+
return { queryChunks: chunks };
|
|
129
|
+
}
|
|
130
|
+
function ascOp(column: unknown): SqlOp {
|
|
131
|
+
return { queryChunks: [column, strChunk(" asc")] };
|
|
132
|
+
}
|
|
133
|
+
function descOp(column: unknown): SqlOp {
|
|
134
|
+
return { queryChunks: [column, strChunk(" desc")] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Recursive keyset predicate for "rows strictly after the cursor point". */
|
|
138
|
+
function keysetAfter(
|
|
139
|
+
columns: readonly unknown[],
|
|
140
|
+
values: readonly unknown[],
|
|
141
|
+
descending: boolean,
|
|
142
|
+
): SqlOp | undefined {
|
|
143
|
+
const cmp = descending ? "<" : ">";
|
|
144
|
+
const build = (i: number): SqlOp | undefined => {
|
|
145
|
+
if (i >= columns.length) return undefined;
|
|
146
|
+
const col = columns[i]!;
|
|
147
|
+
const rest = build(i + 1);
|
|
148
|
+
const tail =
|
|
149
|
+
rest === undefined ? undefined : groupOp("and", [leafOp(col, "=", values[i]), rest]);
|
|
150
|
+
return tail === undefined
|
|
151
|
+
? leafOp(col, cmp, values[i])
|
|
152
|
+
: groupOp("or", [leafOp(col, cmp, values[i]), tail]);
|
|
153
|
+
};
|
|
154
|
+
return build(0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* —————————————————————————————— cursor codec —————————————————————————— */
|
|
158
|
+
|
|
159
|
+
/** Keyset token direction — `?cursor=` carries either side. */
|
|
160
|
+
export type CursorDir = "after" | "before";
|
|
161
|
+
|
|
162
|
+
export function encodeOffsetCursor(offset: number): string {
|
|
163
|
+
return btoa(JSON.stringify({ k: "off", o: offset }));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function decodeOffsetCursor(raw: string): number | null {
|
|
167
|
+
try {
|
|
168
|
+
const value: unknown = JSON.parse(atob(raw));
|
|
169
|
+
if (
|
|
170
|
+
value !== null &&
|
|
171
|
+
typeof value === "object" &&
|
|
172
|
+
"k" in value &&
|
|
173
|
+
value.k === "off" &&
|
|
174
|
+
"o" in value &&
|
|
175
|
+
typeof value.o === "number" &&
|
|
176
|
+
Number.isInteger(value.o) &&
|
|
177
|
+
value.o >= 0
|
|
178
|
+
) {
|
|
179
|
+
return value.o;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function encodeCursor(values: readonly unknown[], dir: CursorDir): string {
|
|
188
|
+
return btoa(JSON.stringify({ v: values, d: dir }));
|
|
189
|
+
}
|
|
190
|
+
function decodeCursor(
|
|
191
|
+
raw: string,
|
|
192
|
+
arity: number,
|
|
193
|
+
): { readonly values: readonly unknown[]; readonly dir: CursorDir } | null {
|
|
194
|
+
try {
|
|
195
|
+
const value: unknown = JSON.parse(atob(raw));
|
|
196
|
+
if (Array.isArray(value) && value.length === arity) {
|
|
197
|
+
return { values: value, dir: "after" };
|
|
198
|
+
}
|
|
199
|
+
if (
|
|
200
|
+
value !== null &&
|
|
201
|
+
typeof value === "object" &&
|
|
202
|
+
"v" in value &&
|
|
203
|
+
"d" in value &&
|
|
204
|
+
Array.isArray(value.v) &&
|
|
205
|
+
value.v.length === arity &&
|
|
206
|
+
(value.d === "after" || value.d === "before")
|
|
207
|
+
) {
|
|
208
|
+
return { values: value.v as unknown[], dir: value.d };
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function flipOrder(op: SqlOp): SqlOp {
|
|
217
|
+
return {
|
|
218
|
+
queryChunks: op.queryChunks.map((chunk) => {
|
|
219
|
+
if (
|
|
220
|
+
chunk !== null &&
|
|
221
|
+
typeof chunk === "object" &&
|
|
222
|
+
"value" in chunk &&
|
|
223
|
+
Array.isArray((chunk as { value: unknown }).value)
|
|
224
|
+
) {
|
|
225
|
+
const text = (chunk as { value: string[] }).value[0];
|
|
226
|
+
if (text === " asc") return strChunk(" desc");
|
|
227
|
+
if (text === " desc") return strChunk(" asc");
|
|
228
|
+
}
|
|
229
|
+
return chunk;
|
|
230
|
+
}),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* —————————————————————————————— URL parsing ——————————————————————————— */
|
|
235
|
+
|
|
236
|
+
const RESERVED_PARAMS = new Set([
|
|
237
|
+
"cursor",
|
|
238
|
+
"limit",
|
|
239
|
+
"offset",
|
|
240
|
+
"search",
|
|
241
|
+
"q",
|
|
242
|
+
"order",
|
|
243
|
+
"select",
|
|
244
|
+
"or",
|
|
245
|
+
"and",
|
|
246
|
+
]);
|
|
247
|
+
|
|
248
|
+
const FILTER_OPS = new Set(["eq", "neq", "gt", "gte", "lt", "lte", "like", "ilike", "in", "is"]);
|
|
249
|
+
|
|
250
|
+
function badInput(message: string, path: string) {
|
|
251
|
+
return validationFailure([{ message, path: [path] }]);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function parseGroupLead(
|
|
255
|
+
t: string,
|
|
256
|
+
): { readonly negated: boolean; readonly joiner: "and" | "or"; readonly body: string } | undefined {
|
|
257
|
+
if (t.startsWith("not.and(")) return { negated: true, joiner: "and", body: t.slice(7) };
|
|
258
|
+
if (t.startsWith("not.or(")) return { negated: true, joiner: "or", body: t.slice(6) };
|
|
259
|
+
if (t.startsWith("and(")) return { negated: false, joiner: "and", body: t.slice(3) };
|
|
260
|
+
if (t.startsWith("or(")) return { negated: false, joiner: "or", body: t.slice(2) };
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function negateTermString(term: string): string {
|
|
265
|
+
const t = term.trim();
|
|
266
|
+
if (t.startsWith("not.and(")) return `and${t.slice(7)}`;
|
|
267
|
+
if (t.startsWith("not.or(")) return `or${t.slice(6)}`;
|
|
268
|
+
if (t.startsWith("and(")) return `not.and${t.slice(3)}`;
|
|
269
|
+
if (t.startsWith("or(")) return `not.or${t.slice(2)}`;
|
|
270
|
+
const dot = t.indexOf(".");
|
|
271
|
+
if (dot <= 0) return t;
|
|
272
|
+
return `${t.slice(0, dot)}.${invertOpString(t.slice(dot + 1))}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Invert a PostgREST op string (`not.eq.x` → `neq.x`) so SQL stays a leaf. */
|
|
276
|
+
function invertOpString(raw: string): string {
|
|
277
|
+
let rest = raw.trim();
|
|
278
|
+
if (rest.startsWith("not.")) return rest.slice(4);
|
|
279
|
+
const dot = rest.indexOf(".");
|
|
280
|
+
if (dot <= 0) return raw;
|
|
281
|
+
const op = rest.slice(0, dot);
|
|
282
|
+
const value = rest.slice(dot + 1);
|
|
283
|
+
if (op === "eq") return `neq.${value}`;
|
|
284
|
+
if (op === "neq") return `eq.${value}`;
|
|
285
|
+
if (op === "gt") return `lte.${value}`;
|
|
286
|
+
if (op === "gte") return `lt.${value}`;
|
|
287
|
+
if (op === "lt") return `gte.${value}`;
|
|
288
|
+
if (op === "lte") return `gt.${value}`;
|
|
289
|
+
if (op === "is" && (value === "null" || value === "unknown")) return "is.not_null";
|
|
290
|
+
if (op === "is" && value === "not_null") return "is.null";
|
|
291
|
+
if (op === "is" && value === "true") return "is.false";
|
|
292
|
+
if (op === "is" && value === "false") return "is.true";
|
|
293
|
+
return raw;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function splitTopLevel(inner: string): string[] {
|
|
297
|
+
const parts: string[] = [];
|
|
298
|
+
let buf = "";
|
|
299
|
+
let depth = 0;
|
|
300
|
+
let quote = false;
|
|
301
|
+
for (const ch of inner) {
|
|
302
|
+
if (quote) {
|
|
303
|
+
if (ch === '"') quote = false;
|
|
304
|
+
buf += ch;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (ch === '"') {
|
|
308
|
+
quote = true;
|
|
309
|
+
buf += ch;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (ch === "(") {
|
|
313
|
+
depth += 1;
|
|
314
|
+
buf += ch;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (ch === ")") {
|
|
318
|
+
depth -= 1;
|
|
319
|
+
buf += ch;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (ch === "," && depth === 0) {
|
|
323
|
+
parts.push(buf.trim());
|
|
324
|
+
buf = "";
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
buf += ch;
|
|
328
|
+
}
|
|
329
|
+
if (buf.trim().length > 0) parts.push(buf.trim());
|
|
330
|
+
return parts;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function unquote(raw: string): string {
|
|
334
|
+
const t = raw.trim();
|
|
335
|
+
if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) return t.slice(1, -1);
|
|
336
|
+
return t;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Parse `eq.x` / `not.eq.x` / `in.(a,"b,c")` / `is.null` into a drizzle op. */
|
|
340
|
+
function filterOp(
|
|
341
|
+
column: unknown,
|
|
342
|
+
key: string,
|
|
343
|
+
raw: string,
|
|
344
|
+
): SqlOp | { failure: ReturnType<typeof badInput> } {
|
|
345
|
+
let rest = raw.trim();
|
|
346
|
+
if (rest.startsWith("not.")) rest = invertOpString(rest);
|
|
347
|
+
const dot = rest.indexOf(".");
|
|
348
|
+
if (dot <= 0) return { failure: badInput(`expected "op.value" (e.g. ${key}=eq.x)`, key) };
|
|
349
|
+
const op = rest.slice(0, dot);
|
|
350
|
+
const value = rest.slice(dot + 1);
|
|
351
|
+
if (!FILTER_OPS.has(op)) {
|
|
352
|
+
return { failure: badInput(`unsupported filter op "${op}"`, key) };
|
|
353
|
+
}
|
|
354
|
+
let leaf: SqlOp | { failure: ReturnType<typeof badInput> };
|
|
355
|
+
switch (op) {
|
|
356
|
+
case "eq":
|
|
357
|
+
leaf = leafOp(column, "=", unquote(value));
|
|
358
|
+
break;
|
|
359
|
+
case "neq":
|
|
360
|
+
leaf = leafOp(column, "!=", unquote(value));
|
|
361
|
+
break;
|
|
362
|
+
case "gt":
|
|
363
|
+
leaf = leafOp(column, ">", unquote(value));
|
|
364
|
+
break;
|
|
365
|
+
case "gte":
|
|
366
|
+
leaf = leafOp(column, ">=", unquote(value));
|
|
367
|
+
break;
|
|
368
|
+
case "lt":
|
|
369
|
+
leaf = leafOp(column, "<", unquote(value));
|
|
370
|
+
break;
|
|
371
|
+
case "lte":
|
|
372
|
+
leaf = leafOp(column, "<=", unquote(value));
|
|
373
|
+
break;
|
|
374
|
+
case "like":
|
|
375
|
+
leaf = leafOp(column, "like", unquote(value).replaceAll("*", "%"));
|
|
376
|
+
break;
|
|
377
|
+
case "ilike":
|
|
378
|
+
leaf = leafOp(column, "ilike", unquote(value).replaceAll("*", "%"));
|
|
379
|
+
break;
|
|
380
|
+
case "is": {
|
|
381
|
+
if (value === "null" || value === "unknown") leaf = leafOp(column, "is null", undefined);
|
|
382
|
+
else if (value === "not_null") leaf = leafOp(column, "is not null", undefined);
|
|
383
|
+
else if (value === "true") leaf = leafOp(column, "=", true);
|
|
384
|
+
else if (value === "false") leaf = leafOp(column, "=", false);
|
|
385
|
+
else leaf = { failure: badInput(`is expects null|not_null|true|false|unknown`, key) };
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
case "in": {
|
|
389
|
+
const match = /^\((.*)\)$/.exec(value);
|
|
390
|
+
if (!match) {
|
|
391
|
+
leaf = { failure: badInput(`in expects (a,b,c)`, key) };
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
const inner = match[1]!.trim();
|
|
395
|
+
const values = inner.length === 0 ? [] : splitTopLevel(inner).map(unquote);
|
|
396
|
+
if (values.length === 0) {
|
|
397
|
+
leaf = { failure: badInput(`in expects at least one value`, key) };
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
leaf = leafOp(column, "in", values);
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
default:
|
|
404
|
+
leaf = { failure: badInput(`unsupported filter op "${op}"`, key) };
|
|
405
|
+
}
|
|
406
|
+
return leaf;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Split `or=(a.ilike.*x*,b.eq.1)` inner list on commas (nested-safe). */
|
|
410
|
+
function groupInner(raw: string): readonly string[] | null {
|
|
411
|
+
const match = /^\((.*)\)$/.exec(raw.trim());
|
|
412
|
+
if (!match) return null;
|
|
413
|
+
const inner = match[1]!.trim();
|
|
414
|
+
return inner.length === 0 ? [] : splitTopLevel(inner);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** One `col.op.value` or nested `not.and(…)` term inside `or=` / `and=`. */
|
|
418
|
+
function groupedFilterTerm(
|
|
419
|
+
tableColumns: Readonly<Record<string, unknown>>,
|
|
420
|
+
term: string,
|
|
421
|
+
joiner: "or" | "and",
|
|
422
|
+
): SqlOp | { failure: ReturnType<typeof badInput> } {
|
|
423
|
+
const t = term.trim();
|
|
424
|
+
const group = parseGroupLead(t);
|
|
425
|
+
if (group) {
|
|
426
|
+
const inner = groupInner(group.body);
|
|
427
|
+
if (inner === null || inner.length === 0) {
|
|
428
|
+
return { failure: badInput(`${group.joiner} expects (col.op.value,…)`, joiner) };
|
|
429
|
+
}
|
|
430
|
+
const innerJoiner = group.negated ? (group.joiner === "and" ? "or" : "and") : group.joiner;
|
|
431
|
+
const parts: SqlOp[] = [];
|
|
432
|
+
for (const piece of inner) {
|
|
433
|
+
const next = group.negated ? negateTermString(piece) : piece;
|
|
434
|
+
const op = groupedFilterTerm(tableColumns, next, innerJoiner);
|
|
435
|
+
if ("failure" in op) return op;
|
|
436
|
+
parts.push(op);
|
|
437
|
+
}
|
|
438
|
+
return parts.length === 1 ? parts[0]! : groupOp(innerJoiner, parts);
|
|
439
|
+
}
|
|
440
|
+
const dot = t.indexOf(".");
|
|
441
|
+
if (dot <= 0) {
|
|
442
|
+
return { failure: badInput(`expected "col.op.value" inside ${joiner}=(…)`, joiner) };
|
|
443
|
+
}
|
|
444
|
+
const col = tableColumns[t.slice(0, dot)];
|
|
445
|
+
if (col === undefined) {
|
|
446
|
+
return { failure: badInput(`unknown column "${t.slice(0, dot)}"`, joiner) };
|
|
447
|
+
}
|
|
448
|
+
return filterOp(col, joiner, t.slice(dot + 1));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/* ————————————————————————————— scope resolution ——————————————————————————— */
|
|
452
|
+
|
|
453
|
+
function scopeColumns(
|
|
454
|
+
scope: ColumnScope,
|
|
455
|
+
columns: readonly ResolvedColumn[],
|
|
456
|
+
tableColumns: Readonly<Record<string, unknown>>,
|
|
457
|
+
): readonly ResolvedColumn[] | "none" | "all" {
|
|
458
|
+
if (scope === "none") return "none";
|
|
459
|
+
if (scope === "all") return columns;
|
|
460
|
+
const allowed = new Set(
|
|
461
|
+
scope
|
|
462
|
+
.map((c) =>
|
|
463
|
+
columns.find(
|
|
464
|
+
(col) => tableColumns[col.key] === c || col.sqlName === (c as { name?: string }).name,
|
|
465
|
+
),
|
|
466
|
+
)
|
|
467
|
+
.filter((c): c is ResolvedColumn => c !== undefined),
|
|
468
|
+
);
|
|
469
|
+
return columns.filter((c) => allowed.has(c));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Resolve a declarative list surface against a table: defaults applied,
|
|
474
|
+
* column scopes narrowed, cursor columns bound. Pure — no driver, no IO.
|
|
475
|
+
*
|
|
476
|
+
* @param table - Drizzle / schema table
|
|
477
|
+
* @param list - Declarative list options
|
|
478
|
+
*/
|
|
479
|
+
export function resolveListScope(table: unknown, list?: ListOptions): ResolvedListScope {
|
|
480
|
+
const tableName = resolveTableName(table);
|
|
481
|
+
void tableName;
|
|
482
|
+
const columns = resolveColumns(table);
|
|
483
|
+
const tableColumns = table as Readonly<Record<string, unknown>>;
|
|
484
|
+
|
|
485
|
+
const opts = list ?? {};
|
|
486
|
+
const cursorCols = opts.cursor ?? [];
|
|
487
|
+
const resolvedCursor = cursorCols
|
|
488
|
+
.map((c) =>
|
|
489
|
+
columns.find(
|
|
490
|
+
(col) => tableColumns[col.key] === c || col.sqlName === (c as { name?: string }).name,
|
|
491
|
+
),
|
|
492
|
+
)
|
|
493
|
+
.filter((c): c is ResolvedColumn => c !== undefined);
|
|
494
|
+
const mode: ListPageMode = opts.mode ?? (resolvedCursor.length > 0 ? "cursor" : "offset");
|
|
495
|
+
const direction = opts.direction ?? "desc";
|
|
496
|
+
const limit = opts.limit ?? 20;
|
|
497
|
+
const maxLimit = opts.maxLimit ?? 100;
|
|
498
|
+
const countMode = opts.count ?? "exact";
|
|
499
|
+
|
|
500
|
+
const searchCols = scopeColumns(opts.search ?? "none", columns, tableColumns);
|
|
501
|
+
const filterCols = scopeColumns(opts.filter ?? "none", columns, tableColumns);
|
|
502
|
+
const orderScope =
|
|
503
|
+
opts.order ?? (resolvedCursor.length > 0 ? (cursorCols as readonly unknown[]) : "all");
|
|
504
|
+
const orderCols = scopeColumns(orderScope, columns, tableColumns);
|
|
505
|
+
const selectScope = opts.select ?? "all";
|
|
506
|
+
|
|
507
|
+
const listConfig: ResolvedListConfig = {
|
|
508
|
+
mode,
|
|
509
|
+
cursor: resolvedCursor,
|
|
510
|
+
direction,
|
|
511
|
+
limit,
|
|
512
|
+
maxLimit,
|
|
513
|
+
count: countMode,
|
|
514
|
+
search: searchCols === "none" || searchCols === "all" ? [] : searchCols,
|
|
515
|
+
filter: filterCols === "none" ? "none" : filterCols === "all" ? columns : filterCols,
|
|
516
|
+
order: orderCols === "none" ? "none" : orderCols === "all" ? columns : orderCols,
|
|
517
|
+
select:
|
|
518
|
+
selectScope === "all"
|
|
519
|
+
? "all"
|
|
520
|
+
: (scopeColumns(selectScope, columns, tableColumns) as readonly ResolvedColumn[]),
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const query: ListQueryScope = {
|
|
524
|
+
mode,
|
|
525
|
+
direction,
|
|
526
|
+
limit,
|
|
527
|
+
maxLimit,
|
|
528
|
+
columns,
|
|
529
|
+
tableColumns,
|
|
530
|
+
searchColumns: listConfig.search,
|
|
531
|
+
filterColumns: listConfig.filter,
|
|
532
|
+
orderColumns: listConfig.order,
|
|
533
|
+
selectColumns: listConfig.select,
|
|
534
|
+
cursorColumns: resolvedCursor,
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
mode,
|
|
539
|
+
direction,
|
|
540
|
+
limit,
|
|
541
|
+
maxLimit,
|
|
542
|
+
count: countMode,
|
|
543
|
+
cursorColumns: resolvedCursor,
|
|
544
|
+
listConfig,
|
|
545
|
+
query,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* ————————————————————————————— the parser ——————————————————————————— */
|
|
550
|
+
|
|
551
|
+
/** Parsed list input — page options plus shaping metadata. */
|
|
552
|
+
export type ListQueryResult =
|
|
553
|
+
| {
|
|
554
|
+
ok: true;
|
|
555
|
+
page: SqlPageOptions;
|
|
556
|
+
meta: Record<string, unknown>;
|
|
557
|
+
select?: readonly ResolvedColumn[];
|
|
558
|
+
cursorDir?: CursorDir;
|
|
559
|
+
}
|
|
560
|
+
| { ok: false; failure: unknown };
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Parse validated list input (the PostgREST grammar) into page options —
|
|
564
|
+
* or a validation failure. Table-agnostic: the same function serves
|
|
565
|
+
* `store.resource` lists, live query windows, and hand-written flows.
|
|
566
|
+
*
|
|
567
|
+
* @param input - Query record (already flattened URL params)
|
|
568
|
+
* @param scope - Resolved surface from {@link resolveListScope}
|
|
569
|
+
*/
|
|
570
|
+
export function parseListQuery(input: unknown, scope: ListQueryScope): ListQueryResult {
|
|
571
|
+
const {
|
|
572
|
+
direction,
|
|
573
|
+
limit,
|
|
574
|
+
maxLimit,
|
|
575
|
+
columns,
|
|
576
|
+
tableColumns,
|
|
577
|
+
searchColumns: searchList,
|
|
578
|
+
filterColumns,
|
|
579
|
+
orderColumns,
|
|
580
|
+
selectColumns,
|
|
581
|
+
cursorColumns: resolvedCursor,
|
|
582
|
+
mode,
|
|
583
|
+
} = scope;
|
|
584
|
+
const filterAllowed = new Set(
|
|
585
|
+
filterColumns === "none" ? [] : (filterColumns as readonly ResolvedColumn[]).map((c) => c.key),
|
|
586
|
+
);
|
|
587
|
+
const orderAllowed = new Set(
|
|
588
|
+
orderColumns === "none" ? [] : (orderColumns as readonly ResolvedColumn[]).map((c) => c.key),
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
const query = (input ?? {}) as Record<string, unknown>;
|
|
592
|
+
const str = (k: string): string | undefined => {
|
|
593
|
+
const v = query[k];
|
|
594
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
595
|
+
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
|
596
|
+
return undefined;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// limit
|
|
600
|
+
let pageLimit = limit;
|
|
601
|
+
const rawLimit = str("limit");
|
|
602
|
+
if (rawLimit !== undefined) {
|
|
603
|
+
const n = Number(rawLimit);
|
|
604
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
605
|
+
return { ok: false, failure: badInput("limit must be a positive integer", "limit") };
|
|
606
|
+
}
|
|
607
|
+
pageLimit = Math.min(n, maxLimit);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const wheres: unknown[] = [];
|
|
611
|
+
let meta: Record<string, unknown> = {};
|
|
612
|
+
|
|
613
|
+
// search
|
|
614
|
+
const search = str("search");
|
|
615
|
+
const q = str("q");
|
|
616
|
+
if (search !== undefined && q !== undefined) {
|
|
617
|
+
return { ok: false, failure: badInput("use either search or q, not both", "search") };
|
|
618
|
+
}
|
|
619
|
+
const searchTerm = search ?? q;
|
|
620
|
+
if (searchTerm !== undefined) {
|
|
621
|
+
if (searchList.length === 0) {
|
|
622
|
+
return {
|
|
623
|
+
ok: false,
|
|
624
|
+
failure: badInput("search is not enabled for this resource", "search"),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
const pattern = `%${searchTerm}%`;
|
|
628
|
+
const parts = searchList
|
|
629
|
+
.map((c) => tableColumns[c.key])
|
|
630
|
+
.filter((c): c is unknown => c !== undefined)
|
|
631
|
+
.map((c) => leafOp(c, "like", pattern) as SqlOp);
|
|
632
|
+
wheres.push(parts.length === 1 ? parts[0] : groupOp("or", parts));
|
|
633
|
+
meta.search = searchTerm;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// column filters
|
|
637
|
+
if (filterColumns === "none") {
|
|
638
|
+
for (const key of Object.keys(query)) {
|
|
639
|
+
if (!RESERVED_PARAMS.has(key) && key !== "headers" && key !== "cookie") {
|
|
640
|
+
return { ok: false, failure: badInput(`unknown list param "${key}"`, key) };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
} else {
|
|
644
|
+
for (const [key, value] of Object.entries(query)) {
|
|
645
|
+
if (RESERVED_PARAMS.has(key) || key === "headers" || key === "cookie") continue;
|
|
646
|
+
if (typeof value !== "string") continue;
|
|
647
|
+
const col = tableColumns[key];
|
|
648
|
+
const resolved = columns.find((c) => c.key === key);
|
|
649
|
+
if (col === undefined || resolved === undefined || !filterAllowed.has(key)) {
|
|
650
|
+
return { ok: false, failure: badInput(`unknown or unfilterable column "${key}"`, key) };
|
|
651
|
+
}
|
|
652
|
+
const op = filterOp(col, key, value);
|
|
653
|
+
if ("failure" in op) return { ok: false, failure: op.failure };
|
|
654
|
+
wheres.push(op);
|
|
655
|
+
}
|
|
656
|
+
// or= / and= groups (one level)
|
|
657
|
+
for (const joiner of ["or", "and"] as const) {
|
|
658
|
+
const rawGroup = str(joiner);
|
|
659
|
+
if (rawGroup === undefined) continue;
|
|
660
|
+
const inner = groupInner(rawGroup);
|
|
661
|
+
if (inner === null || inner.length === 0) {
|
|
662
|
+
return { ok: false, failure: badInput(`${joiner} expects (col.op.value,…)`, joiner) };
|
|
663
|
+
}
|
|
664
|
+
const parts: SqlOp[] = [];
|
|
665
|
+
for (const term of inner) {
|
|
666
|
+
const op = groupedFilterTerm(tableColumns, term, joiner);
|
|
667
|
+
if ("failure" in op) return { ok: false, failure: op.failure };
|
|
668
|
+
parts.push(op);
|
|
669
|
+
}
|
|
670
|
+
wheres.push(parts.length === 1 ? parts[0] : groupOp(joiner, parts));
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// order
|
|
675
|
+
let orders: SqlOp[] | undefined;
|
|
676
|
+
const rawOrder = str("order");
|
|
677
|
+
if (rawOrder !== undefined) {
|
|
678
|
+
if (orderColumns === "none") {
|
|
679
|
+
return { ok: false, failure: badInput("order is not enabled for this resource", "order") };
|
|
680
|
+
}
|
|
681
|
+
orders = [];
|
|
682
|
+
for (const term of rawOrder.split(",")) {
|
|
683
|
+
const [key, dir] = term.split(".");
|
|
684
|
+
if (!key || (dir !== undefined && dir !== "asc" && dir !== "desc")) {
|
|
685
|
+
return { ok: false, failure: badInput(`bad order term "${term}"`, "order") };
|
|
686
|
+
}
|
|
687
|
+
const col = tableColumns[key];
|
|
688
|
+
const resolved = columns.find((c) => c.key === key);
|
|
689
|
+
if (col === undefined || resolved === undefined || !orderAllowed.has(key)) {
|
|
690
|
+
return {
|
|
691
|
+
ok: false,
|
|
692
|
+
failure: badInput(`unknown or unorderable column "${key}"`, "order"),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
orders.push(dir === "asc" ? ascOp(col) : descOp(col));
|
|
696
|
+
}
|
|
697
|
+
meta.order = rawOrder;
|
|
698
|
+
} else if (resolvedCursor.length > 0) {
|
|
699
|
+
orders = resolvedCursor.map((c) => {
|
|
700
|
+
const col = tableColumns[c.key];
|
|
701
|
+
return direction === "asc" ? ascOp(col) : descOp(col);
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// select projection
|
|
706
|
+
let select: ResolvedColumn[] | undefined;
|
|
707
|
+
const rawSelect = str("select");
|
|
708
|
+
if (rawSelect !== undefined) {
|
|
709
|
+
if (selectColumns !== "all") {
|
|
710
|
+
return {
|
|
711
|
+
ok: false,
|
|
712
|
+
failure: badInput("select is not enabled for this resource", "select"),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
select = [];
|
|
716
|
+
for (const key of rawSelect.split(",")) {
|
|
717
|
+
const resolved = columns.find((c) => c.key === key);
|
|
718
|
+
if (resolved === undefined) {
|
|
719
|
+
return { ok: false, failure: badInput(`unknown column "${key}"`, "select") };
|
|
720
|
+
}
|
|
721
|
+
select.push(resolved);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// pagination
|
|
726
|
+
let offset: number | undefined;
|
|
727
|
+
let after: unknown;
|
|
728
|
+
let before: unknown;
|
|
729
|
+
let cursorDir: CursorDir | undefined;
|
|
730
|
+
let orderBy = orders;
|
|
731
|
+
if (mode === "offset") {
|
|
732
|
+
const rawCursor = str("cursor");
|
|
733
|
+
const rawOffset = str("offset");
|
|
734
|
+
if (rawCursor !== undefined) {
|
|
735
|
+
const fromCursor = decodeOffsetCursor(rawCursor);
|
|
736
|
+
if (fromCursor === null) {
|
|
737
|
+
return { ok: false, failure: badInput("invalid cursor", "cursor") };
|
|
738
|
+
}
|
|
739
|
+
offset = fromCursor;
|
|
740
|
+
} else if (rawOffset !== undefined) {
|
|
741
|
+
const n = Number(rawOffset);
|
|
742
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
743
|
+
return {
|
|
744
|
+
ok: false,
|
|
745
|
+
failure: badInput("offset must be a non-negative integer", "offset"),
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
offset = n;
|
|
749
|
+
}
|
|
750
|
+
} else {
|
|
751
|
+
const rawCursor = str("cursor");
|
|
752
|
+
if (rawCursor !== undefined) {
|
|
753
|
+
if (resolvedCursor.length === 0) {
|
|
754
|
+
return { ok: false, failure: badInput("cursor pagination is not configured", "cursor") };
|
|
755
|
+
}
|
|
756
|
+
const decoded = decodeCursor(rawCursor, resolvedCursor.length);
|
|
757
|
+
if (decoded === null) {
|
|
758
|
+
return { ok: false, failure: badInput("invalid cursor", "cursor") };
|
|
759
|
+
}
|
|
760
|
+
cursorDir = decoded.dir;
|
|
761
|
+
const cols = resolvedCursor.map((c) => tableColumns[c.key]);
|
|
762
|
+
if (decoded.dir === "before") {
|
|
763
|
+
before = keysetAfter(cols, decoded.values, direction !== "desc");
|
|
764
|
+
orderBy = (orders ?? []).map(flipOrder);
|
|
765
|
+
} else {
|
|
766
|
+
after = keysetAfter(cols, decoded.values, direction === "desc");
|
|
767
|
+
}
|
|
768
|
+
meta.cursor = rawCursor;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const where =
|
|
773
|
+
wheres.length === 0
|
|
774
|
+
? undefined
|
|
775
|
+
: wheres.length === 1
|
|
776
|
+
? wheres[0]
|
|
777
|
+
: groupOp("and", wheres as SqlOp[]);
|
|
778
|
+
return {
|
|
779
|
+
ok: true,
|
|
780
|
+
page: {
|
|
781
|
+
where,
|
|
782
|
+
orderBy,
|
|
783
|
+
limit: pageLimit + (mode === "cursor" ? 1 : 0),
|
|
784
|
+
offset,
|
|
785
|
+
after,
|
|
786
|
+
before,
|
|
787
|
+
},
|
|
788
|
+
meta: { mode, limit: pageLimit, ...meta },
|
|
789
|
+
...(select !== undefined ? { select } : {}),
|
|
790
|
+
...(cursorDir !== undefined ? { cursorDir } : {}),
|
|
791
|
+
};
|
|
792
|
+
}
|