@databricks/appkit 0.67.0 → 0.68.0
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/CLAUDE.md +2 -2
- package/dist/appkit/package.js +1 -1
- package/dist/database/contract/index.js +1 -1
- package/dist/database/contract/wire.d.ts.map +1 -1
- package/dist/database/contract/wire.js +5 -1
- package/dist/database/contract/wire.js.map +1 -1
- package/dist/database/errors.js +39 -3
- package/dist/database/errors.js.map +1 -1
- package/dist/database/runtime/data-path.js.map +1 -1
- package/dist/database/runtime/engine/drizzle-data-path.js +1 -1
- package/dist/database/runtime/engine/drizzle-data-path.js.map +1 -1
- package/dist/database/runtime/engine/translate.js +9 -2
- package/dist/database/runtime/engine/translate.js.map +1 -1
- package/dist/database/schema-builder/define-schema.d.ts +5 -1
- package/dist/database/schema-builder/define-schema.d.ts.map +1 -1
- package/dist/database/schema-builder/define-schema.js +4 -0
- package/dist/database/schema-builder/define-schema.js.map +1 -1
- package/dist/database/schema-builder/types.d.ts +7 -2
- package/dist/database/schema-builder/types.d.ts.map +1 -1
- package/dist/database/schema-builder/types.js.map +1 -1
- package/dist/plugins/database/crud/codecs.js +78 -0
- package/dist/plugins/database/crud/codecs.js.map +1 -0
- package/dist/plugins/database/crud/contract.js +151 -0
- package/dist/plugins/database/crud/contract.js.map +1 -0
- package/dist/plugins/database/crud/exposure.js +40 -0
- package/dist/plugins/database/crud/exposure.js.map +1 -0
- package/dist/plugins/database/crud/query.js +244 -0
- package/dist/plugins/database/crud/query.js.map +1 -0
- package/dist/plugins/database/crud/routes.js +124 -0
- package/dist/plugins/database/crud/routes.js.map +1 -0
- package/dist/plugins/database/database.d.ts +6 -0
- package/dist/plugins/database/database.d.ts.map +1 -1
- package/dist/plugins/database/database.js +58 -4
- package/dist/plugins/database/database.js.map +1 -1
- package/dist/plugins/database/defaults.js +28 -1
- package/dist/plugins/database/defaults.js.map +1 -1
- package/dist/plugins/database/entity-client.js +1 -1
- package/dist/plugins/database/entity-types.d.ts +13 -4
- package/dist/plugins/database/entity-types.d.ts.map +1 -1
- package/dist/plugins/database/lifecycle.js +2 -1
- package/dist/plugins/database/lifecycle.js.map +1 -1
- package/dist/plugins/database/types.d.ts +32 -0
- package/dist/plugins/database/types.d.ts.map +1 -1
- package/dist/plugins/server/index.js +2 -2
- package/dist/plugins/server/index.js.map +1 -1
- package/dist/plugins/server/remote-tunnel/remote-tunnel-manager.js +3 -3
- package/dist/plugins/server/remote-tunnel/remote-tunnel-manager.js.map +1 -1
- package/dist/plugins/server/static-server.js +3 -3
- package/dist/plugins/server/static-server.js.map +1 -1
- package/dist/plugins/server/utils.js +3 -3
- package/dist/plugins/server/utils.js.map +1 -1
- package/dist/plugins/server/vite-dev-server.js +4 -4
- package/dist/plugins/server/vite-dev-server.js.map +1 -1
- package/dist/shared/src/schemas/manifest.d.ts +33 -33
- package/dist/type-generator/database/generate.js +3 -3
- package/dist/type-generator/database/generate.js.map +1 -1
- package/dist/type-generator/migration.js +2 -2
- package/dist/type-generator/migration.js.map +1 -1
- package/dist/type-generator/serving/server-file-extractor.js +3 -3
- package/dist/type-generator/serving/server-file-extractor.js.map +1 -1
- package/docs/api/appkit/Function.database.md +3 -3
- package/docs/api/appkit/Function.defineSchema.md +14 -6
- package/docs/api/appkit/Interface.Schema.md +10 -2
- package/docs/api/appkit/TypeAlias.IDatabaseConfig.md +20 -0
- package/docs/api/appkit.md +2 -2
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/sbom.cdx.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query.js","names":[],"sources":["../../../../src/plugins/database/crud/query.ts"],"sourcesContent":["import {\n DEFAULT_LIMIT,\n IN_CAP,\n isFilterOperator,\n MAX_INCLUDE_DEPTH,\n MAX_INCLUDE_NODES,\n MAX_INCLUDES,\n MAX_LIMIT,\n} from \"../../../database/contract\";\nimport { invalidDatabaseInput } from \"../../../database/errors\";\nimport type {\n IncludeOptions,\n IncludeSpec,\n OrderDirection,\n OrderSpec,\n ScalarValue,\n WhereClause,\n WhereValue,\n} from \"../../../database/runtime\";\nimport { filterOperatorsForKind } from \"../../../database/schema-builder/types\";\nimport {\n MAX_GROUP_ITEMS,\n MAX_MATERIALIZED_NODES,\n MAX_OFFSET,\n MAX_ORDER_FIELDS,\n MAX_QUERY_BYTES,\n MAX_WHERE_CONDITIONS,\n MAX_WHERE_DEPTH,\n} from \"../defaults\";\nimport type { CompiledColumn } from \"./codecs\";\nimport type { CrudTable } from \"./contract\";\n\n/** One decoded, budget-checked page request. */\ninterface DecodedListQuery {\n readonly where?: WhereClause;\n readonly order?: OrderSpec;\n readonly select?: string[];\n readonly include?: IncludeSpec;\n readonly limit: number;\n readonly offset: number;\n}\n\n/** One decoded, budget-checked single-row request. */\ninterface DecodedDetailQuery {\n readonly select?: string[];\n readonly include?: IncludeSpec;\n}\n\nconst LIST_PARAMS = new Set([\n \"where\",\n \"order\",\n \"select\",\n \"include\",\n \"limit\",\n \"offset\",\n]);\nconst DETAIL_PARAMS = new Set([\"select\", \"include\"]);\nconst CANONICAL_INT = /^-?(0|[1-9]\\d*)$/;\n\n/**\n * Reject one query parameter. `parameter` is always a fixed name and `reason`\n * a fixed sentence, so a rejection can never echo caller-supplied text back.\n */\nfunction reject(parameter: string, reason: string): never {\n throw invalidDatabaseInput([parameter], reason);\n}\n\n/** A decoded JSON object; arrays and null are separate wire shapes here. */\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/** Read known, single-occurrence parameters from the raw query string. */\nfunction parseParams(\n rawQuery: string,\n allowed: ReadonlySet<string>,\n): Map<string, string> {\n if (Buffer.byteLength(rawQuery, \"utf8\") > MAX_QUERY_BYTES) {\n reject(\"query\", \"Query string exceeds the maximum size\");\n }\n const params = new Map<string, string>();\n for (const [key, value] of new URLSearchParams(rawQuery)) {\n if (!allowed.has(key) || params.has(key)) {\n reject(\"query\", \"Unknown or repeated query parameter\");\n }\n params.set(key, value);\n }\n return params;\n}\n\n/** Parse one structured parameter without leaking the parser's diagnostics. */\nfunction parseJson(raw: string, parameter: string): unknown {\n try {\n return JSON.parse(raw);\n } catch {\n reject(parameter, \"Expected JSON\");\n }\n}\n\n/** Accept one bounded integer, rejecting the forms `Number` would coerce. */\nfunction decodeIntParam(\n raw: string | undefined,\n parameter: string,\n fallback: number,\n max: number,\n): number {\n if (raw === undefined) return fallback;\n if (!CANONICAL_INT.test(raw)) {\n reject(parameter, \"Expected a canonical decimal integer\");\n }\n const value = Number(raw);\n if (!Number.isSafeInteger(value) || value < 0 || value > max) {\n reject(parameter, `Must be between 0 and ${max}`);\n }\n return value;\n}\n\n/** Narrow a projection to the table's public columns. */\nfunction decodeSelect(\n table: CrudTable,\n value: unknown,\n parameter: string,\n): string[] {\n if (!Array.isArray(value) || value.length === 0) {\n reject(parameter, \"Expected a non-empty array of public column names\");\n }\n for (const name of value) {\n if (typeof name !== \"string\" || !table.selectable.has(name)) {\n reject(parameter, \"Names an unknown or private column\");\n }\n }\n return value as string[];\n}\n\n/** Order only by columns the table can sort on, in a declared direction. */\nfunction decodeOrder(\n table: CrudTable,\n value: unknown,\n parameter: string,\n): OrderSpec {\n if (!isPlainObject(value)) {\n reject(parameter, \"Expected an object of column directions\");\n }\n const entries = Object.entries(value);\n if (entries.length === 0 || entries.length > MAX_ORDER_FIELDS) {\n reject(parameter, `Expected 1 to ${MAX_ORDER_FIELDS} columns`);\n }\n const order: Record<string, OrderDirection> = {};\n for (const [column, direction] of entries) {\n if (!table.queryable.has(column)) {\n reject(parameter, \"Names an unknown or unorderable column\");\n }\n if (direction !== \"asc\" && direction !== \"desc\") {\n reject(parameter, \"Expected a direction of asc or desc\");\n }\n order[column] = direction;\n }\n return order;\n}\n\n/** Move one filter operand onto its column's canonical runtime value. */\nfunction decodeOperand(\n column: CompiledColumn,\n raw: unknown,\n parameter: string,\n): ScalarValue {\n const value = column.decode(raw);\n if (value === undefined)\n reject(parameter, \"Operand does not match its column type\");\n return value;\n}\n\n/**\n * Decode the predicate on one column against the operator matrix its kind\n * allows, counting conditions so a filter cannot grow without bound.\n */\nfunction decodeCondition(\n column: CompiledColumn,\n value: unknown,\n parameter: string,\n state: { conditions: number },\n): WhereValue {\n const countCondition = () => {\n state.conditions += 1;\n if (state.conditions > MAX_WHERE_CONDITIONS) {\n reject(parameter, `Expected at most ${MAX_WHERE_CONDITIONS} conditions`);\n }\n };\n\n if (value === null) {\n // SQL three-valued matching stays explicit; a bare null is ambiguous.\n reject(parameter, \"Match a null column with is: null\");\n }\n if (!isPlainObject(value)) {\n if (Array.isArray(value)) {\n reject(parameter, \"Expected a scalar or an operator object\");\n }\n countCondition();\n return decodeOperand(column, value, parameter);\n }\n\n const nullable = !column.meta.notNull;\n const supported = filterOperatorsForKind(column.meta.kind);\n const operators: Record<string, ScalarValue | ScalarValue[]> = {};\n const entries = Object.entries(value);\n if (entries.length === 0) reject(parameter, \"Filter cannot be empty\");\n\n for (const [operator, operand] of entries) {\n countCondition();\n if (operator === \"is\") {\n if (operand !== null || !nullable) {\n reject(\n parameter,\n \"The is operator accepts only null on a nullable column\",\n );\n }\n operators.is = null;\n continue;\n }\n if (!isFilterOperator(operator) || !supported.includes(operator)) {\n reject(parameter, \"Operator is not supported for this column\");\n }\n if (operator !== \"in\") {\n operators[operator] = decodeOperand(column, operand, parameter);\n continue;\n }\n if (!Array.isArray(operand) || operand.length > IN_CAP) {\n reject(parameter, `Expected an array of at most ${IN_CAP} values`);\n }\n operators.in = operand.map((item) => {\n if (item === null)\n reject(parameter, \"The in operator does not accept null\");\n return decodeOperand(column, item, parameter);\n });\n }\n return operators as WhereValue;\n}\n\n/** Decode one filter level, recursing through bounded `and`/`or` groups. */\nfunction decodeWhere(\n table: CrudTable,\n node: unknown,\n depth: number,\n parameter: string,\n state: { conditions: number },\n): WhereClause {\n if (depth > MAX_WHERE_DEPTH) {\n reject(parameter, `Expected at most ${MAX_WHERE_DEPTH} nesting levels`);\n }\n if (!isPlainObject(node)) reject(parameter, \"Expected an object\");\n\n const clause: Record<string, WhereValue | WhereClause[]> = {};\n for (const [key, value] of Object.entries(node)) {\n if (key === \"and\" || key === \"or\") {\n if (\n !Array.isArray(value) ||\n value.length === 0 ||\n value.length > MAX_GROUP_ITEMS\n ) {\n reject(parameter, `Expected 1 to ${MAX_GROUP_ITEMS} group members`);\n }\n clause[key] = value.map((member) =>\n decodeWhere(table, member, depth + 1, parameter, state),\n );\n continue;\n }\n // Relations are absent from `queryable`, so relation predicates fail here.\n const column = table.queryable.has(key)\n ? table.columns.get(key)\n : undefined;\n if (!column) reject(parameter, \"Names an unknown or unfilterable column\");\n clause[key] = decodeCondition(column, value, parameter, state);\n }\n return clause;\n}\n\n/**\n * Decode one relation's options against the target table, not the parent, and\n * give every to-many edge a limit so an unqualified include stays bounded.\n */\nfunction decodeIncludeOptions(\n target: CrudTable,\n value: unknown,\n depth: number,\n toMany: boolean,\n parameter: string,\n): boolean | IncludeOptions {\n if (value === true) return toMany ? { limit: DEFAULT_LIMIT } : true;\n if (!isPlainObject(value)) {\n reject(parameter, \"Expected true or an options object\");\n }\n\n const options: {\n select?: string[];\n where?: WhereClause;\n order?: OrderSpec;\n limit?: number;\n include?: IncludeSpec;\n } = {};\n for (const [key, inner] of Object.entries(value)) {\n switch (key) {\n case \"select\":\n options.select = decodeSelect(target, inner, parameter);\n break;\n case \"where\":\n options.where = decodeWhere(target, inner, 1, parameter, {\n conditions: 0,\n });\n break;\n case \"order\":\n options.order = decodeOrder(target, inner, parameter);\n break;\n case \"limit\":\n if (!toMany) reject(parameter, \"Only to-many relations accept a limit\");\n if (\n typeof inner !== \"number\" ||\n !Number.isSafeInteger(inner) ||\n inner < 0 ||\n inner > MAX_LIMIT\n ) {\n reject(parameter, `Expected a limit between 0 and ${MAX_LIMIT}`);\n }\n options.limit = inner;\n break;\n case \"include\":\n options.include = decodeInclude(target, inner, depth + 1, parameter);\n break;\n default:\n reject(parameter, \"Unsupported relation option\");\n }\n }\n if (toMany && options.limit === undefined) options.limit = DEFAULT_LIMIT;\n return options;\n}\n\n/** Decode one include level; a relation to an unexposed table has no edge. */\nfunction decodeInclude(\n table: CrudTable,\n value: unknown,\n depth: number,\n parameter: string,\n): IncludeSpec {\n if (depth > MAX_INCLUDE_DEPTH) {\n reject(parameter, `Expected at most ${MAX_INCLUDE_DEPTH} relation edges`);\n }\n if (!isPlainObject(value)) {\n reject(parameter, \"Expected an object of relation names\");\n }\n const entries = Object.entries(value);\n if (entries.length > MAX_INCLUDES) {\n reject(parameter, `Expected at most ${MAX_INCLUDES} relations per level`);\n }\n\n const include: Record<string, boolean | IncludeOptions> = {};\n for (const [name, options] of entries) {\n const relation = table.relations.get(name);\n if (!relation) reject(parameter, \"Names an unknown or unexposed relation\");\n include[name] = decodeIncludeOptions(\n relation.target,\n options,\n depth,\n relation.cardinality === \"toMany\",\n parameter,\n );\n }\n return include;\n}\n\n/**\n * Rows one include tree materializes per parent row, counting relation nodes\n * on the way. Every term is a non-negative integer bounded by `MAX_LIMIT` and\n * both running totals are checked after each addition, so neither can overflow.\n */\nfunction measureInclude(\n table: CrudTable,\n include: IncludeSpec | undefined,\n budget: { nodes: number },\n): number {\n let rows = 1;\n if (!include) return rows;\n for (const [name, value] of Object.entries(include)) {\n const relation = table.relations.get(name);\n if (!relation) continue;\n budget.nodes += 1;\n if (budget.nodes > MAX_INCLUDE_NODES) {\n reject(\"include\", `Expected at most ${MAX_INCLUDE_NODES} relations`);\n }\n const options = value === true ? undefined : (value as IncludeOptions);\n const child = measureInclude(relation.target, options?.include, budget);\n rows +=\n relation.cardinality === \"toMany\"\n ? (options?.limit ?? DEFAULT_LIMIT) * child\n : child;\n if (rows > MAX_MATERIALIZED_NODES) {\n reject(\"include\", \"Relation fan-out exceeds the read budget\");\n }\n }\n return rows;\n}\n\n/** Reject an include tree whose cost is only visible once it is assembled. */\nfunction assertReadBudget(\n table: CrudTable,\n include: IncludeSpec | undefined,\n rootRows: number,\n): void {\n const rows = measureInclude(table, include, { nodes: 0 });\n if (rootRows * rows > MAX_MATERIALIZED_NODES) {\n reject(\"include\", \"Relation fan-out exceeds the read budget\");\n }\n}\n\n/** Decode the projection parameters both reads share. */\nfunction decodeProjection(\n table: CrudTable,\n params: ReadonlyMap<string, string>,\n): DecodedDetailQuery {\n const rawSelect = params.get(\"select\");\n const rawInclude = params.get(\"include\");\n return {\n select:\n rawSelect === undefined\n ? undefined\n : decodeSelect(table, parseJson(rawSelect, \"select\"), \"select\"),\n include:\n rawInclude === undefined\n ? undefined\n : decodeInclude(table, parseJson(rawInclude, \"include\"), 1, \"include\"),\n };\n}\n\n/** Decode `GET /:table` and reject it before any database work is scheduled. */\nexport function decodeListQuery(\n table: CrudTable,\n rawQuery: string,\n): DecodedListQuery {\n const params = parseParams(rawQuery, LIST_PARAMS);\n const rawWhere = params.get(\"where\");\n const rawOrder = params.get(\"order\");\n\n const where =\n rawWhere === undefined\n ? undefined\n : decodeWhere(table, parseJson(rawWhere, \"where\"), 1, \"where\", {\n conditions: 0,\n });\n const order =\n rawOrder === undefined\n ? undefined\n : decodeOrder(table, parseJson(rawOrder, \"order\"), \"order\");\n const { select, include } = decodeProjection(table, params);\n const limit = decodeIntParam(\n params.get(\"limit\"),\n \"limit\",\n DEFAULT_LIMIT,\n MAX_LIMIT,\n );\n const offset = decodeIntParam(params.get(\"offset\"), \"offset\", 0, MAX_OFFSET);\n\n assertReadBudget(table, include, limit);\n return { where, order, select, include, limit, offset };\n}\n\n/** Decode `GET /:table/:id`, which supports projection and includes only. */\nexport function decodeDetailQuery(\n table: CrudTable,\n rawQuery: string,\n): DecodedDetailQuery {\n const decoded = decodeProjection(table, parseParams(rawQuery, DETAIL_PARAMS));\n assertReadBudget(table, decoded.include, 1);\n return decoded;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { DatabasePluginError, classifyDatabaseError, invalidDatabaseInput } from "../../../database/errors.js";
|
|
2
|
+
import { MAX_RESPONSE_BYTES } from "../defaults.js";
|
|
3
|
+
import { decodeDetailQuery, decodeListQuery } from "./query.js";
|
|
4
|
+
|
|
5
|
+
//#region src/plugins/database/crud/routes.ts
|
|
6
|
+
/** Low-cardinality span outcome for one failed generated read. */
|
|
7
|
+
function readRouteOutcome(error) {
|
|
8
|
+
const { statusCode } = classifyDatabaseError(error, "read");
|
|
9
|
+
if (statusCode === 404) return "not_found";
|
|
10
|
+
return statusCode < 500 ? "rejected" : "failed";
|
|
11
|
+
}
|
|
12
|
+
/** Express normalizes `req.query`; the decoders need the untouched string. */
|
|
13
|
+
function rawQuery(req) {
|
|
14
|
+
const url = req.originalUrl ?? req.url;
|
|
15
|
+
const start = url.indexOf("?");
|
|
16
|
+
return start === -1 ? "" : url.slice(start + 1);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Append the primary key so equal sort keys cannot reshuffle between pages.
|
|
20
|
+
* A keyless table has no unique tie-breaker to append, so it must order itself
|
|
21
|
+
* and its pages stay stable only while no concurrent write reorders them.
|
|
22
|
+
*/
|
|
23
|
+
function stableOrder(primaryKey, order) {
|
|
24
|
+
if (primaryKey) return {
|
|
25
|
+
...order,
|
|
26
|
+
[primaryKey]: order?.[primaryKey] ?? "asc"
|
|
27
|
+
};
|
|
28
|
+
if (!order) throw invalidDatabaseInput(["order"], "A table without a primary key requires an explicit order");
|
|
29
|
+
return order;
|
|
30
|
+
}
|
|
31
|
+
function serializeRow(deps, operation, row) {
|
|
32
|
+
const projected = deps.table.projectPublicRow(row);
|
|
33
|
+
if (!deps.serialize) return projected;
|
|
34
|
+
const shaped = deps.serialize(projected, {
|
|
35
|
+
entity: deps.table.name,
|
|
36
|
+
operation
|
|
37
|
+
});
|
|
38
|
+
return deps.table.sanitizeSerializedRow(shaped);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Row data is never cacheable by a shared proxy or a browser: the same URL can
|
|
42
|
+
* answer differently once the underlying table or the caller's rights change.
|
|
43
|
+
*/
|
|
44
|
+
function writeJson(res, status, payload) {
|
|
45
|
+
res.status(status);
|
|
46
|
+
res.type("application/json");
|
|
47
|
+
res.setHeader("Cache-Control", "no-store");
|
|
48
|
+
res.send(payload);
|
|
49
|
+
}
|
|
50
|
+
/** Measure the encoded body before sending so no partial response escapes. */
|
|
51
|
+
function sendJson(res, body) {
|
|
52
|
+
const payload = JSON.stringify(body);
|
|
53
|
+
if (Buffer.byteLength(payload, "utf8") > MAX_RESPONSE_BYTES) throw new DatabasePluginError("PAYLOAD_TOO_LARGE", "read");
|
|
54
|
+
writeJson(res, 200, payload);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Encode the list envelope one row at a time, charging each encoded row
|
|
58
|
+
* against the byte budget before the next row is shaped, so one response
|
|
59
|
+
* never costs more than the budget in memory.
|
|
60
|
+
*/
|
|
61
|
+
function encodeListPayload(deps, rows, limit, offset) {
|
|
62
|
+
const prefix = "{\"items\":[";
|
|
63
|
+
const suffix = `],"limit":${limit},"offset":${offset}}`;
|
|
64
|
+
let bytes = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
|
|
65
|
+
const items = [];
|
|
66
|
+
for (const row of rows) {
|
|
67
|
+
const encoded = JSON.stringify(serializeRow(deps, "list", row));
|
|
68
|
+
bytes += Buffer.byteLength(encoded, "utf8") + (items.length > 0 ? 1 : 0);
|
|
69
|
+
if (bytes > MAX_RESPONSE_BYTES) throw new DatabasePluginError("PAYLOAD_TOO_LARGE", "read");
|
|
70
|
+
items.push(encoded);
|
|
71
|
+
}
|
|
72
|
+
return prefix + items.join(",") + suffix;
|
|
73
|
+
}
|
|
74
|
+
/** Answer with the failure's safe category and the field it concerns. */
|
|
75
|
+
function writeError(res, error) {
|
|
76
|
+
if (res.headersSent) return;
|
|
77
|
+
const safe = classifyDatabaseError(error, "read");
|
|
78
|
+
const body = { error: safe.clientMessage };
|
|
79
|
+
if (safe.details && safe.details.length > 0) body.details = safe.details;
|
|
80
|
+
writeJson(res, safe.statusCode, JSON.stringify(body));
|
|
81
|
+
}
|
|
82
|
+
/** `GET /:table` — one bounded page in the `{ items, limit, offset }` envelope. */
|
|
83
|
+
function createListHandler(deps) {
|
|
84
|
+
const primaryKey = deps.table.primaryKey?.meta.columnName;
|
|
85
|
+
const route = `/${deps.table.name}`;
|
|
86
|
+
return async (req, res) => {
|
|
87
|
+
try {
|
|
88
|
+
await deps.runRouteSpan("list", route, async () => {
|
|
89
|
+
const decoded = decodeListQuery(deps.table, rawQuery(req));
|
|
90
|
+
let query = deps.entity().order(stableOrder(primaryKey, decoded.order)).limit(decoded.limit).offset(decoded.offset);
|
|
91
|
+
if (decoded.where) query = query.where(decoded.where);
|
|
92
|
+
if (decoded.select) query = query.select(decoded.select);
|
|
93
|
+
if (decoded.include) query = query.include(decoded.include);
|
|
94
|
+
writeJson(res, 200, encodeListPayload(deps, await query.toArray(), decoded.limit, decoded.offset));
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
writeError(res, error);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** `GET /:table/:id` — one public row, or 404 when nothing matches. */
|
|
102
|
+
function createDetailHandler(deps) {
|
|
103
|
+
const route = `/${deps.table.name}/:id`;
|
|
104
|
+
return async (req, res) => {
|
|
105
|
+
try {
|
|
106
|
+
await deps.runRouteSpan("detail", route, async () => {
|
|
107
|
+
const id = deps.table.decodeId(req.params.id);
|
|
108
|
+
const decoded = decodeDetailQuery(deps.table, rawQuery(req));
|
|
109
|
+
let query = deps.entity();
|
|
110
|
+
if (decoded.select) query = query.select(decoded.select);
|
|
111
|
+
if (decoded.include) query = query.include(decoded.include);
|
|
112
|
+
const row = await query.find(id);
|
|
113
|
+
if (row === null) throw new DatabasePluginError("NOT_FOUND", "read");
|
|
114
|
+
sendJson(res, serializeRow(deps, "detail", row));
|
|
115
|
+
});
|
|
116
|
+
} catch (error) {
|
|
117
|
+
writeError(res, error);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//#endregion
|
|
123
|
+
export { createDetailHandler, createListHandler, readRouteOutcome };
|
|
124
|
+
//# sourceMappingURL=routes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.js","names":[],"sources":["../../../../src/plugins/database/crud/routes.ts"],"sourcesContent":["import type { Request, Response } from \"express\";\n\nimport {\n classifyDatabaseError,\n type DatabaseErrorDetail,\n DatabasePluginError,\n invalidDatabaseInput,\n} from \"../../../database/errors\";\nimport type {\n IdValue,\n IncludeSpec,\n OrderSpec,\n Row,\n WhereClause,\n} from \"../../../database/runtime\";\nimport { MAX_RESPONSE_BYTES } from \"../defaults\";\nimport type { ReadSerializer } from \"../types\";\nimport type { JsonValue } from \"./codecs\";\nimport type { CrudTable } from \"./contract\";\nimport { decodeDetailQuery, decodeListQuery } from \"./query\";\n\n/** The `EntityClient` subset a generated read drives. */\nexport interface CrudReadEntity {\n where(where: WhereClause): CrudReadEntity;\n order(order: OrderSpec): CrudReadEntity;\n select(columns: string[]): CrudReadEntity;\n include(include: IncludeSpec): CrudReadEntity;\n limit(limit: number): CrudReadEntity;\n offset(offset: number): CrudReadEntity;\n toArray(): Promise<Row[]>;\n find(id: IdValue): Promise<Row | null>;\n}\n\n/** Everything one table's generated reads need from the plugin instance. */\nexport interface ReadRouteDeps {\n readonly table: CrudTable;\n /** Resolved per request so a draining plugin cannot serve a stale client. */\n entity(): CrudReadEntity;\n readonly serialize?: ReadSerializer;\n runRouteSpan(\n operation: \"list\" | \"detail\",\n route: string,\n run: () => Promise<void>,\n ): Promise<void>;\n}\n\ntype ReadHandler = (req: Request, res: Response) => Promise<void>;\n\n/** Low-cardinality span outcome for one failed generated read. */\nexport function readRouteOutcome(\n error: unknown,\n): \"not_found\" | \"rejected\" | \"failed\" {\n const { statusCode } = classifyDatabaseError(error, \"read\");\n if (statusCode === 404) return \"not_found\";\n return statusCode < 500 ? \"rejected\" : \"failed\";\n}\n\n/** Express normalizes `req.query`; the decoders need the untouched string. */\nfunction rawQuery(req: Request): string {\n const url = req.originalUrl ?? req.url;\n const start = url.indexOf(\"?\");\n return start === -1 ? \"\" : url.slice(start + 1);\n}\n\n/**\n * Append the primary key so equal sort keys cannot reshuffle between pages.\n * A keyless table has no unique tie-breaker to append, so it must order itself\n * and its pages stay stable only while no concurrent write reorders them.\n */\nfunction stableOrder(\n primaryKey: string | undefined,\n order: OrderSpec | undefined,\n): OrderSpec {\n if (primaryKey)\n return { ...order, [primaryKey]: order?.[primaryKey] ?? \"asc\" };\n if (!order) {\n throw invalidDatabaseInput(\n [\"order\"],\n \"A table without a primary key requires an explicit order\",\n );\n }\n return order;\n}\n\nfunction serializeRow(\n deps: ReadRouteDeps,\n operation: \"list\" | \"detail\",\n row: Row,\n): JsonValue {\n const projected = deps.table.projectPublicRow(row);\n if (!deps.serialize) return projected;\n const shaped = deps.serialize(projected as Record<string, unknown>, {\n entity: deps.table.name,\n operation,\n });\n return deps.table.sanitizeSerializedRow(shaped);\n}\n\n/**\n * Row data is never cacheable by a shared proxy or a browser: the same URL can\n * answer differently once the underlying table or the caller's rights change.\n */\nfunction writeJson(res: Response, status: number, payload: string): void {\n res.status(status);\n res.type(\"application/json\");\n res.setHeader(\"Cache-Control\", \"no-store\");\n res.send(payload);\n}\n\n/** Measure the encoded body before sending so no partial response escapes. */\nfunction sendJson(res: Response, body: JsonValue): void {\n const payload = JSON.stringify(body);\n if (Buffer.byteLength(payload, \"utf8\") > MAX_RESPONSE_BYTES) {\n throw new DatabasePluginError(\"PAYLOAD_TOO_LARGE\", \"read\");\n }\n writeJson(res, 200, payload);\n}\n\n/**\n * Encode the list envelope one row at a time, charging each encoded row\n * against the byte budget before the next row is shaped, so one response\n * never costs more than the budget in memory.\n */\nfunction encodeListPayload(\n deps: ReadRouteDeps,\n rows: Row[],\n limit: number,\n offset: number,\n): string {\n const prefix = '{\"items\":[';\n const suffix = `],\"limit\":${limit},\"offset\":${offset}}`;\n let bytes =\n Buffer.byteLength(prefix, \"utf8\") + Buffer.byteLength(suffix, \"utf8\");\n const items: string[] = [];\n for (const row of rows) {\n const encoded = JSON.stringify(serializeRow(deps, \"list\", row));\n bytes += Buffer.byteLength(encoded, \"utf8\") + (items.length > 0 ? 1 : 0);\n if (bytes > MAX_RESPONSE_BYTES) {\n throw new DatabasePluginError(\"PAYLOAD_TOO_LARGE\", \"read\");\n }\n items.push(encoded);\n }\n return prefix + items.join(\",\") + suffix;\n}\n\n/** Answer with the failure's safe category and the field it concerns. */\nfunction writeError(res: Response, error: unknown): void {\n if (res.headersSent) return;\n const safe = classifyDatabaseError(error, \"read\");\n const body: { error: string; details?: readonly DatabaseErrorDetail[] } = {\n error: safe.clientMessage,\n };\n if (safe.details && safe.details.length > 0) body.details = safe.details;\n writeJson(res, safe.statusCode, JSON.stringify(body));\n}\n\n/** `GET /:table` — one bounded page in the `{ items, limit, offset }` envelope. */\nexport function createListHandler(deps: ReadRouteDeps): ReadHandler {\n const primaryKey = deps.table.primaryKey?.meta.columnName;\n const route = `/${deps.table.name}`;\n\n return async (req, res) => {\n try {\n await deps.runRouteSpan(\"list\", route, async () => {\n const decoded = decodeListQuery(deps.table, rawQuery(req));\n let query = deps\n .entity()\n .order(stableOrder(primaryKey, decoded.order))\n .limit(decoded.limit)\n .offset(decoded.offset);\n if (decoded.where) query = query.where(decoded.where);\n if (decoded.select) query = query.select(decoded.select);\n if (decoded.include) query = query.include(decoded.include);\n\n const rows = await query.toArray();\n writeJson(\n res,\n 200,\n encodeListPayload(deps, rows, decoded.limit, decoded.offset),\n );\n });\n } catch (error) {\n writeError(res, error);\n }\n };\n}\n\n/** `GET /:table/:id` — one public row, or 404 when nothing matches. */\nexport function createDetailHandler(deps: ReadRouteDeps): ReadHandler {\n const route = `/${deps.table.name}/:id`;\n\n return async (req, res) => {\n try {\n await deps.runRouteSpan(\"detail\", route, async () => {\n const id = deps.table.decodeId(req.params.id);\n const decoded = decodeDetailQuery(deps.table, rawQuery(req));\n let query = deps.entity();\n if (decoded.select) query = query.select(decoded.select);\n if (decoded.include) query = query.include(decoded.include);\n\n const row = await query.find(id);\n if (row === null) throw new DatabasePluginError(\"NOT_FOUND\", \"read\");\n sendJson(res, serializeRow(deps, \"detail\", row));\n });\n } catch (error) {\n writeError(res, error);\n }\n };\n}\n"],"mappings":"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"}
|
|
@@ -7,6 +7,7 @@ import { PluginManifest } from "../../registry/types.js";
|
|
|
7
7
|
import "../../registry/index.js";
|
|
8
8
|
import { DatabaseExports } from "./entity-types.js";
|
|
9
9
|
import { IDatabaseConfig } from "./types.js";
|
|
10
|
+
import express from "express";
|
|
10
11
|
|
|
11
12
|
//#region src/plugins/database/database.d.ts
|
|
12
13
|
/** Schema-driven database plugin */
|
|
@@ -18,13 +19,18 @@ declare class DatabasePlugin<TSchema extends Schema> extends Plugin<IDatabaseCon
|
|
|
18
19
|
private setupPromise;
|
|
19
20
|
private draining;
|
|
20
21
|
private shutdownPromise;
|
|
22
|
+
private exposedTables;
|
|
21
23
|
constructor(config: IDatabaseConfig<TSchema>);
|
|
22
24
|
/** Build and verify one candidate state before publishing its exports. */
|
|
23
25
|
setup(): Promise<void>;
|
|
26
|
+
/** Register generated reads for explicitly exposed tables only. */
|
|
27
|
+
injectRoutes(router: express.Router): void;
|
|
24
28
|
/** Return the typed database API only while the plugin is active. */
|
|
25
29
|
exports(): DatabaseExports;
|
|
26
30
|
/** Stop new work, wait for setup, and close the owned pool exactly once. */
|
|
27
31
|
shutdown(): Promise<void>;
|
|
32
|
+
/** Trace one generated read with allowlisted, low-cardinality attributes. */
|
|
33
|
+
private runReadSpan;
|
|
28
34
|
}
|
|
29
35
|
/** Create a typed database plugin registration for a finalized schema. */
|
|
30
36
|
declare function database<TSchema extends Schema>(config: IDatabaseConfig<TSchema>): {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"database.d.ts","names":[],"sources":["../../../src/plugins/database/database.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"database.d.ts","names":[],"sources":["../../../src/plugins/database/database.ts"],"mappings":";;;;;;;;;;;;;cAyBa,cAAA,iBAA+B,MAAA,UAAgB,MAAA,CAC1D,eAAA,CAAgB,OAAA;;SAGT,QAAA,EAAuB,cAAA;EAAA,UACZ,MAAA,EAAQ,eAAA,CAAgB,OAAA;EAAA,QAClC,KAAA;EAAA,QACA,YAAA;EAAA,QACA,QAAA;EAAA,QACA,eAAA;EAAA,QACA,aAAA;cAEI,MAAA,EAAQ,eAAA,CAAgB,OAAA;EARN;EAkBxB,KAAA,CAAA,GAAS,OAAA;EAjBW;EA2C1B,YAAA,CAAa,MAAA,EAAQ,OAAA,CAAQ,MAAA;EApCT;EA+EpB,OAAA,CAAA,GAOO,eAAA;EAlDc;EAsDf,QAAA,CAAA,GAAY,OAAA;EAAA;EAAA,QAoBV,WAAA;AAAA;;iBA+BM,QAAA,iBAAyB,MAAA,CAAA,CACvC,MAAA,EAAQ,eAAA,CAAgB,OAAA;UAGe,iBAAA,CACnC,gBAAA,EACA,cAAA,CAAe,OAAA"}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Plugin } from "../../plugin/plugin.js";
|
|
2
2
|
import "../../plugin/index.js";
|
|
3
|
-
import { DatabasePluginError } from "../../database/errors.js";
|
|
3
|
+
import { DatabasePluginError, databaseSetupFailed } from "../../database/errors.js";
|
|
4
|
+
import { compileCrudTables } from "./crud/contract.js";
|
|
5
|
+
import { resolveExposedTables } from "./crud/exposure.js";
|
|
6
|
+
import { createDetailHandler, createListHandler, readRouteOutcome } from "./crud/routes.js";
|
|
4
7
|
import { createDatabaseState } from "./lifecycle.js";
|
|
5
8
|
import manifest_default from "./manifest.js";
|
|
6
9
|
|
|
@@ -13,24 +16,57 @@ var DatabasePlugin = class extends Plugin {
|
|
|
13
16
|
setupPromise = null;
|
|
14
17
|
draining = false;
|
|
15
18
|
shutdownPromise = null;
|
|
19
|
+
exposedTables = [];
|
|
16
20
|
constructor(config) {
|
|
17
21
|
super({ schema: config.schema });
|
|
18
|
-
this.config = {
|
|
22
|
+
this.config = {
|
|
23
|
+
schema: config.schema,
|
|
24
|
+
crudRoutes: config.crudRoutes,
|
|
25
|
+
hooks: config.hooks
|
|
26
|
+
};
|
|
19
27
|
}
|
|
20
28
|
/** Build and verify one candidate state before publishing its exports. */
|
|
21
29
|
async setup() {
|
|
22
|
-
if (this.draining || this.state) throw
|
|
30
|
+
if (this.draining || this.state) throw databaseSetupFailed();
|
|
23
31
|
if (!this.setupPromise) this.setupPromise = (async () => {
|
|
32
|
+
this.exposedTables = resolveExposedTables(this.config.crudRoutes, Object.keys(this.config.schema.$tables));
|
|
24
33
|
const candidate = await createDatabaseState(this.config.schema, (operation, options) => this.execute(operation, options));
|
|
25
34
|
if (this.draining) {
|
|
26
35
|
candidate.deactivate();
|
|
27
36
|
await candidate.pool.end().catch(() => void 0);
|
|
28
|
-
throw
|
|
37
|
+
throw databaseSetupFailed();
|
|
29
38
|
}
|
|
30
39
|
this.state = candidate;
|
|
31
40
|
})();
|
|
32
41
|
return this.setupPromise;
|
|
33
42
|
}
|
|
43
|
+
/** Register generated reads for explicitly exposed tables only. */
|
|
44
|
+
injectRoutes(router) {
|
|
45
|
+
if (this.exposedTables.length === 0) return;
|
|
46
|
+
const tables = compileCrudTables(Object.fromEntries(this.exposedTables.map((name) => [name, this.config.schema.$tables[name]])));
|
|
47
|
+
const serializers = this.config.hooks;
|
|
48
|
+
const entities = () => this.exports();
|
|
49
|
+
for (const table of tables.values()) {
|
|
50
|
+
const deps = {
|
|
51
|
+
table,
|
|
52
|
+
entity: () => entities()[table.name],
|
|
53
|
+
serialize: serializers?.[table.name]?.serialize,
|
|
54
|
+
runRouteSpan: (operation, route, run) => this.runReadSpan(table.name, operation, route, run)
|
|
55
|
+
};
|
|
56
|
+
this.route(router, {
|
|
57
|
+
name: `${table.name}.list`,
|
|
58
|
+
method: "get",
|
|
59
|
+
path: `/${table.name}`,
|
|
60
|
+
handler: createListHandler(deps)
|
|
61
|
+
});
|
|
62
|
+
if (table.primaryKey) this.route(router, {
|
|
63
|
+
name: `${table.name}.detail`,
|
|
64
|
+
method: "get",
|
|
65
|
+
path: `/${table.name}/:id`,
|
|
66
|
+
handler: createDetailHandler(deps)
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
34
70
|
/** Return the typed database API only while the plugin is active. */
|
|
35
71
|
exports() {
|
|
36
72
|
if (!this.state || this.draining) throw new DatabasePluginError("INTERNAL", "read");
|
|
@@ -53,6 +89,24 @@ var DatabasePlugin = class extends Plugin {
|
|
|
53
89
|
})();
|
|
54
90
|
return this.shutdownPromise;
|
|
55
91
|
}
|
|
92
|
+
/** Trace one generated read with allowlisted, low-cardinality attributes. */
|
|
93
|
+
runReadSpan(table, operation, route, run) {
|
|
94
|
+
return this.telemetry.startActiveSpan("database.crud.route", { attributes: {
|
|
95
|
+
table_name: table,
|
|
96
|
+
operation,
|
|
97
|
+
"http.route": `/api/${this.name}${route}`
|
|
98
|
+
} }, async (span) => {
|
|
99
|
+
try {
|
|
100
|
+
await run();
|
|
101
|
+
span.setAttribute("outcome", "success");
|
|
102
|
+
} catch (error) {
|
|
103
|
+
span.setAttribute("outcome", readRouteOutcome(error));
|
|
104
|
+
throw error;
|
|
105
|
+
} finally {
|
|
106
|
+
span.end();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
56
110
|
};
|
|
57
111
|
/** Create a typed database plugin registration for a finalized schema. */
|
|
58
112
|
function database(config) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"database.js","names":["manifest"],"sources":["../../../src/plugins/database/database.ts"],"sourcesContent":["import type { BasePluginConfig, PluginConstructor } from \"shared\";\n\nimport {
|
|
1
|
+
{"version":3,"file":"database.js","names":["manifest"],"sources":["../../../src/plugins/database/database.ts"],"sourcesContent":["import type express from \"express\";\nimport type { BasePluginConfig, PluginConstructor } from \"shared\";\n\nimport {\n DatabasePluginError,\n databaseSetupFailed,\n} from \"../../database/errors\";\nimport type { Schema } from \"../../database/schema-builder\";\nimport { Plugin } from \"../../plugin\";\nimport type { PluginManifest } from \"../../registry\";\nimport { compileCrudTables } from \"./crud/contract\";\nimport { resolveExposedTables } from \"./crud/exposure\";\nimport {\n type CrudReadEntity,\n createDetailHandler,\n createListHandler,\n type ReadRouteDeps,\n readRouteOutcome,\n} from \"./crud/routes\";\nimport type { DatabaseExports } from \"./entity-types\";\nimport { createDatabaseState, type DatabaseState } from \"./lifecycle\";\nimport manifest from \"./manifest.json\";\nimport type { IDatabaseConfig, ReadSerializer } from \"./types\";\n\n/** Schema-driven database plugin */\nexport class DatabasePlugin<TSchema extends Schema> extends Plugin<\n IDatabaseConfig<TSchema>\n> {\n /** Plugin metadata and required PostgreSQL resource. */\n static manifest = manifest as PluginManifest<\"database\">;\n declare protected config: IDatabaseConfig<TSchema>;\n private state: DatabaseState | null = null;\n private setupPromise: Promise<void> | null = null;\n private draining = false;\n private shutdownPromise: Promise<void> | null = null;\n private exposedTables: string[] = [];\n\n constructor(config: IDatabaseConfig<TSchema>) {\n super({ schema: config.schema });\n this.config = {\n schema: config.schema,\n crudRoutes: config.crudRoutes,\n hooks: config.hooks,\n };\n }\n\n /** Build and verify one candidate state before publishing its exports. */\n async setup(): Promise<void> {\n if (this.draining || this.state) throw databaseSetupFailed();\n if (!this.setupPromise) {\n const attempt = (async () => {\n this.exposedTables = resolveExposedTables(\n this.config.crudRoutes,\n Object.keys(this.config.schema.$tables),\n );\n const candidate = await createDatabaseState(\n this.config.schema,\n (operation, options) => this.execute(operation, options),\n );\n if (this.draining) {\n // Setup may finish while shutdown is waiting; never publish that state.\n candidate.deactivate();\n await candidate.pool.end().catch(() => undefined);\n throw databaseSetupFailed();\n }\n this.state = candidate;\n })();\n this.setupPromise = attempt;\n }\n return this.setupPromise;\n }\n\n /** Register generated reads for explicitly exposed tables only. */\n injectRoutes(router: express.Router): void {\n if (this.exposedTables.length === 0) return;\n const tables = compileCrudTables(\n Object.fromEntries(\n this.exposedTables.map((name) => [\n name,\n this.config.schema.$tables[name],\n ]),\n ),\n );\n const serializers = this.config.hooks as\n | Record<string, { serialize?: ReadSerializer } | undefined>\n | undefined;\n // Every exposed name is a declared table, so its export is an entity client.\n const entities = () =>\n this.exports() as unknown as Record<string, CrudReadEntity>;\n\n for (const table of tables.values()) {\n const deps: ReadRouteDeps = {\n table,\n entity: () => entities()[table.name],\n serialize: serializers?.[table.name]?.serialize,\n runRouteSpan: (operation, route, run) =>\n this.runReadSpan(table.name, operation, route, run),\n };\n this.route(router, {\n name: `${table.name}.list`,\n method: \"get\",\n path: `/${table.name}`,\n handler: createListHandler(deps),\n });\n if (table.primaryKey) {\n this.route(router, {\n name: `${table.name}.detail`,\n method: \"get\",\n path: `/${table.name}/:id`,\n handler: createDetailHandler(deps),\n });\n }\n }\n }\n\n /** Return the typed database API only while the plugin is active. */\n exports() {\n if (!this.state || this.draining)\n throw new DatabasePluginError(\"INTERNAL\", \"read\");\n // AppKit binds exported functions onto this object on every access.\n return Object.assign(\n Object.create(null),\n this.state.exports,\n ) as DatabaseExports;\n }\n\n /** Stop new work, wait for setup, and close the owned pool exactly once. */\n async shutdown(): Promise<void> {\n if (this.shutdownPromise) return this.shutdownPromise;\n this.draining = true;\n this.shutdownPromise = (async () => {\n await this.setupPromise?.catch(() => undefined);\n const state = this.state;\n state?.deactivate();\n this.state = null;\n if (state) {\n try {\n await state.pool.end();\n } catch {\n throw new DatabasePluginError(\"INTERNAL\", \"shutdown\");\n }\n }\n })();\n return this.shutdownPromise;\n }\n\n /** Trace one generated read with allowlisted, low-cardinality attributes. */\n private runReadSpan(\n table: string,\n operation: \"list\" | \"detail\",\n route: string,\n run: () => Promise<void>,\n ): Promise<void> {\n return this.telemetry.startActiveSpan(\n \"database.crud.route\",\n {\n attributes: {\n table_name: table,\n operation,\n \"http.route\": `/api/${this.name}${route}`,\n },\n },\n async (span) => {\n try {\n await run();\n span.setAttribute(\"outcome\", \"success\");\n } catch (error) {\n span.setAttribute(\"outcome\", readRouteOutcome(error));\n throw error;\n } finally {\n span.end();\n }\n },\n );\n }\n}\n\n/** Create a typed database plugin registration for a finalized schema. */\nexport function database<TSchema extends Schema>(\n config: IDatabaseConfig<TSchema>,\n) {\n return {\n plugin: DatabasePlugin as unknown as PluginConstructor<\n BasePluginConfig,\n DatabasePlugin<TSchema>\n >,\n config,\n name: \"database\" as const,\n };\n}\n"],"mappings":"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"}
|
|
@@ -6,7 +6,34 @@ const databaseWriteDefaults = {
|
|
|
6
6
|
cache: { enabled: false },
|
|
7
7
|
retry: { enabled: false }
|
|
8
8
|
};
|
|
9
|
+
/**
|
|
10
|
+
* Server-side `statement_timeout` applied to every pooled connection (ms).
|
|
11
|
+
* PostgreSQL cancels the statement itself, so an expensive caller-supplied
|
|
12
|
+
* filter (for example a leading-wildcard `ilike`) cannot hold its pooled
|
|
13
|
+
* connection past the deadline.
|
|
14
|
+
*/
|
|
15
|
+
const STATEMENT_TIMEOUT_MS = 3e4;
|
|
16
|
+
/** Max encoded size of a generated read query string. */
|
|
17
|
+
const MAX_QUERY_BYTES = 8 * 1024;
|
|
18
|
+
/** Max nesting depth of `and`/`or` groups in one generated filter. */
|
|
19
|
+
const MAX_WHERE_DEPTH = 5;
|
|
20
|
+
/** Max column conditions across one generated filter. */
|
|
21
|
+
const MAX_WHERE_CONDITIONS = 50;
|
|
22
|
+
/** Max members in one `and`/`or` group. */
|
|
23
|
+
const MAX_GROUP_ITEMS = 20;
|
|
24
|
+
/** Max columns one generated `order` may name. */
|
|
25
|
+
const MAX_ORDER_FIELDS = 10;
|
|
26
|
+
/** Max accepted generated `offset`. */
|
|
27
|
+
const MAX_OFFSET = 1e4;
|
|
28
|
+
/** Max rows one generated read may materialize across its include tree. */
|
|
29
|
+
const MAX_MATERIALIZED_NODES = 1e4;
|
|
30
|
+
/** Max nesting depth of read-serializer output. */
|
|
31
|
+
const MAX_SERIALIZED_DEPTH = 32;
|
|
32
|
+
/** Max node count of read-serializer output. */
|
|
33
|
+
const MAX_SERIALIZED_NODES = 1e4;
|
|
34
|
+
/** Max UTF-8 byte length of one generated read response body. */
|
|
35
|
+
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
9
36
|
|
|
10
37
|
//#endregion
|
|
11
|
-
export { databaseReadDefaults, databaseWriteDefaults };
|
|
38
|
+
export { MAX_GROUP_ITEMS, MAX_MATERIALIZED_NODES, MAX_OFFSET, MAX_ORDER_FIELDS, MAX_QUERY_BYTES, MAX_RESPONSE_BYTES, MAX_SERIALIZED_DEPTH, MAX_SERIALIZED_NODES, MAX_WHERE_CONDITIONS, MAX_WHERE_DEPTH, STATEMENT_TIMEOUT_MS, databaseReadDefaults, databaseWriteDefaults };
|
|
12
39
|
//# sourceMappingURL=defaults.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"defaults.js","names":[],"sources":["../../../src/plugins/database/defaults.ts"],"sourcesContent":["import type { PluginExecuteConfig } from \"shared\";\n\n/** Default interceptor policy for bounded reads. */\nexport const databaseReadDefaults: PluginExecuteConfig = {\n retry: { enabled: false },\n};\n\n/** Default interceptor policy for mutations. */\nexport const databaseWriteDefaults: PluginExecuteConfig = {\n cache: { enabled: false },\n retry: { enabled: false },\n};\n"],"mappings":";;AAGA,MAAa,uBAA4C,EACvD,OAAO,EAAE,SAAS,OAAO,EAC1B;;AAGD,MAAa,wBAA6C;CACxD,OAAO,EAAE,SAAS,OAAO;CACzB,OAAO,EAAE,SAAS,OAAO;CAC1B"}
|
|
1
|
+
{"version":3,"file":"defaults.js","names":[],"sources":["../../../src/plugins/database/defaults.ts"],"sourcesContent":["import type { PluginExecuteConfig } from \"shared\";\n\n/** Default interceptor policy for bounded reads. */\nexport const databaseReadDefaults: PluginExecuteConfig = {\n retry: { enabled: false },\n};\n\n/** Default interceptor policy for mutations. */\nexport const databaseWriteDefaults: PluginExecuteConfig = {\n cache: { enabled: false },\n retry: { enabled: false },\n};\n\n// Connection posture: how long any one statement may hold a pooled connection.\n/**\n * Server-side `statement_timeout` applied to every pooled connection (ms).\n * PostgreSQL cancels the statement itself, so an expensive caller-supplied\n * filter (for example a leading-wildcard `ilike`) cannot hold its pooled\n * connection past the deadline.\n */\nexport const STATEMENT_TIMEOUT_MS = 30_000;\n\n// Generated-read limits. The wire caps a typed caller shares with HTTP live in\n// `database/contract`; these bound only what an untrusted request may ask for.\n\n// Request decoding: rejected before any database work is scheduled.\n/** Max encoded size of a generated read query string. */\nexport const MAX_QUERY_BYTES = 8 * 1024;\n/** Max nesting depth of `and`/`or` groups in one generated filter. */\nexport const MAX_WHERE_DEPTH = 5;\n/** Max column conditions across one generated filter. */\nexport const MAX_WHERE_CONDITIONS = 50;\n/** Max members in one `and`/`or` group. */\nexport const MAX_GROUP_ITEMS = 20;\n/** Max columns one generated `order` may name. */\nexport const MAX_ORDER_FIELDS = 10;\n/** Max accepted generated `offset`. */\nexport const MAX_OFFSET = 10_000;\n/** Max rows one generated read may materialize across its include tree. */\nexport const MAX_MATERIALIZED_NODES = 10_000;\n\n// Response shaping: bounds trusted output on its way to the wire.\n/** Max nesting depth of read-serializer output. */\nexport const MAX_SERIALIZED_DEPTH = 32;\n/** Max node count of read-serializer output. */\nexport const MAX_SERIALIZED_NODES = 10_000;\n/** Max UTF-8 byte length of one generated read response body. */\nexport const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;\n"],"mappings":";;AAGA,MAAa,uBAA4C,EACvD,OAAO,EAAE,SAAS,OAAO,EAC1B;;AAGD,MAAa,wBAA6C;CACxD,OAAO,EAAE,SAAS,OAAO;CACzB,OAAO,EAAE,SAAS,OAAO;CAC1B;;;;;;;AASD,MAAa,uBAAuB;;AAOpC,MAAa,kBAAkB,IAAI;;AAEnC,MAAa,kBAAkB;;AAE/B,MAAa,uBAAuB;;AAEpC,MAAa,kBAAkB;;AAE/B,MAAa,mBAAmB;;AAEhC,MAAa,aAAa;;AAE1B,MAAa,yBAAyB;;AAItC,MAAa,uBAAuB;;AAEpC,MAAa,uBAAuB;;AAEpC,MAAa,qBAAqB,IAAI,OAAO"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DatabasePluginError, classifyDatabaseError, databaseErrorFromStatus } from "../../database/errors.js";
|
|
2
|
-
import { andWhere, validateLimit, validateOffset } from "../../database/runtime/data-path.js";
|
|
3
2
|
import { databaseReadDefaults, databaseWriteDefaults } from "./defaults.js";
|
|
3
|
+
import { andWhere, validateLimit, validateOffset } from "../../database/runtime/data-path.js";
|
|
4
4
|
|
|
5
5
|
//#region src/plugins/database/entity-client.ts
|
|
6
6
|
/** Clone plain acyclic input so caller mutation cannot change a built query. */
|
|
@@ -17,7 +17,9 @@ type PublicRowOf<K extends EntityName> = PublicRowOfFor<DatabaseRegistry, K>;
|
|
|
17
17
|
type RelationTargetFor<Registry extends RegistryShape<Registry>, K extends EntityNameFor<Registry>, Relation extends keyof IncludesOfFor<Registry, K>> = IncludesOfFor<Registry, K>[Relation] extends {
|
|
18
18
|
to: infer Target extends EntityNameFor<Registry>;
|
|
19
19
|
} ? Target : never;
|
|
20
|
-
|
|
20
|
+
/** How many further relation edges may nest inside one include level. */
|
|
21
|
+
type RemainingEdges = 0 | 1;
|
|
22
|
+
type IncludeOptionsFor<Registry extends RegistryShape<Registry>, K extends EntityNameFor<Registry>, Many extends boolean, Remaining extends RemainingEdges = 0> = {
|
|
21
23
|
readonly select?: readonly (keyof RowOfFor<Registry, K> & string)[];
|
|
22
24
|
readonly where?: FiltersOfFor<Registry, K>;
|
|
23
25
|
readonly order?: Partial<Record<keyof RowOfFor<Registry, K> & string, OrderDirection>>;
|
|
@@ -25,13 +27,20 @@ type IncludeOptionsFor<Registry extends RegistryShape<Registry>, K extends Entit
|
|
|
25
27
|
readonly limit?: number;
|
|
26
28
|
} : {
|
|
27
29
|
readonly limit?: never;
|
|
30
|
+
}) & (Remaining extends 1 ? {
|
|
31
|
+
readonly include?: IncludeArgFor<Registry, K, 0>;
|
|
32
|
+
} : {
|
|
33
|
+
readonly include?: never;
|
|
28
34
|
});
|
|
29
|
-
type IncludeArgFor<Registry extends RegistryShape<Registry>, K extends EntityNameFor<Registry
|
|
35
|
+
type IncludeArgFor<Registry extends RegistryShape<Registry>, K extends EntityNameFor<Registry>, Remaining extends RemainingEdges = 1> = { readonly [Relation in keyof IncludesOfFor<Registry, K>]?: boolean | IncludeOptionsFor<Registry, RelationTargetFor<Registry, K, Relation>, IncludesOfFor<Registry, K>[Relation] extends {
|
|
30
36
|
many: infer Many extends boolean;
|
|
31
|
-
} ? Many : false> };
|
|
32
|
-
type
|
|
37
|
+
} ? Many : false, Remaining> };
|
|
38
|
+
type SelectedRowFor<Registry extends RegistryShape<Registry>, K extends EntityNameFor<Registry>, Config> = Config extends {
|
|
33
39
|
readonly select: infer Columns extends readonly (keyof RowOfFor<Registry, K> & string)[];
|
|
34
40
|
} ? Pick<RowOfFor<Registry, K>, Columns[number]> : PublicRowOfFor<Registry, K>;
|
|
41
|
+
type IncludedRowFor<Registry extends RegistryShape<Registry>, K extends EntityNameFor<Registry>, Config> = Config extends {
|
|
42
|
+
readonly include: infer Nested;
|
|
43
|
+
} ? SelectedRowFor<Registry, K, Config> & IncludedResultFor<Registry, K, Nested> : SelectedRowFor<Registry, K, Config>;
|
|
35
44
|
type IncludedResultFor<Registry extends RegistryShape<Registry>, K extends EntityNameFor<Registry>, I> = I extends Record<string, unknown> ? { [Relation in keyof I & keyof IncludesOfFor<Registry, K> as I[Relation] extends false ? never : Relation]: IncludesOfFor<Registry, K>[Relation] extends {
|
|
36
45
|
to: infer Target extends EntityNameFor<Registry>;
|
|
37
46
|
many: infer Many;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entity-types.d.ts","names":[],"sources":["../../../src/plugins/database/entity-types.ts"],"mappings":";;;;;KAQK,aAAA,6BACkB,CAAA,GAAI,qBAAA;AAAA,KAGf,aAAA,WAAwB,aAAA,CAAc,CAAA,KAAM,OAAA,OAChD,CAAA;AAAA,KAGH,QAAA,WACO,aAAA,CAAc,CAAA,aACd,aAAA,CAAc,CAAA,KACtB,CAAA,CAAE,CAAA,UAAW,qBAAA,GAAwB,CAAA,CAAE,CAAA;AAAA,KACtC,QAAA,WACO,aAAA,CAAc,CAAA,aACd,aAAA,CAAc,CAAA,KACtB,QAAA,CAAS,CAAA,EAAG,CAAA;AAAA,KACX,cAAA,WACO,aAAA,CAAc,CAAA,aACd,aAAA,CAAc,CAAA,KACtB,QAAA,CAAS,CAAA,EAAG,CAAA;AAAA,KACX,WAAA,WACO,aAAA,CAAc,CAAA,aACd,aAAA,CAAc,CAAA,KACtB,QAAA,CAAS,CAAA,EAAG,CAAA;AAAA,KACX,WAAA,WACO,aAAA,CAAc,CAAA,aACd,aAAA,CAAc,CAAA,KACtB,QAAA,CAAS,CAAA,EAAG,CAAA;AAAA,KACX,YAAA,WACO,aAAA,CAAc,CAAA,aACd,aAAA,CAAc,CAAA,KACtB,QAAA,CAAS,CAAA,EAAG,CAAA;AAAA,KACX,aAAA,WACO,aAAA,CAAc,CAAA,aACd,aAAA,CAAc,CAAA,KACtB,QAAA,CAAS,CAAA,EAAG,CAAA;AAAA,KAEJ,UAAA,GAAa,aAAA,CAAc,gBAAA;AAAA,KAClC,WAAA,WAAsB,UAAA,IAAc,cAAA,CAAe,gBAAA,EAAkB,CAAA;AAAA,KAGrE,iBAAA,kBACc,aAAA,CAAc,QAAA,aACrB,aAAA,CAAc,QAAA,0BACD,aAAA,CAAc,QAAA,EAAU,CAAA,KAC7C,aAAA,CAAc,QAAA,EAAU,CAAA,EAAG,QAAA;EAC7B,EAAA,uBAAyB,aAAA,CAAc,QAAA;AAAA,IAErC,MAAA;AAAA,
|
|
1
|
+
{"version":3,"file":"entity-types.d.ts","names":[],"sources":["../../../src/plugins/database/entity-types.ts"],"mappings":"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"}
|
|
@@ -2,6 +2,7 @@ import { createLogger } from "../../logging/logger.js";
|
|
|
2
2
|
import { createLakebasePool } from "../../connectors/lakebase/index.js";
|
|
3
3
|
import { assertFinalizedSchema } from "../../database/schema-builder/define-schema.js";
|
|
4
4
|
import { DatabasePluginError, classifyDatabaseError } from "../../database/errors.js";
|
|
5
|
+
import { STATEMENT_TIMEOUT_MS } from "./defaults.js";
|
|
5
6
|
import { createDrizzleDataPath, createDrizzleDb } from "../../database/runtime/engine/drizzle-data-path.js";
|
|
6
7
|
import { EntityClient } from "./entity-client.js";
|
|
7
8
|
|
|
@@ -78,7 +79,7 @@ async function createDatabaseState(schema, execute) {
|
|
|
78
79
|
if (!active) throw new DatabasePluginError("INTERNAL", "runtime");
|
|
79
80
|
};
|
|
80
81
|
try {
|
|
81
|
-
pool = createLakebasePool();
|
|
82
|
+
pool = createLakebasePool({ statement_timeout: STATEMENT_TIMEOUT_MS });
|
|
82
83
|
const dataPath = createDrizzleDataPath(createDrizzleDb(pool, schema), schema, { columnAccess: "trusted" });
|
|
83
84
|
const exports = buildDatabaseExports({
|
|
84
85
|
schema,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lifecycle.js","names":[],"sources":["../../../src/plugins/database/lifecycle.ts"],"sourcesContent":["import { createLakebasePool } from \"../../connectors/lakebase\";\nimport {\n classifyDatabaseError,\n DatabasePluginError,\n} from \"../../database/errors\";\nimport type { DataPath } from \"../../database/runtime\";\nimport {\n createDrizzleDataPath,\n createDrizzleDb,\n} from \"../../database/runtime/engine/drizzle-data-path\";\nimport type { Schema } from \"../../database/schema-builder\";\nimport { assertFinalizedSchema } from \"../../database/schema-builder/define-schema\";\nimport { createLogger } from \"../../logging/logger\";\nimport { EntityClient, type EntityExecute } from \"./entity-client\";\nimport type {\n DatabaseExports,\n SqlTag,\n TransactionClient,\n} from \"./entity-types\";\n\nconst logger = createLogger(\"database\");\n\n/** Resources owned by one successfully initialized plugin instance. */\nexport interface DatabaseState {\n readonly pool: ReturnType<typeof createLakebasePool>;\n readonly exports: DatabaseExports;\n readonly deactivate: () => void;\n}\n\ninterface ExportContext {\n readonly schema: Schema;\n readonly getDataPath: () => DataPath;\n readonly execute: EntityExecute;\n readonly assertActive: () => void;\n}\n\n/** Execute value-only SQL on the bound DataPath without per-statement retries. */\nfunction buildSql(context: ExportContext): SqlTag {\n return async <T = Record<string, unknown>>(\n strings: TemplateStringsArray,\n ...values: unknown[]\n ) => {\n try {\n context.assertActive();\n // DataPath accepts value interpolation only and keeps Drizzle private.\n return await context.getDataPath().raw<T>(strings, ...values);\n } catch (error) {\n throw classifyDatabaseError(error, \"read\");\n }\n };\n}\n\n/** Build entities and SQL bound to either the root pool or one transaction. */\nfunction buildTransactionClient(context: ExportContext): TransactionClient {\n const result: Record<string, unknown> = Object.create(null);\n for (const [name, table] of Object.entries(context.schema.$tables)) {\n result[name] = new EntityClient({\n table,\n getDataPath: context.getDataPath,\n execute: context.execute,\n assertActive: context.assertActive,\n });\n }\n result.sql = buildSql(context);\n return result as TransactionClient;\n}\n\n/** Add the transaction entry point to the root database surface. */\nfunction buildDatabaseExports(context: ExportContext): DatabaseExports {\n const result = buildTransactionClient(context) as DatabaseExports;\n result.transaction = async <T>(\n callback: (tx: TransactionClient) => Promise<T>,\n ) => {\n try {\n context.assertActive();\n return await context.getDataPath().transaction(async (txDataPath) => {\n let active = true;\n const assertTransactionActive = () => {\n context.assertActive();\n if (!active) throw new DatabasePluginError(\"INTERNAL\", \"transaction\");\n };\n // The outer transaction owns execution; inner clients use its connection.\n const directExecute: EntityExecute = async (operation) => ({\n ok: true,\n data: await operation(),\n });\n const tx = buildTransactionClient({\n ...context,\n getDataPath: () => txDataPath,\n execute: directExecute,\n assertActive: assertTransactionActive,\n });\n try {\n return await callback(tx);\n } finally {\n // Captured clients must not outlive the transaction callback.\n active = false;\n }\n });\n } catch (error) {\n throw classifyDatabaseError(error, \"transaction\");\n }\n };\n return result;\n}\n\n/** Validate the schema, create one pool-backed API, and verify connectivity. */\nexport async function createDatabaseState<TSchema extends Schema>(\n schema: TSchema,\n execute: EntityExecute,\n): Promise<DatabaseState> {\n try {\n assertFinalizedSchema(schema);\n } catch (error) {\n logger.error(\"Database schema failed validation: %O\", error);\n throw new DatabasePluginError(\"SETUP_FAILED\", \"setup\");\n }\n\n let active = true;\n let pool: ReturnType<typeof createLakebasePool> | undefined;\n const assertActive = () => {\n if (!active) throw new DatabasePluginError(\"INTERNAL\", \"runtime\");\n };\n try {\n pool = createLakebasePool();\n const db = createDrizzleDb(pool, schema);\n const dataPath = createDrizzleDataPath(db, schema, {\n columnAccess: \"trusted\",\n });\n const exports = buildDatabaseExports({\n schema,\n getDataPath: () => dataPath,\n execute,\n assertActive,\n });\n // Do not publish exports until an authenticated statement succeeds.\n await dataPath.raw`select 1`;\n return {\n pool,\n exports,\n deactivate: () => {\n active = false;\n },\n };\n } catch (error) {\n logger.error(\"Database setup failed: %O\", error);\n active = false;\n await pool?.end().catch(() => undefined);\n throw new DatabasePluginError(\"SETUP_FAILED\", \"setup\");\n }\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"lifecycle.js","names":[],"sources":["../../../src/plugins/database/lifecycle.ts"],"sourcesContent":["import { createLakebasePool } from \"../../connectors/lakebase\";\nimport {\n classifyDatabaseError,\n DatabasePluginError,\n} from \"../../database/errors\";\nimport type { DataPath } from \"../../database/runtime\";\nimport {\n createDrizzleDataPath,\n createDrizzleDb,\n} from \"../../database/runtime/engine/drizzle-data-path\";\nimport type { Schema } from \"../../database/schema-builder\";\nimport { assertFinalizedSchema } from \"../../database/schema-builder/define-schema\";\nimport { createLogger } from \"../../logging/logger\";\nimport { STATEMENT_TIMEOUT_MS } from \"./defaults\";\nimport { EntityClient, type EntityExecute } from \"./entity-client\";\nimport type {\n DatabaseExports,\n SqlTag,\n TransactionClient,\n} from \"./entity-types\";\n\nconst logger = createLogger(\"database\");\n\n/** Resources owned by one successfully initialized plugin instance. */\nexport interface DatabaseState {\n readonly pool: ReturnType<typeof createLakebasePool>;\n readonly exports: DatabaseExports;\n readonly deactivate: () => void;\n}\n\ninterface ExportContext {\n readonly schema: Schema;\n readonly getDataPath: () => DataPath;\n readonly execute: EntityExecute;\n readonly assertActive: () => void;\n}\n\n/** Execute value-only SQL on the bound DataPath without per-statement retries. */\nfunction buildSql(context: ExportContext): SqlTag {\n return async <T = Record<string, unknown>>(\n strings: TemplateStringsArray,\n ...values: unknown[]\n ) => {\n try {\n context.assertActive();\n // DataPath accepts value interpolation only and keeps Drizzle private.\n return await context.getDataPath().raw<T>(strings, ...values);\n } catch (error) {\n throw classifyDatabaseError(error, \"read\");\n }\n };\n}\n\n/** Build entities and SQL bound to either the root pool or one transaction. */\nfunction buildTransactionClient(context: ExportContext): TransactionClient {\n const result: Record<string, unknown> = Object.create(null);\n for (const [name, table] of Object.entries(context.schema.$tables)) {\n result[name] = new EntityClient({\n table,\n getDataPath: context.getDataPath,\n execute: context.execute,\n assertActive: context.assertActive,\n });\n }\n result.sql = buildSql(context);\n return result as TransactionClient;\n}\n\n/** Add the transaction entry point to the root database surface. */\nfunction buildDatabaseExports(context: ExportContext): DatabaseExports {\n const result = buildTransactionClient(context) as DatabaseExports;\n result.transaction = async <T>(\n callback: (tx: TransactionClient) => Promise<T>,\n ) => {\n try {\n context.assertActive();\n return await context.getDataPath().transaction(async (txDataPath) => {\n let active = true;\n const assertTransactionActive = () => {\n context.assertActive();\n if (!active) throw new DatabasePluginError(\"INTERNAL\", \"transaction\");\n };\n // The outer transaction owns execution; inner clients use its connection.\n const directExecute: EntityExecute = async (operation) => ({\n ok: true,\n data: await operation(),\n });\n const tx = buildTransactionClient({\n ...context,\n getDataPath: () => txDataPath,\n execute: directExecute,\n assertActive: assertTransactionActive,\n });\n try {\n return await callback(tx);\n } finally {\n // Captured clients must not outlive the transaction callback.\n active = false;\n }\n });\n } catch (error) {\n throw classifyDatabaseError(error, \"transaction\");\n }\n };\n return result;\n}\n\n/** Validate the schema, create one pool-backed API, and verify connectivity. */\nexport async function createDatabaseState<TSchema extends Schema>(\n schema: TSchema,\n execute: EntityExecute,\n): Promise<DatabaseState> {\n try {\n assertFinalizedSchema(schema);\n } catch (error) {\n logger.error(\"Database schema failed validation: %O\", error);\n throw new DatabasePluginError(\"SETUP_FAILED\", \"setup\");\n }\n\n let active = true;\n let pool: ReturnType<typeof createLakebasePool> | undefined;\n const assertActive = () => {\n if (!active) throw new DatabasePluginError(\"INTERNAL\", \"runtime\");\n };\n try {\n pool = createLakebasePool({ statement_timeout: STATEMENT_TIMEOUT_MS });\n const db = createDrizzleDb(pool, schema);\n const dataPath = createDrizzleDataPath(db, schema, {\n columnAccess: \"trusted\",\n });\n const exports = buildDatabaseExports({\n schema,\n getDataPath: () => dataPath,\n execute,\n assertActive,\n });\n // Do not publish exports until an authenticated statement succeeds.\n await dataPath.raw`select 1`;\n return {\n pool,\n exports,\n deactivate: () => {\n active = false;\n },\n };\n } catch (error) {\n logger.error(\"Database setup failed: %O\", error);\n active = false;\n await pool?.end().catch(() => undefined);\n throw new DatabasePluginError(\"SETUP_FAILED\", \"setup\");\n }\n}\n"],"mappings":";;;;;;;;;AAqBA,MAAM,SAAS,aAAa,WAAW;;AAiBvC,SAAS,SAAS,SAAgC;AAChD,QAAO,OACL,SACA,GAAG,WACA;AACH,MAAI;AACF,WAAQ,cAAc;AAEtB,UAAO,MAAM,QAAQ,aAAa,CAAC,IAAO,SAAS,GAAG,OAAO;WACtD,OAAO;AACd,SAAM,sBAAsB,OAAO,OAAO;;;;;AAMhD,SAAS,uBAAuB,SAA2C;CACzE,MAAM,SAAkC,OAAO,OAAO,KAAK;AAC3D,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,QAAQ,OAAO,QAAQ,CAChE,QAAO,QAAQ,IAAI,aAAa;EAC9B;EACA,aAAa,QAAQ;EACrB,SAAS,QAAQ;EACjB,cAAc,QAAQ;EACvB,CAAC;AAEJ,QAAO,MAAM,SAAS,QAAQ;AAC9B,QAAO;;;AAIT,SAAS,qBAAqB,SAAyC;CACrE,MAAM,SAAS,uBAAuB,QAAQ;AAC9C,QAAO,cAAc,OACnB,aACG;AACH,MAAI;AACF,WAAQ,cAAc;AACtB,UAAO,MAAM,QAAQ,aAAa,CAAC,YAAY,OAAO,eAAe;IACnE,IAAI,SAAS;IACb,MAAM,gCAAgC;AACpC,aAAQ,cAAc;AACtB,SAAI,CAAC,OAAQ,OAAM,IAAI,oBAAoB,YAAY,cAAc;;IAGvE,MAAM,gBAA+B,OAAO,eAAe;KACzD,IAAI;KACJ,MAAM,MAAM,WAAW;KACxB;IACD,MAAM,KAAK,uBAAuB;KAChC,GAAG;KACH,mBAAmB;KACnB,SAAS;KACT,cAAc;KACf,CAAC;AACF,QAAI;AACF,YAAO,MAAM,SAAS,GAAG;cACjB;AAER,cAAS;;KAEX;WACK,OAAO;AACd,SAAM,sBAAsB,OAAO,cAAc;;;AAGrD,QAAO;;;AAIT,eAAsB,oBACpB,QACA,SACwB;AACxB,KAAI;AACF,wBAAsB,OAAO;UACtB,OAAO;AACd,SAAO,MAAM,yCAAyC,MAAM;AAC5D,QAAM,IAAI,oBAAoB,gBAAgB,QAAQ;;CAGxD,IAAI,SAAS;CACb,IAAI;CACJ,MAAM,qBAAqB;AACzB,MAAI,CAAC,OAAQ,OAAM,IAAI,oBAAoB,YAAY,UAAU;;AAEnE,KAAI;AACF,SAAO,mBAAmB,EAAE,mBAAmB,sBAAsB,CAAC;EAEtE,MAAM,WAAW,sBADN,gBAAgB,MAAM,OAAO,EACG,QAAQ,EACjD,cAAc,WACf,CAAC;EACF,MAAM,UAAU,qBAAqB;GACnC;GACA,mBAAmB;GACnB;GACA;GACD,CAAC;AAEF,QAAM,SAAS,GAAG;AAClB,SAAO;GACL;GACA;GACA,kBAAkB;AAChB,aAAS;;GAEZ;UACM,OAAO;AACd,SAAO,MAAM,6BAA6B,MAAM;AAChD,WAAS;AACT,QAAM,MAAM,KAAK,CAAC,YAAY,OAAU;AACxC,QAAM,IAAI,oBAAoB,gBAAgB,QAAQ"}
|
|
@@ -1,9 +1,41 @@
|
|
|
1
1
|
import { Schema } from "../../database/schema-builder/types.js";
|
|
2
2
|
|
|
3
3
|
//#region src/plugins/database/types.d.ts
|
|
4
|
+
/** Table names declared by one finalized schema. */
|
|
5
|
+
type SchemaTableName<TSchema extends Schema> = Extract<keyof TSchema["$tables"], string>;
|
|
6
|
+
/**
|
|
7
|
+
* Generated read exposure: off by default, every table, or an explicit list.
|
|
8
|
+
*
|
|
9
|
+
* Reads run as the app's service principal and apply no per-user filter, so an
|
|
10
|
+
* enabled table is readable by anyone the app admits. Enabling a table also
|
|
11
|
+
* makes it includable from its neighbours; a relation whose target stays off
|
|
12
|
+
* cannot be included, which keeps one table's data behind one decision.
|
|
13
|
+
*
|
|
14
|
+
* Text filters accept caller-supplied `like`/`ilike` patterns; a server-side
|
|
15
|
+
* `statement_timeout` cancels a pattern that would otherwise hold its pooled
|
|
16
|
+
* connection to completion.
|
|
17
|
+
*/
|
|
18
|
+
type CrudRoutesConfig<TSchema extends Schema> = boolean | {
|
|
19
|
+
readonly tables: readonly SchemaTableName<TSchema>[];
|
|
20
|
+
};
|
|
21
|
+
/** Which entity and generated operation produced the row being shaped. */
|
|
22
|
+
interface ReadSerializerContext {
|
|
23
|
+
readonly entity: string;
|
|
24
|
+
readonly operation: "list" | "detail";
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Shape one already private-safe row before it reaches the wire. A `Promise`
|
|
28
|
+
* is not assignable to the return type, so an async callback fails to compile:
|
|
29
|
+
* serializers run inside the response path and must not add latency there.
|
|
30
|
+
*/
|
|
31
|
+
type ReadSerializer = (row: Record<string, unknown>, context: ReadSerializerContext) => Record<string, unknown>;
|
|
4
32
|
/** Configuration for one schema-bound DatabasePlugin instance. */
|
|
5
33
|
type IDatabaseConfig<TSchema extends Schema> = {
|
|
6
34
|
readonly schema: TSchema;
|
|
35
|
+
readonly crudRoutes?: CrudRoutesConfig<TSchema>;
|
|
36
|
+
readonly hooks?: { readonly [TTable in SchemaTableName<TSchema>]?: {
|
|
37
|
+
readonly serialize?: ReadSerializer;
|
|
38
|
+
} };
|
|
7
39
|
};
|
|
8
40
|
//#endregion
|
|
9
41
|
export { IDatabaseConfig };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/plugins/database/types.ts"],"mappings":";;;;KAGY,eAAA,iBAAgC,MAAA;EAAA,SACjC,MAAA,EAAQ,OAAA;AAAA"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/plugins/database/types.ts"],"mappings":";;;;KAGY,eAAA,iBAAgC,MAAA,IAAU,OAAA,OAC9C,OAAA;AADR;;;;;;;;;;;;AAAA,KAiBY,gBAAA,iBAAiC,MAAA;EAAA,SAE9B,MAAA,WAAiB,eAAA,CAAgB,OAAA;AAAA;;UAG/B,qBAAA;EAAA,SACN,MAAA;EAAA,SACA,SAAA;AAAA;;;;;;KAQC,cAAA,IACV,GAAA,EAAK,MAAA,mBACL,OAAA,EAAS,qBAAA,KACN,MAAA;;KAGO,eAAA,iBAAgC,MAAA;EAAA,SACjC,MAAA,EAAQ,OAAA;EAAA,SACR,UAAA,GAAa,gBAAA,CAAiB,OAAA;EAAA,SAC9B,KAAA,yBACa,eAAA,CAAgB,OAAA;IAAA,SACzB,SAAA,GAAY,cAAA;EAAA;AAAA"}
|
|
@@ -19,7 +19,7 @@ import { getRoutes, printRoutes } from "./utils.js";
|
|
|
19
19
|
import { StaticServer } from "./static-server.js";
|
|
20
20
|
import { ViteDevServer } from "./vite-dev-server.js";
|
|
21
21
|
import path from "node:path";
|
|
22
|
-
import
|
|
22
|
+
import fs from "node:fs";
|
|
23
23
|
import dotenv from "dotenv";
|
|
24
24
|
import express from "express";
|
|
25
25
|
import getPort, { portNumbers } from "get-port";
|
|
@@ -246,7 +246,7 @@ var ServerPlugin = class ServerPlugin extends Plugin {
|
|
|
246
246
|
const cwd = process.cwd();
|
|
247
247
|
for (const p of staticPaths) {
|
|
248
248
|
const fullPath = path.resolve(cwd, p);
|
|
249
|
-
if (
|
|
249
|
+
if (fs.existsSync(path.resolve(fullPath, "index.html"))) {
|
|
250
250
|
logger.debug("Static files: serving from %s", fullPath);
|
|
251
251
|
return fullPath;
|
|
252
252
|
}
|