okengine 0.2.8 → 0.3.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 +17 -15
- package/README.md +59 -31
- package/package.json +11 -16
- package/site/content/docs/ai/llms-txt.mdx +54 -0
- package/site/content/docs/ai/mcp.mdx +123 -0
- package/site/content/docs/ai/meta.json +5 -0
- package/site/content/docs/ai/skills.mdx +53 -0
- package/site/content/docs/console/access.mdx +29 -0
- package/site/content/docs/console/ai.mdx +35 -0
- package/site/content/docs/console/architecture.mdx +35 -0
- package/site/content/docs/console/channels.mdx +37 -0
- package/site/content/docs/console/clock.mdx +31 -0
- package/site/content/docs/console/flows.mdx +31 -0
- package/site/content/docs/console/gates.mdx +35 -0
- package/site/content/docs/console/manifest-diff.mdx +34 -0
- package/site/content/docs/console/meta.json +23 -0
- package/site/content/docs/console/overview.mdx +40 -0
- package/site/content/docs/console/plugins.mdx +41 -0
- package/site/content/docs/console/privacy.mdx +32 -0
- package/site/content/docs/console/runs.mdx +40 -0
- package/site/content/docs/console/signals.mdx +31 -0
- package/site/content/docs/console/store.mdx +32 -0
- package/site/content/docs/console/tenancy.mdx +32 -0
- package/site/content/docs/console/traces.mdx +34 -0
- package/site/content/docs/console/vault.mdx +37 -0
- package/site/content/docs/elements/ai.mdx +180 -0
- package/site/content/docs/elements/channel.mdx +167 -0
- package/site/content/docs/elements/clock.mdx +182 -0
- package/site/content/docs/elements/flow.mdx +288 -0
- package/site/content/docs/elements/gate.mdx +171 -0
- package/site/content/docs/elements/meta.json +5 -0
- package/site/content/docs/elements/signal.mdx +171 -0
- package/site/content/docs/elements/store.mdx +320 -0
- package/site/content/docs/elements/vault.mdx +263 -0
- package/site/content/docs/get-started/basic-usage.mdx +124 -0
- package/site/content/docs/get-started/comparison.mdx +65 -0
- package/site/content/docs/get-started/installation.mdx +113 -0
- package/site/content/docs/get-started/introduction.mdx +123 -0
- package/site/content/docs/get-started/meta.json +5 -0
- package/site/content/docs/index.mdx +63 -0
- package/site/content/docs/meta.json +5 -0
- package/site/content/docs/plugins/compression.mdx +60 -0
- package/site/content/docs/plugins/cors.mdx +98 -0
- package/site/content/docs/plugins/csrf.mdx +96 -0
- package/site/content/docs/plugins/ip-allowlist.mdx +93 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
- package/site/content/docs/plugins/meta.json +15 -0
- package/site/content/docs/plugins/security-headers.mdx +136 -0
- package/site/content/docs/reference/cli.md +101 -0
- package/site/content/docs/reference/configuration.mdx +159 -0
- package/site/content/docs/reference/environment-variables.mdx +87 -0
- package/site/content/docs/reference/errors.mdx +80 -0
- package/site/content/docs/reference/fx.mdx +117 -0
- package/site/content/docs/reference/meta.json +5 -0
- package/site/content/docs/reference/plugins.mdx +249 -0
- package/site/content/docs/reference/security.md +63 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/cli/ask-dev-mode.ts +1 -1
- package/src/cli/db.ts +87 -17
- package/src/cli/dev-db-push.test.ts +32 -2
- package/src/cli/dev-schema-sync.test.ts +66 -0
- package/src/cli/dev-schema-sync.ts +139 -0
- package/src/cli/dev.test.ts +123 -1
- package/src/cli/dev.ts +150 -8
- package/src/cli/doc-staleness.test.ts +4 -4
- package/src/cli/docker-cli.test.ts +20 -0
- package/src/cli/docker.ts +10 -0
- package/src/cli/drizzle-env.test.ts +67 -0
- package/src/cli/drizzle-env.ts +78 -0
- package/src/cli/ensure-drizzle-config.ts +50 -0
- package/src/cli/hero-meta.test.ts +1 -1
- package/src/cli/load-config.ts +6 -0
- package/src/cli/mode.ts +24 -4
- package/src/cli/openbao-bootstrap.test.ts +149 -0
- package/src/cli/openbao-bootstrap.ts +314 -0
- package/src/cli/openbao-restart.integration.test.ts +136 -0
- package/src/cli/ports.test.ts +7 -5
- package/src/cli/ports.ts +6 -2
- package/src/cli/resolve-dev-sql-env.test.ts +48 -0
- package/src/cli/resolve-dev-sql-env.ts +42 -0
- package/src/cli/stack.ts +7 -4
- package/src/cli/vault-cmd.ts +63 -0
- package/src/client/types.ts +7 -1
- package/src/compiler/extract.test.ts +40 -0
- package/src/compiler/extract.ts +123 -1
- package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
- package/src/compiler/fixtures/skyport.expected.json +1 -1
- package/src/compiler/response.ts +12 -0
- package/src/config/define-config.test.ts +4 -6
- package/src/config/index.ts +2 -15
- package/src/console/server/app.ts +2 -0
- package/src/console/server/vault.ts +21 -6
- package/src/docker/compose.ts +112 -16
- package/src/docker/derive.ts +7 -1
- package/src/docker/docker.test.ts +103 -0
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -2
- package/src/docker/recipes/openbao.ts +47 -0
- package/src/docker/recipes/redis.ts +5 -1
- package/src/docker/recipes/rustfs.ts +2 -3
- package/src/docker/stack-id.test.ts +43 -8
- package/src/docker/stack-id.ts +99 -20
- package/src/docker/stack.ts +36 -4
- package/src/docker/types.ts +3 -0
- package/src/docs-origin.ts +4 -4
- package/src/drivers/drizzle-dialect.test.ts +20 -0
- package/src/drivers/drizzle-dialect.ts +37 -0
- package/src/drivers/index.ts +1 -2
- package/src/drivers/memory.ts +278 -39
- package/src/drivers/s3.ts +10 -1
- package/src/drivers/vault-driver-removal.test.ts +55 -0
- package/src/drivers/vault-openbao.test.ts +97 -0
- package/src/drivers/vault-openbao.ts +102 -35
- package/src/drivers/vault-types.ts +3 -10
- package/src/elements/store/declare.ts +4 -1
- package/src/elements/store/resource-list-docs.fixture.ts +56 -0
- package/src/elements/store/resource-list-docs.test.ts +79 -0
- package/src/elements/store/resource.test.ts +253 -0
- package/src/elements/store/resource.ts +786 -0
- package/src/elements/store/sql-condition.test.ts +132 -0
- package/src/elements/store/sql-condition.ts +284 -46
- package/src/elements/store/sql-session.test.ts +86 -1
- package/src/elements/store/sql-session.ts +187 -27
- package/src/elements/store/table.ts +34 -4
- package/src/elements/store.ts +16 -0
- package/src/elements/vault/runtime.ts +1 -1
- package/src/elements/vault.test.ts +1 -28
- package/src/elements/vault.ts +1 -1
- package/src/kernel/app.ts +59 -25
- package/src/kernel/boot-bind/channel.test.ts +60 -0
- package/src/kernel/boot-bind/channel.ts +64 -2
- package/src/kernel/boot-bind/store.test.ts +10 -1
- package/src/kernel/boot-bind/store.ts +49 -2
- package/src/kernel/boot.test.ts +0 -1
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/edge.test.ts +68 -0
- package/src/kernel/errors.registry.test.ts +1 -1
- package/src/kernel/flow.ts +8 -0
- package/src/kernel/fx.test.ts +23 -3
- package/src/kernel/fx.ts +115 -18
- package/src/kernel/hooks.test.ts +33 -0
- package/src/kernel/hooks.ts +22 -0
- package/src/kernel/index.ts +7 -0
- package/src/kernel/on.ts +44 -3
- package/src/kernel/plugin.ts +33 -3
- package/src/kernel/registry-isolation.test.ts +74 -0
- package/src/kernel/registry.ts +22 -1
- package/src/kernel/triggers.ts +59 -0
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +1 -1
- package/src/manifest/index.ts +1 -1
- package/src/manifest/types.ts +1 -1
- package/src/manifest/validate.ts +2 -2
- package/src/plugins/compression.test.ts +127 -0
- package/src/plugins/compression.ts +94 -0
- package/src/plugins/config-source.test.ts +204 -0
- package/src/plugins/config-source.ts +209 -0
- package/src/plugins/cors.test.ts +172 -0
- package/src/plugins/cors.ts +153 -0
- package/src/plugins/csrf.test.ts +102 -0
- package/src/plugins/csrf.ts +86 -0
- package/src/plugins/headers.ts +54 -0
- package/src/plugins/index.ts +26 -0
- package/src/plugins/ip-allowlist.test.ts +139 -0
- package/src/plugins/ip-allowlist.ts +129 -0
- package/src/plugins/maintenance-mode.test.ts +91 -0
- package/src/plugins/maintenance-mode.ts +85 -0
- package/src/plugins/security-headers.test.ts +243 -0
- package/src/plugins/security-headers.ts +255 -0
- package/src/release/measure.ts +1 -2
- package/src/test/create-test-app.ts +14 -2
- package/docs/spec/console.md +0 -762
- package/docs/spec/example.md +0 -1374
- package/docs/spec/four-applications.md +0 -1376
- package/docs/spec/unified-theory.md +0 -498
- package/src/cli/doc-drift.test.ts +0 -147
- package/src/cli/doc-drift.ts +0 -401
- package/src/cli/doctor-diff-examples.ts +0 -90
- package/src/drivers/vault-sops.ts +0 -246
- /package/{spec/manifest.v1.schema.json → manifest.v1.schema.json} +0 -0
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `store.resource(db, table, opts)` — a CRUD + list resource factory.
|
|
3
|
+
*
|
|
4
|
+
* Declarative sugar, never new physics: every op is an ordinary `flow({…})`
|
|
5
|
+
* whose body composes `fx.store(db)` (select/insert/update/findById/delete +
|
|
6
|
+
* `page`/`count`) through the existing Drizzle-condition compiler. Wire
|
|
7
|
+
* binding stays in `on(http.resource(path, resource.all()))` — the factory
|
|
8
|
+
* itself registers no triggers.
|
|
9
|
+
*
|
|
10
|
+
* List URL (PostgREST-shaped, UTF-8 values — English, Arabic, …):
|
|
11
|
+
* `?cursor=` / `?offset=` / `?limit=` · `?search=` (`?q=` alias) ·
|
|
12
|
+
* `?col=eq.x|neq|gt|gte|lt|lte|like.*p*|ilike.*p*|in.(a,b)|is.true|false|null` ·
|
|
13
|
+
* `?or=(…)` / `?and=(…)` · `?order=col.desc,…` · `?select=id,title`.
|
|
14
|
+
* Every surface is whitelisted by a ColumnScope (`"all" | Column[] | "none"`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { flow, type FlowDef, type FlowErrorMap } from "../../kernel/flow.ts";
|
|
18
|
+
import { fail } from "../../kernel/errors.ts";
|
|
19
|
+
import type { Fx } from "../../kernel/fx.ts";
|
|
20
|
+
import { validationFailure } from "../../validation/standard-schema.ts";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import type { SqlStoreDecl } from "./declare.ts";
|
|
23
|
+
import type { SqlRow } from "../../drivers/types.ts";
|
|
24
|
+
import { resolveColumns, resolveTableName, type ResolvedColumn } from "./table.ts";
|
|
25
|
+
import type { SqlPageOptions } from "./sql-session.ts";
|
|
26
|
+
|
|
27
|
+
/** Whitelist for one list concern (`search` / `filter` / `order` / `select`). */
|
|
28
|
+
export type ColumnScope = "all" | readonly unknown[] | "none";
|
|
29
|
+
|
|
30
|
+
/** Pagination mode for a resource list. */
|
|
31
|
+
export type ListPageMode = "cursor" | "offset";
|
|
32
|
+
|
|
33
|
+
/** Offset-mode `COUNT(*)` policy. */
|
|
34
|
+
export type ListCountMode = "exact" | "none";
|
|
35
|
+
|
|
36
|
+
/** Options for {@link resource}. */
|
|
37
|
+
export interface ResourceOptions {
|
|
38
|
+
/** Create body schema (Standard Schema). */
|
|
39
|
+
readonly in: unknown;
|
|
40
|
+
/** Item schema (Standard Schema) — also the list item type. */
|
|
41
|
+
readonly out: unknown;
|
|
42
|
+
/** Update body schema; defaults to the partial of `in` when omitted. */
|
|
43
|
+
readonly update?: unknown;
|
|
44
|
+
/**
|
|
45
|
+
* Update `:id` schema; defaults to `z.object({ id: z.string() })`. Only
|
|
46
|
+
* used to extend `update` with the path id (wire sends `{ id, ...patch }`).
|
|
47
|
+
*/
|
|
48
|
+
readonly idSchema?: unknown;
|
|
49
|
+
/** Typed errors for get / update / remove (default `{ NotFound }`). */
|
|
50
|
+
readonly errors?: FlowErrorMap;
|
|
51
|
+
/** `:id` column; defaults to the table primary key. */
|
|
52
|
+
readonly id?: unknown;
|
|
53
|
+
/** List surface. */
|
|
54
|
+
readonly list?: ResourceListOptions;
|
|
55
|
+
/** Flow unit scope (default: table name). Client namespace still comes from `adopt`. */
|
|
56
|
+
readonly unit?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Acknowledge intentional Manifest contract breaks for the five flows
|
|
59
|
+
* (`breaking: true` on each). Use when migrating a handwritten CRUD unit
|
|
60
|
+
* onto `store.resource`.
|
|
61
|
+
*/
|
|
62
|
+
readonly breaking?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** List options on {@link ResourceOptions}. */
|
|
66
|
+
export interface ResourceListOptions {
|
|
67
|
+
/** Pagination; default `"cursor"` when `cursor` columns are set. */
|
|
68
|
+
readonly mode?: ListPageMode;
|
|
69
|
+
/** Keyset columns (cursor mode). */
|
|
70
|
+
readonly cursor?: readonly unknown[];
|
|
71
|
+
/** Default sort when no `?order=` (default `"desc"`). */
|
|
72
|
+
readonly direction?: "asc" | "desc";
|
|
73
|
+
/** Default page size (default 20). */
|
|
74
|
+
readonly limit?: number;
|
|
75
|
+
/** Hard cap on `?limit=` (default 100). */
|
|
76
|
+
readonly maxLimit?: number;
|
|
77
|
+
/** Offset-only `COUNT(*)` (default `"exact"`). */
|
|
78
|
+
readonly count?: ListCountMode;
|
|
79
|
+
/** Substring search columns (`?search=` / `?q=`). Default `"none"`. */
|
|
80
|
+
readonly search?: ColumnScope;
|
|
81
|
+
/** Filter grammar columns (`?col=op.value`). Default `"none"`. */
|
|
82
|
+
readonly filter?: ColumnScope;
|
|
83
|
+
/** `?order=` columns. Default: cursor columns, else `"all"`. */
|
|
84
|
+
readonly order?: ColumnScope;
|
|
85
|
+
/** `?select=` projection columns (runtime only). Default `"all"`. */
|
|
86
|
+
readonly select?: ColumnScope;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One CRUD op bundle returned by {@link resource}. */
|
|
90
|
+
export interface ResourceFlowDefs {
|
|
91
|
+
readonly list: FlowDef<any, any, any>;
|
|
92
|
+
readonly create: FlowDef<any, any, any>;
|
|
93
|
+
readonly get: FlowDef<any, any, any>;
|
|
94
|
+
readonly update: FlowDef<any, any, any>;
|
|
95
|
+
readonly remove: FlowDef<any, any, any>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Column lookup for {@link ResourceDef.page}. */
|
|
99
|
+
export interface ResourceColumns {
|
|
100
|
+
/** Column property on the table by URL key (`createdAt` → drizzle col). */
|
|
101
|
+
readonly columns: Readonly<Record<string, unknown>>;
|
|
102
|
+
/** SQL name per URL key (for order compilation). */
|
|
103
|
+
readonly sqlNameOf: (key: string) => string | undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A resource factory result — FlowDefs plus introspection for `page`. */
|
|
107
|
+
export interface ResourceDef extends ResourceColumns {
|
|
108
|
+
readonly list: FlowDef<any, any, any>;
|
|
109
|
+
readonly create: FlowDef<any, any, any>;
|
|
110
|
+
readonly get: FlowDef<any, any, any>;
|
|
111
|
+
readonly update: FlowDef<any, any, any>;
|
|
112
|
+
readonly remove: FlowDef<any, any, any>;
|
|
113
|
+
/** Table name (SQL identifier). */
|
|
114
|
+
readonly table: string;
|
|
115
|
+
/** `:id` URL key. */
|
|
116
|
+
readonly idKey: string;
|
|
117
|
+
/** Default page size. */
|
|
118
|
+
readonly limit: number;
|
|
119
|
+
/** `?limit=` cap. */
|
|
120
|
+
readonly maxLimit: number;
|
|
121
|
+
/** Resolved list config (defaults applied). */
|
|
122
|
+
readonly listConfig: ResolvedListConfig;
|
|
123
|
+
/** All five ops for `http.resource(path, resource.all())`. */
|
|
124
|
+
all(): ResourceFlowDefs;
|
|
125
|
+
/** Input for {@link SqlStoreHandle.page} from validated list input. */
|
|
126
|
+
page(input: unknown): SqlPageOptions & { readonly meta: Record<string, unknown> };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** List config after defaults are applied. */
|
|
130
|
+
export interface ResolvedListConfig {
|
|
131
|
+
readonly mode: ListPageMode;
|
|
132
|
+
readonly cursor: readonly ResolvedColumn[];
|
|
133
|
+
readonly direction: "asc" | "desc";
|
|
134
|
+
readonly limit: number;
|
|
135
|
+
readonly maxLimit: number;
|
|
136
|
+
readonly count: ListCountMode;
|
|
137
|
+
readonly search: readonly ResolvedColumn[];
|
|
138
|
+
readonly filter: readonly ResolvedColumn[] | "none";
|
|
139
|
+
readonly order: readonly ResolvedColumn[] | "none";
|
|
140
|
+
readonly select: readonly ResolvedColumn[] | "all";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ———————————————————————————— drizzle-op builders (no drizzle import) ——— */
|
|
144
|
+
|
|
145
|
+
/** Build a Drizzle-shaped `SQL` leaf/group without importing `drizzle-orm`. */
|
|
146
|
+
interface SqlOp {
|
|
147
|
+
readonly queryChunks: readonly unknown[];
|
|
148
|
+
}
|
|
149
|
+
function strChunk(text: string): unknown {
|
|
150
|
+
return { constructor: { name: "StringChunk" }, value: [text] };
|
|
151
|
+
}
|
|
152
|
+
function paramChunk(value: unknown): unknown {
|
|
153
|
+
return { value };
|
|
154
|
+
}
|
|
155
|
+
function leafOp(column: unknown, op: string, value: unknown): SqlOp {
|
|
156
|
+
if (op === "is null" || op === "is not null") {
|
|
157
|
+
return { queryChunks: [column, strChunk(` ${op}`)] };
|
|
158
|
+
}
|
|
159
|
+
if (op === "in") {
|
|
160
|
+
const values = (value as readonly unknown[]).map(paramChunk);
|
|
161
|
+
return { queryChunks: [column, strChunk(" in "), values] };
|
|
162
|
+
}
|
|
163
|
+
return { queryChunks: [column, strChunk(` ${op} `), paramChunk(value)] };
|
|
164
|
+
}
|
|
165
|
+
function groupOp(joiner: "and" | "or", parts: readonly SqlOp[]): SqlOp {
|
|
166
|
+
const chunks: unknown[] = [];
|
|
167
|
+
parts.forEach((part, i) => {
|
|
168
|
+
if (i > 0) chunks.push(strChunk(` ${joiner} `));
|
|
169
|
+
chunks.push(part);
|
|
170
|
+
});
|
|
171
|
+
return { queryChunks: chunks };
|
|
172
|
+
}
|
|
173
|
+
function ascOp(column: unknown): SqlOp {
|
|
174
|
+
return { queryChunks: [column, strChunk(" asc")] };
|
|
175
|
+
}
|
|
176
|
+
function descOp(column: unknown): SqlOp {
|
|
177
|
+
return { queryChunks: [column, strChunk(" desc")] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Recursive keyset predicate for "rows strictly after the cursor point". */
|
|
181
|
+
function keysetAfter(
|
|
182
|
+
columns: readonly unknown[],
|
|
183
|
+
values: readonly unknown[],
|
|
184
|
+
descending: boolean,
|
|
185
|
+
): SqlOp | undefined {
|
|
186
|
+
const cmp = descending ? "<" : ">";
|
|
187
|
+
const build = (i: number): SqlOp | undefined => {
|
|
188
|
+
if (i >= columns.length) return undefined;
|
|
189
|
+
const col = columns[i]!;
|
|
190
|
+
const rest = build(i + 1);
|
|
191
|
+
const tail =
|
|
192
|
+
rest === undefined ? undefined : groupOp("and", [leafOp(col, "=", values[i]), rest]);
|
|
193
|
+
return tail === undefined
|
|
194
|
+
? leafOp(col, cmp, values[i])
|
|
195
|
+
: groupOp("or", [leafOp(col, cmp, values[i]), tail]);
|
|
196
|
+
};
|
|
197
|
+
return build(0);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* —————————————————————————————— cursor codec —————————————————————————— */
|
|
201
|
+
|
|
202
|
+
function encodeCursor(values: readonly unknown[]): string {
|
|
203
|
+
return btoa(JSON.stringify(values));
|
|
204
|
+
}
|
|
205
|
+
function decodeCursor(raw: string, arity: number): readonly unknown[] | null {
|
|
206
|
+
try {
|
|
207
|
+
const value: unknown = JSON.parse(atob(raw));
|
|
208
|
+
if (!Array.isArray(value) || value.length !== arity) return null;
|
|
209
|
+
return value as readonly unknown[];
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* —————————————————————————————— URL parsing ——————————————————————————— */
|
|
216
|
+
|
|
217
|
+
const RESERVED_PARAMS = new Set([
|
|
218
|
+
"cursor",
|
|
219
|
+
"limit",
|
|
220
|
+
"offset",
|
|
221
|
+
"search",
|
|
222
|
+
"q",
|
|
223
|
+
"order",
|
|
224
|
+
"select",
|
|
225
|
+
"or",
|
|
226
|
+
"and",
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
const FILTER_OPS = new Set(["eq", "neq", "gt", "gte", "lt", "lte", "like", "ilike", "in", "is"]);
|
|
230
|
+
|
|
231
|
+
function badInput(message: string, path: string) {
|
|
232
|
+
return validationFailure([{ message, path: [path] }]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Parse `eq.x` / `in.(a,b)` / `is.null` into a drizzle op over `column`. */
|
|
236
|
+
function filterOp(
|
|
237
|
+
column: unknown,
|
|
238
|
+
key: string,
|
|
239
|
+
raw: string,
|
|
240
|
+
): SqlOp | { failure: ReturnType<typeof badInput> } {
|
|
241
|
+
const dot = raw.indexOf(".");
|
|
242
|
+
if (dot <= 0) return { failure: badInput(`expected "op.value" (e.g. ${key}=eq.x)`, key) };
|
|
243
|
+
const op = raw.slice(0, dot);
|
|
244
|
+
const value = raw.slice(dot + 1);
|
|
245
|
+
if (!FILTER_OPS.has(op)) {
|
|
246
|
+
return { failure: badInput(`unsupported filter op "${op}"`, key) };
|
|
247
|
+
}
|
|
248
|
+
switch (op) {
|
|
249
|
+
case "eq":
|
|
250
|
+
return leafOp(column, "=", value);
|
|
251
|
+
case "neq":
|
|
252
|
+
return leafOp(column, "!=", value);
|
|
253
|
+
case "gt":
|
|
254
|
+
return leafOp(column, ">", value);
|
|
255
|
+
case "gte":
|
|
256
|
+
return leafOp(column, ">=", value);
|
|
257
|
+
case "lt":
|
|
258
|
+
return leafOp(column, "<", value);
|
|
259
|
+
case "lte":
|
|
260
|
+
return leafOp(column, "<=", value);
|
|
261
|
+
case "like":
|
|
262
|
+
return leafOp(column, "like", value.replaceAll("*", "%"));
|
|
263
|
+
case "ilike":
|
|
264
|
+
return leafOp(column, "ilike", value.replaceAll("*", "%"));
|
|
265
|
+
case "is": {
|
|
266
|
+
if (value === "null") return leafOp(column, "is null", undefined);
|
|
267
|
+
if (value === "true") return leafOp(column, "=", true);
|
|
268
|
+
if (value === "false") return leafOp(column, "=", false);
|
|
269
|
+
return { failure: badInput(`is expects null|true|false`, key) };
|
|
270
|
+
}
|
|
271
|
+
case "in": {
|
|
272
|
+
const match = /^\((.*)\)$/.exec(value);
|
|
273
|
+
if (!match) return { failure: badInput(`in expects (a,b,c)`, key) };
|
|
274
|
+
const inner = match[1]!.trim();
|
|
275
|
+
const values = inner.length === 0 ? [] : inner.split(",");
|
|
276
|
+
if (values.length === 0) return { failure: badInput(`in expects at least one value`, key) };
|
|
277
|
+
return leafOp(column, "in", values);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return { failure: badInput(`unsupported filter op "${op}"`, key) };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Split `or=(a.ilike.*x*,b.eq.1)` inner list on commas. */
|
|
284
|
+
function groupInner(raw: string): readonly string[] | null {
|
|
285
|
+
const match = /^\((.*)\)$/.exec(raw.trim());
|
|
286
|
+
if (!match) return null;
|
|
287
|
+
const inner = match[1]!.trim();
|
|
288
|
+
return inner.length === 0 ? [] : inner.split(",");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** One `col.op.value` term inside an `or=(…)` / `and=(…)` group. */
|
|
292
|
+
function groupedFilterTerm(
|
|
293
|
+
tableColumns: Readonly<Record<string, unknown>>,
|
|
294
|
+
term: string,
|
|
295
|
+
joiner: "or" | "and",
|
|
296
|
+
): SqlOp | { failure: ReturnType<typeof badInput> } {
|
|
297
|
+
const dot = term.indexOf(".");
|
|
298
|
+
if (dot <= 0) {
|
|
299
|
+
return { failure: badInput(`expected "col.op.value" inside ${joiner}=(…)`, joiner) };
|
|
300
|
+
}
|
|
301
|
+
const col = tableColumns[term.slice(0, dot)];
|
|
302
|
+
if (col === undefined) {
|
|
303
|
+
return { failure: badInput(`unknown column "${term.slice(0, dot)}"`, joiner) };
|
|
304
|
+
}
|
|
305
|
+
return filterOp(col, joiner, term.slice(dot + 1));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* —————————————————————————————— the factory ——————————————————————————— */
|
|
309
|
+
|
|
310
|
+
function scopeColumns(
|
|
311
|
+
scope: ColumnScope,
|
|
312
|
+
columns: readonly ResolvedColumn[],
|
|
313
|
+
tableColumns: Readonly<Record<string, unknown>>,
|
|
314
|
+
): readonly ResolvedColumn[] | "none" | "all" {
|
|
315
|
+
if (scope === "none") return "none";
|
|
316
|
+
if (scope === "all") return columns;
|
|
317
|
+
const allowed = new Set(
|
|
318
|
+
scope
|
|
319
|
+
.map((c) =>
|
|
320
|
+
columns.find(
|
|
321
|
+
(col) => tableColumns[col.key] === c || col.sqlName === (c as { name?: string }).name,
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
.filter((c): c is ResolvedColumn => c !== undefined),
|
|
325
|
+
);
|
|
326
|
+
return columns.filter((c) => allowed.has(c));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build a CRUD + list resource over a sql table. Returns FlowDefs plus
|
|
331
|
+
* `.all()` for `on(http.resource(path, resource.all()))`.
|
|
332
|
+
*
|
|
333
|
+
* @param db - Sql store decl (`store.sql(...)`)
|
|
334
|
+
* @param table - Drizzle / schema table
|
|
335
|
+
* @param options - Contracts + list surface
|
|
336
|
+
*/
|
|
337
|
+
export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOptions): ResourceDef {
|
|
338
|
+
const tableName = resolveTableName(table);
|
|
339
|
+
const columns = resolveColumns(table);
|
|
340
|
+
const tableColumns = table as Readonly<Record<string, unknown>>;
|
|
341
|
+
const unit = options.unit ?? tableName;
|
|
342
|
+
|
|
343
|
+
const pk = columns.find((c) => c.primary) ?? columns[0];
|
|
344
|
+
const idColumn =
|
|
345
|
+
options.id !== undefined
|
|
346
|
+
? columns.find(
|
|
347
|
+
(c) =>
|
|
348
|
+
tableColumns[c.key] === options.id ||
|
|
349
|
+
c.sqlName === (options.id as { name?: string }).name,
|
|
350
|
+
)
|
|
351
|
+
: pk;
|
|
352
|
+
const idKey = idColumn?.key ?? "id";
|
|
353
|
+
const idDrizzleCol = idColumn !== undefined ? tableColumns[idColumn.key] : tableColumns.id;
|
|
354
|
+
|
|
355
|
+
const list = options.list ?? {};
|
|
356
|
+
const cursorCols = list.cursor ?? [];
|
|
357
|
+
const resolvedCursor = cursorCols
|
|
358
|
+
.map((c) =>
|
|
359
|
+
columns.find(
|
|
360
|
+
(col) => tableColumns[col.key] === c || col.sqlName === (c as { name?: string }).name,
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
.filter((c): c is ResolvedColumn => c !== undefined);
|
|
364
|
+
const mode: ListPageMode = list.mode ?? (resolvedCursor.length > 0 ? "cursor" : "offset");
|
|
365
|
+
const direction = list.direction ?? "desc";
|
|
366
|
+
const limit = list.limit ?? 20;
|
|
367
|
+
const maxLimit = list.maxLimit ?? 100;
|
|
368
|
+
const countMode = list.count ?? "exact";
|
|
369
|
+
|
|
370
|
+
const searchCols = scopeColumns(list.search ?? "none", columns, tableColumns);
|
|
371
|
+
const filterCols = scopeColumns(list.filter ?? "none", columns, tableColumns);
|
|
372
|
+
const orderScope =
|
|
373
|
+
list.order ?? (resolvedCursor.length > 0 ? (cursorCols as readonly unknown[]) : "all");
|
|
374
|
+
const orderCols = scopeColumns(orderScope, columns, tableColumns);
|
|
375
|
+
const selectScope = list.select ?? "all";
|
|
376
|
+
|
|
377
|
+
const filterAllowed = new Set(
|
|
378
|
+
filterCols === "none" || filterCols === "all" ? [] : filterCols.map((c) => c.key),
|
|
379
|
+
);
|
|
380
|
+
const orderAllowed = new Set(
|
|
381
|
+
orderCols === "none" || orderCols === "all" ? [] : orderCols.map((c) => c.key),
|
|
382
|
+
);
|
|
383
|
+
const searchList = searchCols === "none" || searchCols === "all" ? [] : searchCols;
|
|
384
|
+
|
|
385
|
+
const config: ResolvedListConfig = {
|
|
386
|
+
mode,
|
|
387
|
+
cursor: resolvedCursor,
|
|
388
|
+
direction,
|
|
389
|
+
limit,
|
|
390
|
+
maxLimit,
|
|
391
|
+
count: countMode,
|
|
392
|
+
search: searchCols === "none" || searchCols === "all" ? [] : searchCols,
|
|
393
|
+
filter: filterCols === "none" ? "none" : filterCols === "all" ? columns : filterCols,
|
|
394
|
+
order: orderCols === "none" ? "none" : orderCols === "all" ? columns : orderCols,
|
|
395
|
+
select:
|
|
396
|
+
selectScope === "all"
|
|
397
|
+
? "all"
|
|
398
|
+
: (scopeColumns(selectScope, columns, tableColumns) as readonly ResolvedColumn[]),
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const errors = options.errors ?? ({ NotFound: {} as never } as FlowErrorMap);
|
|
402
|
+
const breaking = options.breaking === true;
|
|
403
|
+
|
|
404
|
+
/** Parse validated list input → page options or a validation failure. */
|
|
405
|
+
function parseList(input: unknown):
|
|
406
|
+
| {
|
|
407
|
+
ok: true;
|
|
408
|
+
page: SqlPageOptions;
|
|
409
|
+
meta: Record<string, unknown>;
|
|
410
|
+
select?: readonly ResolvedColumn[];
|
|
411
|
+
}
|
|
412
|
+
| { ok: false; failure: unknown } {
|
|
413
|
+
const query = (input ?? {}) as Record<string, unknown>;
|
|
414
|
+
const str = (k: string): string | undefined => {
|
|
415
|
+
const v = query[k];
|
|
416
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
417
|
+
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
|
418
|
+
return undefined;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// limit
|
|
422
|
+
let pageLimit = limit;
|
|
423
|
+
const rawLimit = str("limit");
|
|
424
|
+
if (rawLimit !== undefined) {
|
|
425
|
+
const n = Number(rawLimit);
|
|
426
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
427
|
+
return { ok: false, failure: badInput("limit must be a positive integer", "limit") };
|
|
428
|
+
}
|
|
429
|
+
pageLimit = Math.min(n, maxLimit);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const wheres: unknown[] = [];
|
|
433
|
+
let meta: Record<string, unknown> = {};
|
|
434
|
+
|
|
435
|
+
// search
|
|
436
|
+
const search = str("search");
|
|
437
|
+
const q = str("q");
|
|
438
|
+
if (search !== undefined && q !== undefined) {
|
|
439
|
+
return { ok: false, failure: badInput("use either search or q, not both", "search") };
|
|
440
|
+
}
|
|
441
|
+
const searchTerm = search ?? q;
|
|
442
|
+
if (searchTerm !== undefined) {
|
|
443
|
+
if (searchList.length === 0) {
|
|
444
|
+
return {
|
|
445
|
+
ok: false,
|
|
446
|
+
failure: badInput("search is not enabled for this resource", "search"),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
const pattern = `%${searchTerm}%`;
|
|
450
|
+
const parts = searchList
|
|
451
|
+
.map((c) => tableColumns[c.key])
|
|
452
|
+
.filter((c): c is unknown => c !== undefined)
|
|
453
|
+
.map((c) => leafOp(c, "like", pattern) as SqlOp);
|
|
454
|
+
wheres.push(parts.length === 1 ? parts[0] : groupOp("or", parts));
|
|
455
|
+
meta.search = searchTerm;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// column filters
|
|
459
|
+
if (config.filter === "none") {
|
|
460
|
+
for (const key of Object.keys(query)) {
|
|
461
|
+
if (!RESERVED_PARAMS.has(key) && key !== "headers" && key !== "cookie") {
|
|
462
|
+
return { ok: false, failure: badInput(`unknown list param "${key}"`, key) };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} else {
|
|
466
|
+
for (const [key, value] of Object.entries(query)) {
|
|
467
|
+
if (RESERVED_PARAMS.has(key) || key === "headers" || key === "cookie") continue;
|
|
468
|
+
if (typeof value !== "string") continue;
|
|
469
|
+
const col = tableColumns[key];
|
|
470
|
+
const resolved = columns.find((c) => c.key === key);
|
|
471
|
+
if (col === undefined || resolved === undefined || !filterAllowed.has(key)) {
|
|
472
|
+
return { ok: false, failure: badInput(`unknown or unfilterable column "${key}"`, key) };
|
|
473
|
+
}
|
|
474
|
+
const op = filterOp(col, key, value);
|
|
475
|
+
if ("failure" in op) return { ok: false, failure: op.failure };
|
|
476
|
+
wheres.push(op);
|
|
477
|
+
}
|
|
478
|
+
// or= / and= groups (one level)
|
|
479
|
+
for (const joiner of ["or", "and"] as const) {
|
|
480
|
+
const rawGroup = str(joiner);
|
|
481
|
+
if (rawGroup === undefined) continue;
|
|
482
|
+
const inner = groupInner(rawGroup);
|
|
483
|
+
if (inner === null || inner.length === 0) {
|
|
484
|
+
return { ok: false, failure: badInput(`${joiner} expects (col.op.value,…)`, joiner) };
|
|
485
|
+
}
|
|
486
|
+
const parts: SqlOp[] = [];
|
|
487
|
+
for (const term of inner) {
|
|
488
|
+
const op = groupedFilterTerm(tableColumns, term, joiner);
|
|
489
|
+
if ("failure" in op) return { ok: false, failure: op.failure };
|
|
490
|
+
parts.push(op);
|
|
491
|
+
}
|
|
492
|
+
wheres.push(parts.length === 1 ? parts[0] : groupOp(joiner, parts));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// order
|
|
497
|
+
let orders: unknown[] | undefined;
|
|
498
|
+
const rawOrder = str("order");
|
|
499
|
+
if (rawOrder !== undefined) {
|
|
500
|
+
if (config.order === "none") {
|
|
501
|
+
return { ok: false, failure: badInput("order is not enabled for this resource", "order") };
|
|
502
|
+
}
|
|
503
|
+
orders = [];
|
|
504
|
+
for (const term of rawOrder.split(",")) {
|
|
505
|
+
const [key, dir] = term.split(".");
|
|
506
|
+
if (!key || (dir !== undefined && dir !== "asc" && dir !== "desc")) {
|
|
507
|
+
return { ok: false, failure: badInput(`bad order term "${term}"`, "order") };
|
|
508
|
+
}
|
|
509
|
+
const col = tableColumns[key];
|
|
510
|
+
const resolved = columns.find((c) => c.key === key);
|
|
511
|
+
if (col === undefined || resolved === undefined || !orderAllowed.has(key)) {
|
|
512
|
+
return {
|
|
513
|
+
ok: false,
|
|
514
|
+
failure: badInput(`unknown or unorderable column "${key}"`, "order"),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
orders.push(dir === "asc" ? ascOp(col) : descOp(col));
|
|
518
|
+
}
|
|
519
|
+
meta.order = rawOrder;
|
|
520
|
+
} else if (resolvedCursor.length > 0) {
|
|
521
|
+
orders = resolvedCursor.map((c) => {
|
|
522
|
+
const col = tableColumns[c.key];
|
|
523
|
+
return direction === "asc" ? ascOp(col) : descOp(col);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// select projection
|
|
528
|
+
let select: ResolvedColumn[] | undefined;
|
|
529
|
+
const rawSelect = str("select");
|
|
530
|
+
if (rawSelect !== undefined) {
|
|
531
|
+
if (config.select !== "all") {
|
|
532
|
+
return {
|
|
533
|
+
ok: false,
|
|
534
|
+
failure: badInput("select is not enabled for this resource", "select"),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
select = [];
|
|
538
|
+
for (const key of rawSelect.split(",")) {
|
|
539
|
+
const resolved = columns.find((c) => c.key === key);
|
|
540
|
+
if (resolved === undefined) {
|
|
541
|
+
return { ok: false, failure: badInput(`unknown column "${key}"`, "select") };
|
|
542
|
+
}
|
|
543
|
+
select.push(resolved);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// pagination
|
|
548
|
+
let offset: number | undefined;
|
|
549
|
+
let after: unknown;
|
|
550
|
+
if (mode === "offset") {
|
|
551
|
+
const rawOffset = str("offset");
|
|
552
|
+
if (rawOffset !== undefined) {
|
|
553
|
+
const n = Number(rawOffset);
|
|
554
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
555
|
+
return {
|
|
556
|
+
ok: false,
|
|
557
|
+
failure: badInput("offset must be a non-negative integer", "offset"),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
offset = n;
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
const rawCursor = str("cursor");
|
|
564
|
+
if (rawCursor !== undefined) {
|
|
565
|
+
if (resolvedCursor.length === 0) {
|
|
566
|
+
return { ok: false, failure: badInput("cursor pagination is not configured", "cursor") };
|
|
567
|
+
}
|
|
568
|
+
const values = decodeCursor(rawCursor, resolvedCursor.length);
|
|
569
|
+
if (values === null) {
|
|
570
|
+
return { ok: false, failure: badInput("invalid cursor", "cursor") };
|
|
571
|
+
}
|
|
572
|
+
after = keysetAfter(
|
|
573
|
+
resolvedCursor.map((c) => tableColumns[c.key]),
|
|
574
|
+
values,
|
|
575
|
+
direction === "desc",
|
|
576
|
+
);
|
|
577
|
+
meta.cursor = rawCursor;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const where =
|
|
582
|
+
wheres.length === 0
|
|
583
|
+
? undefined
|
|
584
|
+
: wheres.length === 1
|
|
585
|
+
? wheres[0]
|
|
586
|
+
: groupOp("and", wheres as SqlOp[]);
|
|
587
|
+
return {
|
|
588
|
+
ok: true,
|
|
589
|
+
page: {
|
|
590
|
+
where,
|
|
591
|
+
orderBy: orders,
|
|
592
|
+
limit: pageLimit + (mode === "cursor" ? 1 : 0),
|
|
593
|
+
offset,
|
|
594
|
+
after,
|
|
595
|
+
},
|
|
596
|
+
meta: { mode, limit: pageLimit, ...meta },
|
|
597
|
+
...(select !== undefined ? { select } : {}),
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Run the list query and shape rows + meta. */
|
|
602
|
+
async function runList(
|
|
603
|
+
input: unknown,
|
|
604
|
+
fx: Fx,
|
|
605
|
+
): Promise<{ data: SqlRow[]; meta: Record<string, unknown> } | { failure: unknown }> {
|
|
606
|
+
const parsed = parseList(input);
|
|
607
|
+
if (!parsed.ok) return { failure: parsed.failure };
|
|
608
|
+
const store = fx.store(db) as {
|
|
609
|
+
page(t: unknown, o: SqlPageOptions): Promise<SqlRow[]>;
|
|
610
|
+
count(t: unknown, w?: unknown): Promise<number>;
|
|
611
|
+
select(columns?: unknown): {
|
|
612
|
+
from(t: unknown): {
|
|
613
|
+
where(w: unknown): {
|
|
614
|
+
orderBy(...o: unknown[]): { limit(n: number): Promise<SqlRow[]> };
|
|
615
|
+
};
|
|
616
|
+
};
|
|
617
|
+
};
|
|
618
|
+
};
|
|
619
|
+
const rows = await store.page(table, parsed.page);
|
|
620
|
+
const meta: Record<string, unknown> = { ...parsed.meta };
|
|
621
|
+
|
|
622
|
+
let data: SqlRow[] = rows;
|
|
623
|
+
if (mode === "cursor") {
|
|
624
|
+
const pageSize = Number(parsed.meta.limit);
|
|
625
|
+
const hasNextPage = rows.length > pageSize;
|
|
626
|
+
const pageRows = hasNextPage ? rows.slice(0, pageSize) : rows;
|
|
627
|
+
const last = hasNextPage && pageRows.length > 0 ? pageRows[pageRows.length - 1] : undefined;
|
|
628
|
+
meta.hasNextPage = hasNextPage;
|
|
629
|
+
meta.nextCursor =
|
|
630
|
+
last === undefined
|
|
631
|
+
? null
|
|
632
|
+
: encodeCursor(resolvedCursor.map((c) => last[c.key] ?? last[c.sqlName]));
|
|
633
|
+
data = pageRows;
|
|
634
|
+
} else if (countMode === "exact") {
|
|
635
|
+
meta.total = await store.count(table, parsed.page.where);
|
|
636
|
+
meta.offset = parsed.page.offset ?? 0;
|
|
637
|
+
} else {
|
|
638
|
+
meta.offset = parsed.page.offset ?? 0;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (parsed.select !== undefined) {
|
|
642
|
+
const keep = new Set(parsed.select.map((c) => c.key));
|
|
643
|
+
data = data.map((row) => {
|
|
644
|
+
const out: SqlRow = {};
|
|
645
|
+
for (const key of Object.keys(row)) if (keep.has(key)) out[key] = row[key];
|
|
646
|
+
return out;
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
return { data, meta };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const listFlow = flow({
|
|
653
|
+
name: "list",
|
|
654
|
+
unit,
|
|
655
|
+
...(breaking ? { breaking: true as const } : {}),
|
|
656
|
+
// Loose record so the HTTP AoT infers `query` and lets every list URL
|
|
657
|
+
// key through; real validation happens in parseList (PostgREST grammar).
|
|
658
|
+
in: z.record(z.string(), z.unknown()) as never,
|
|
659
|
+
effects: { reads: [db.ref] },
|
|
660
|
+
do: async (input, fx) => {
|
|
661
|
+
const result = await runList(input, fx);
|
|
662
|
+
if ("failure" in result) return result.failure;
|
|
663
|
+
return fx.json.with(result.data, result.meta);
|
|
664
|
+
},
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
const createFlow = flow({
|
|
668
|
+
name: "create",
|
|
669
|
+
unit,
|
|
670
|
+
...(breaking ? { breaking: true as const } : {}),
|
|
671
|
+
in: options.in as never,
|
|
672
|
+
effects: { writes: [db.ref] },
|
|
673
|
+
do: async (input, fx) => {
|
|
674
|
+
const store = fx.store(db) as {
|
|
675
|
+
insert(t: unknown): { values(row: SqlRow): { returning(): Promise<SqlRow[]> } };
|
|
676
|
+
};
|
|
677
|
+
const [row] = await store
|
|
678
|
+
.insert(table)
|
|
679
|
+
.values(input as SqlRow)
|
|
680
|
+
.returning();
|
|
681
|
+
return fx.json.create(row);
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
const getFlow = flow({
|
|
686
|
+
name: "get",
|
|
687
|
+
unit,
|
|
688
|
+
...(breaking ? { breaking: true as const } : {}),
|
|
689
|
+
errors,
|
|
690
|
+
effects: { reads: [db.ref] },
|
|
691
|
+
do: async (input, fx) => {
|
|
692
|
+
const id = (input as Record<string, unknown>)[idKey];
|
|
693
|
+
const store = fx.store(db) as {
|
|
694
|
+
findById(t: unknown, id: string): Promise<SqlRow | null>;
|
|
695
|
+
};
|
|
696
|
+
const row = await store.findById(table, String(id));
|
|
697
|
+
if (!row) return fail("NotFound", {});
|
|
698
|
+
return row;
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// Wire update body is `{ id, ...patch }`. The patch schema (`update`,
|
|
703
|
+
// default `in`) describes the mutable fields; the path id rides along and
|
|
704
|
+
// must survive validation, so extend the ZodObject with the id key.
|
|
705
|
+
const patchSchema: unknown = options.update ?? options.in;
|
|
706
|
+
const updateIn =
|
|
707
|
+
options.idSchema !== undefined
|
|
708
|
+
? options.idSchema
|
|
709
|
+
: patchSchema instanceof z.ZodObject
|
|
710
|
+
? patchSchema.extend({ [idKey]: z.string() })
|
|
711
|
+
: patchSchema;
|
|
712
|
+
const updateFlow = flow({
|
|
713
|
+
name: "update",
|
|
714
|
+
unit,
|
|
715
|
+
...(breaking ? { breaking: true as const } : {}),
|
|
716
|
+
in: updateIn as never,
|
|
717
|
+
errors,
|
|
718
|
+
effects: { reads: [db.ref], writes: [db.ref] },
|
|
719
|
+
do: async (input, fx) => {
|
|
720
|
+
const { [idKey]: id, ...patch } = input as Record<string, unknown>;
|
|
721
|
+
const store = fx.store(db) as {
|
|
722
|
+
findById(t: unknown, id: string): Promise<SqlRow | null>;
|
|
723
|
+
update(t: unknown): { set(row: SqlRow): { where(w: unknown): Promise<number> } };
|
|
724
|
+
};
|
|
725
|
+
const existing = await store.findById(table, String(id));
|
|
726
|
+
if (!existing) return fail("NotFound", {});
|
|
727
|
+
if (Object.keys(patch).length > 0) {
|
|
728
|
+
await store
|
|
729
|
+
.update(table)
|
|
730
|
+
.set(patch as SqlRow)
|
|
731
|
+
.where(leafOp(idDrizzleCol, "=", String(id)));
|
|
732
|
+
}
|
|
733
|
+
const row = await store.findById(table, String(id));
|
|
734
|
+
if (!row) return fail("NotFound", {});
|
|
735
|
+
return row;
|
|
736
|
+
},
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
const removeFlow = flow({
|
|
740
|
+
name: "remove",
|
|
741
|
+
unit,
|
|
742
|
+
...(breaking ? { breaking: true as const } : {}),
|
|
743
|
+
errors,
|
|
744
|
+
effects: { writes: [db.ref] },
|
|
745
|
+
do: async (input, fx) => {
|
|
746
|
+
const id = (input as Record<string, unknown>)[idKey];
|
|
747
|
+
const store = fx.store(db) as {
|
|
748
|
+
delete(t: unknown, id: string): Promise<boolean>;
|
|
749
|
+
};
|
|
750
|
+
const deleted = await store.delete(table, String(id));
|
|
751
|
+
if (!deleted) return fail("NotFound", {});
|
|
752
|
+
return fx.json.empty();
|
|
753
|
+
},
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
const defs: ResourceFlowDefs = {
|
|
757
|
+
list: listFlow as FlowDef<any, any, any>,
|
|
758
|
+
create: createFlow as FlowDef<any, any, any>,
|
|
759
|
+
get: getFlow as FlowDef<any, any, any>,
|
|
760
|
+
update: updateFlow as FlowDef<any, any, any>,
|
|
761
|
+
remove: removeFlow as FlowDef<any, any, any>,
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
return {
|
|
765
|
+
...defs,
|
|
766
|
+
table: tableName,
|
|
767
|
+
idKey,
|
|
768
|
+
limit,
|
|
769
|
+
maxLimit,
|
|
770
|
+
listConfig: config,
|
|
771
|
+
columns: tableColumns,
|
|
772
|
+
sqlNameOf(key) {
|
|
773
|
+
return columns.find((c) => c.key === key)?.sqlName;
|
|
774
|
+
},
|
|
775
|
+
all: () => defs,
|
|
776
|
+
page(input) {
|
|
777
|
+
const parsed = parseList(input);
|
|
778
|
+
if (!parsed.ok) {
|
|
779
|
+
throw new TypeError(
|
|
780
|
+
`resource.page: invalid list input — ${JSON.stringify(parsed.failure)}`,
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
return { ...parsed.page, meta: parsed.meta };
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
}
|