@rebasepro/server 0.25.0-canary.ge06257e → 0.25.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/dist/{GCSStorageController-CAKa_EFW.js → GCSStorageController-C7ARrDAp.js} +3 -3
- package/dist/{GCSStorageController-CAKa_EFW.js.map → GCSStorageController-C7ARrDAp.js.map} +1 -1
- package/dist/{S3StorageController-Kt-LJpf3.js → S3StorageController-qevJ0pVE.js} +3 -3
- package/dist/{S3StorageController-Kt-LJpf3.js.map → S3StorageController-qevJ0pVE.js.map} +1 -1
- package/dist/api/errors.d.ts +6 -10
- package/dist/{auth-xk37vrHO.js → auth-CteGWxsL.js} +20 -20
- package/dist/{auth-xk37vrHO.js.map → auth-CteGWxsL.js.map} +1 -1
- package/dist/{backup-CpaJWXSc.js → backup-Dq0lyJ_D.js} +4 -4
- package/dist/{backup-CpaJWXSc.js.map → backup-Dq0lyJ_D.js.map} +1 -1
- package/dist/boot/boot.d.ts +10 -0
- package/dist/{cron-routes-CPIhdasM.js → cron-routes-Dhf82HzT.js} +3 -3
- package/dist/{cron-routes-CPIhdasM.js.map → cron-routes-Dhf82HzT.js.map} +1 -1
- package/dist/{errors-CHSJYpoy.js → errors-_ytoGi8A.js} +11 -18
- package/dist/errors-_ytoGi8A.js.map +1 -0
- package/dist/{function-routes-C0emkHtX.js → function-routes-DNV4RUF0.js} +2 -2
- package/dist/{function-routes-C0emkHtX.js.map → function-routes-DNV4RUF0.js.map} +1 -1
- package/dist/functions/index.js.map +1 -1
- package/dist/index.es.js +128 -41
- package/dist/index.es.js.map +1 -1
- package/dist/{logs-routes-BM2cDory.js → logs-routes-CelTiS7C.js} +2 -2
- package/dist/{logs-routes-BM2cDory.js.map → logs-routes-CelTiS7C.js.map} +1 -1
- package/dist/{openapi-generator-IKwDAQGO.js → openapi-generator-BamY6-ga.js} +2 -2
- package/dist/{openapi-generator-IKwDAQGO.js.map → openapi-generator-BamY6-ga.js.map} +1 -1
- package/dist/{query-parser-BnhugNwZ.js → query-parser-CGohjyWI.js} +3 -3
- package/dist/{query-parser-BnhugNwZ.js.map → query-parser-CGohjyWI.js.map} +1 -1
- package/dist/{request-timeout-LiGpCCP1.js → request-timeout-CRny5KD0.js} +2 -2
- package/dist/{request-timeout-LiGpCCP1.js.map → request-timeout-CRny5KD0.js.map} +1 -1
- package/dist/{schema-editor-routes-zjVKreVQ.js → schema-editor-routes-CnNGCayU.js} +2 -2
- package/dist/{schema-editor-routes-zjVKreVQ.js.map → schema-editor-routes-CnNGCayU.js.map} +1 -1
- package/dist/{soft-delete-params-uHxJoETb.js → soft-delete-params-BAgpmBvp.js} +2 -2
- package/dist/{soft-delete-params-uHxJoETb.js.map → soft-delete-params-BAgpmBvp.js.map} +1 -1
- package/dist/{sql-export-routes-D86u7N9i.js → sql-export-routes-BgTJ7K3S.js} +2 -2
- package/dist/{sql-export-routes-D86u7N9i.js.map → sql-export-routes-BgTJ7K3S.js.map} +1 -1
- package/dist/{upload-validation-pXcDy_EQ.js → upload-validation-I9cCUBIW.js} +2 -2
- package/dist/{upload-validation-pXcDy_EQ.js.map → upload-validation-I9cCUBIW.js.map} +1 -1
- package/package.json +5 -5
- package/dist/errors-CHSJYpoy.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-editor-routes-
|
|
1
|
+
{"version":3,"file":"schema-editor-routes-CnNGCayU.js","names":[],"sources":["../src/schema-edit/source-editor.ts","../src/api/schema-editor-routes.ts"],"sourcesContent":["import type { AstSchemaEditor } from \"../api/ast-schema-editor\";\n\n/**\n * The process's editor of the collection source files, one edit at a time.\n *\n * Two routers rewrite those files: the source-only editor\n * (`/api/schema-editor/*`) and live schema editing's `/apply`. Both go through\n * this one {@link AstSchemaEditor}, so the ts-morph project over the\n * collections directory is parsed once per process rather than once per\n * apply — 180–250 ms of blocked event loop at 200 collection files, on every\n * save. The editor re-reads the disk at the start of each edit\n * (`syncWithDisk`): a file changed since — by the IDE, by a commit being put\n * back — is refreshed, a new one is added, a deleted one is dropped.\n *\n * One project cannot take two edits at once. An edit holds unsaved changes in\n * memory across `await project.save()`, and a second edit arriving meanwhile\n * would refresh those files from disk under it, or save them with its own.\n * So every edit, from either router, waits in one queue for the edits before\n * it to settle — a refused one included — and anything an edit does with the\n * files it wrote (reading them back for a commit) belongs inside the same\n * {@link run}, before the next edit can touch them.\n */\nexport class SchemaSourceEditor {\n private settled: Promise<unknown> = Promise.resolve();\n\n constructor(private readonly editor: AstSchemaEditor) {\n }\n\n /** Run `edit` once every edit queued before it has finished. */\n run<T>(edit: (editor: AstSchemaEditor) => Promise<T>): Promise<T> {\n const result = this.settled.then(() => edit(this.editor));\n this.settled = result.then(() => undefined, () => undefined);\n return result;\n }\n}\n","import { Hono } from \"hono\";\nimport { z } from \"zod\";\nimport { collectionPatchProblems, isCollectionPatch } from \"@rebasepro/types\";\nimport { AstSchemaEditor } from \"./ast-schema-editor\";\nimport { SchemaSourceEditor } from \"../schema-edit/source-editor\";\nimport { ApiError, errorHandler } from \"./errors\";\nimport { HonoEnv } from \"./types\";\n\n/**\n * Rewriting collection source from the admin panel.\n *\n * Every refusal in `AstSchemaEditor` is written for the person who will read it\n * — \"Collection X has no file at …\", \"Relation Y has no target collection. Pick\n * one before saving.\" — and every one of them was a plain `Error`, which\n * `errorHandler` treats as an unexpected failure: 500, and a body that says\n * \"Internal Server Error\". The messages never left the server. They are 400s\n * now, so the panel can show what it was told.\n */\nfunction refusalsAsBadRequest<T>(run: () => Promise<T>): Promise<T> {\n return run().catch((error: unknown) => {\n if (error instanceof ApiError) throw error;\n throw ApiError.badRequest(\n error instanceof Error ? error.message : String(error),\n \"SCHEMA_EDIT_REFUSED\"\n );\n });\n}\n\n/** The identifier half of every payload here, checked once. */\nconst collectionIdSchema = z.string().min(1, \"`collectionId` is required\");\nconst propertyKeySchema = z.string().min(1, \"`propertyKey` is required\");\n\nconst propertySaveSchema = z.object({\n collectionId: collectionIdSchema,\n propertyKey: propertyKeySchema,\n propertyConfig: z.record(z.string(), z.unknown())\n});\nconst propertyDeleteSchema = z.object({\n collectionId: collectionIdSchema,\n propertyKey: propertyKeySchema\n});\nconst collectionSaveSchema = z.object({\n collectionId: collectionIdSchema,\n collectionData: z.record(z.string(), z.unknown()).optional(),\n /** What changed about an existing collection — see `collection_patch.ts`. */\n patch: z.array(z.unknown()).optional(),\n partial: z.boolean().optional()\n}).refine(\n save => (save.collectionData === undefined) !== (save.patch === undefined),\n { message: \"send `patch` (what changed) or `collectionData` (a new collection), not both\" }\n);\nconst collectionDeleteSchema = z.object({\n collectionId: collectionIdSchema\n});\n\n/** Parse a body against a schema, or 400 naming the field. */\nasync function body<S extends z.ZodType>(c: { req: { json: () => Promise<unknown> } }, schema: S): Promise<z.infer<S>> {\n const raw = await c.req.json().catch(() => undefined);\n const parsed = schema.safeParse(raw);\n if (!parsed.success) {\n throw ApiError.badRequest(\n parsed.error.issues.map(i => `${i.path.join(\".\") || \"body\"}: ${i.message}`).join(\"; \"),\n \"INVALID_INPUT\"\n );\n }\n return parsed.data;\n}\n\n/**\n * The editor for `collectionsDir`: one per process, shared by these routes\n * and live schema editing's `/apply`. See {@link SchemaSourceEditor}.\n *\n * Here rather than beside the class because building it loads `ts-morph`, an\n * optional peer dependency this module is imported to probe for.\n */\nexport function createSchemaSourceEditor(collectionsDir: string): SchemaSourceEditor {\n return new SchemaSourceEditor(new AstSchemaEditor(collectionsDir));\n}\n\nexport function createSchemaEditorRoutes(source: SchemaSourceEditor): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n router.onError(errorHandler);\n\n router.post(\"/property/save\", async (c) => {\n const { collectionId, propertyKey, propertyConfig } = await body(c, propertySaveSchema);\n await refusalsAsBadRequest(() => source.run(editor => editor.saveProperty(collectionId, propertyKey, propertyConfig)));\n return c.json({ success: true });\n });\n\n router.post(\"/property/delete\", async (c) => {\n const { collectionId, propertyKey } = await body(c, propertyDeleteSchema);\n await refusalsAsBadRequest(() => source.run(editor => editor.deleteProperty(collectionId, propertyKey)));\n return c.json({ success: true });\n });\n\n /**\n * `partial: true` means \"this payload is what changed\", not \"this is the\n * collection\". Without it a one-key patch — which is what adding a column\n * posts — is read as a whole-collection save and deletes everything it does\n * not mention, `securityRules` included. Absent, it defaults to a full save,\n * so an older panel keeps the behaviour it was written against.\n */\n router.post(\"/collection/save\", async (c) => {\n const { collectionId, collectionData, patch, partial } = await body(c, collectionSaveSchema);\n if (patch !== undefined && !isCollectionPatch(patch)) {\n throw ApiError.badRequest(collectionPatchProblems(patch).join(\" \"), \"INVALID_INPUT\");\n }\n await refusalsAsBadRequest(() => source.run(editor => patch\n ? editor.applyPatch(collectionId, patch)\n : editor.saveCollection(collectionId, collectionData ?? {}, { partial: partial === true })));\n return c.json({ success: true });\n });\n\n router.post(\"/collection/delete\", async (c) => {\n const { collectionId } = await body(c, collectionDeleteSchema);\n await refusalsAsBadRequest(() => source.run(editor => editor.deleteCollection(collectionId)));\n return c.json({ success: true });\n });\n\n return router;\n}\n"],"mappings":"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"}
|
|
@@ -4,7 +4,7 @@ globalThis.process ??= __rebaseProcess;
|
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
5
|
import { r as toSnakeCase } from "./strings.es-BeZWAodn.js";
|
|
6
6
|
import { W as isRelationalCollectionConfig } from "./src-D5Y3N-S4.js";
|
|
7
|
-
import { t as ApiError } from "./errors-
|
|
7
|
+
import { t as ApiError } from "./errors-_ytoGi8A.js";
|
|
8
8
|
//#region ../utils/dist/plurals.es.js
|
|
9
9
|
/**
|
|
10
10
|
* Returns the singular of an English word.
|
|
@@ -665,4 +665,4 @@ function parseHardDelete(raw) {
|
|
|
665
665
|
//#endregion
|
|
666
666
|
export { apiHiddenFieldNames as a, fieldKeyForColumn as c, isJunctionBackedRelation as d, isRelationRequired as f, toWireKey as g, resolveRelation as h, parseWithDeleted as i, findRelation as l, resolveCollectionRelations as m, HARD_DELETE_QUERY_PARAM as n, effectiveAccess as o, relationDeclaringProperty as p, parseHardDelete as r, restrictedFieldNames as s, DELETED_QUERY_PARAM as t, getTableName as u };
|
|
667
667
|
|
|
668
|
-
//# sourceMappingURL=soft-delete-params-
|
|
668
|
+
//# sourceMappingURL=soft-delete-params-BAgpmBvp.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"soft-delete-params-uHxJoETb.js","names":[],"sources":["../../utils/dist/plurals.es.js","../../utils/dist/names.es.js","../../common/src/util/resolve-relation.ts","../../common/src/util/relations.ts","../../common/src/collections/field-access.ts","../src/api/rest/soft-delete-params.ts"],"sourcesContent":["//#region src/plurals.ts\n/**\n* Returns the plural of an English word.\n*\n* @param {string} word\n* @param {number} [amount]\n* @returns {string}\n*/\nfunction plural(word, amount) {\n\tif (amount !== void 0 && amount === 1) return word;\n\tconst plurals = {\n\t\t\"(quiz)$\": \"$1zes\",\n\t\t\"^(ox)$\": \"$1en\",\n\t\t\"([m|l])ouse$\": \"$1ice\",\n\t\t\"(matr|vert|ind)ix|ex$\": \"$1ices\",\n\t\t\"(x|ch|ss|sh)$\": \"$1es\",\n\t\t\"([^aeiouy]|qu)y$\": \"$1ies\",\n\t\t\"(hive)$\": \"$1s\",\n\t\t\"(?:([^f])fe|([lr])f)$\": \"$1$2ves\",\n\t\t\"(shea|lea|loa|thie)f$\": \"$1ves\",\n\t\tsis$: \"ses\",\n\t\t\"([ti])um$\": \"$1a\",\n\t\t\"(tomat|potat|ech|her|vet)o$\": \"$1oes\",\n\t\t\"(bu)s$\": \"$1ses\",\n\t\t\"(alias)$\": \"$1es\",\n\t\t\"(octop)us$\": \"$1i\",\n\t\t\"(ax|test)is$\": \"$1es\",\n\t\t\"(us)$\": \"$1es\",\n\t\t\"([^s]+)$\": \"$1s\"\n\t};\n\tconst irregular = {\n\t\tmove: \"moves\",\n\t\tfoot: \"feet\",\n\t\tgoose: \"geese\",\n\t\tsex: \"sexes\",\n\t\tchild: \"children\",\n\t\tman: \"men\",\n\t\ttooth: \"teeth\",\n\t\tperson: \"people\"\n\t};\n\tif ([\n\t\t\"sheep\",\n\t\t\"fish\",\n\t\t\"deer\",\n\t\t\"moose\",\n\t\t\"series\",\n\t\t\"species\",\n\t\t\"money\",\n\t\t\"rice\",\n\t\t\"information\",\n\t\t\"equipment\",\n\t\t\"bison\",\n\t\t\"cod\",\n\t\t\"offspring\",\n\t\t\"pike\",\n\t\t\"salmon\",\n\t\t\"shrimp\",\n\t\t\"swine\",\n\t\t\"trout\",\n\t\t\"aircraft\",\n\t\t\"hovercraft\",\n\t\t\"spacecraft\",\n\t\t\"sugar\",\n\t\t\"tuna\",\n\t\t\"you\",\n\t\t\"wood\"\n\t].indexOf(word.toLowerCase()) >= 0) return word;\n\tfor (const w in irregular) {\n\t\tconst pattern = new RegExp(`${w}$`, \"i\");\n\t\tconst replace = irregular[w];\n\t\tif (pattern.test(word)) return word.replace(pattern, replace);\n\t}\n\tfor (const reg in plurals) {\n\t\tconst pattern = new RegExp(reg, \"i\");\n\t\tif (pattern.test(word)) return word.replace(pattern, plurals[reg]);\n\t}\n\treturn word;\n}\n/**\n* Returns the singular of an English word.\n*\n* @param {string} word\n* @param {number} [amount]\n* @returns {string}\n*/\nfunction singular(word, amount) {\n\tif (amount !== void 0 && amount !== 1) return word;\n\tconst singulars = {\n\t\t\"(quiz)zes$\": \"$1\",\n\t\t\"(matr)ices$\": \"$1ix\",\n\t\t\"(vert|ind)ices$\": \"$1ex\",\n\t\t\"^(ox)en$\": \"$1\",\n\t\t\"(alias)es$\": \"$1\",\n\t\t\"(octop|vir)i$\": \"$1us\",\n\t\t\"(cris|ax|test)es$\": \"$1is\",\n\t\t\"(shoe)s$\": \"$1\",\n\t\t\"(o)es$\": \"$1\",\n\t\t\"(bus)es$\": \"$1\",\n\t\t\"([m|l])ice$\": \"$1ouse\",\n\t\t\"(x|ch|ss|sh)es$\": \"$1\",\n\t\t\"(m)ovies$\": \"$1ovie\",\n\t\t\"(s)eries$\": \"$1eries\",\n\t\t\"([^aeiouy]|qu)ies$\": \"$1y\",\n\t\t\"([lr])ves$\": \"$1f\",\n\t\t\"(tive)s$\": \"$1\",\n\t\t\"(hive)s$\": \"$1\",\n\t\t\"(li|wi|kni)ves$\": \"$1fe\",\n\t\t\"(shea|loa|lea|thie)ves$\": \"$1f\",\n\t\t\"(^analy)ses$\": \"$1sis\",\n\t\t\"((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$\": \"$1$2sis\",\n\t\t\"([ti])a$\": \"$1um\",\n\t\t\"(n)ews$\": \"$1ews\",\n\t\t\"(h|bl)ouses$\": \"$1ouse\",\n\t\t\"(corpse)s$\": \"$1\",\n\t\t\"(us)es$\": \"$1\",\n\t\ts$: \"\"\n\t};\n\tconst irregular = {\n\t\tmove: \"moves\",\n\t\tfoot: \"feet\",\n\t\tgoose: \"geese\",\n\t\tsex: \"sexes\",\n\t\tchild: \"children\",\n\t\tman: \"men\",\n\t\ttooth: \"teeth\",\n\t\tperson: \"people\"\n\t};\n\tif ([\n\t\t\"sheep\",\n\t\t\"fish\",\n\t\t\"deer\",\n\t\t\"moose\",\n\t\t\"series\",\n\t\t\"species\",\n\t\t\"money\",\n\t\t\"rice\",\n\t\t\"information\",\n\t\t\"equipment\",\n\t\t\"bison\",\n\t\t\"cod\",\n\t\t\"offspring\",\n\t\t\"pike\",\n\t\t\"salmon\",\n\t\t\"shrimp\",\n\t\t\"swine\",\n\t\t\"trout\",\n\t\t\"aircraft\",\n\t\t\"hovercraft\",\n\t\t\"spacecraft\",\n\t\t\"sugar\",\n\t\t\"tuna\",\n\t\t\"you\",\n\t\t\"wood\"\n\t].indexOf(word.toLowerCase()) >= 0) return word;\n\tfor (const w in irregular) {\n\t\tconst pattern = new RegExp(`${irregular[w]}$`, \"i\");\n\t\tif (pattern.test(word)) return word.replace(pattern, w);\n\t}\n\tfor (const reg in singulars) {\n\t\tconst pattern = new RegExp(reg, \"i\");\n\t\tif (pattern.test(word)) return word.replace(pattern, singulars[reg]);\n\t}\n\treturn word;\n}\n//#endregion\nexport { plural, singular };\n\n//# sourceMappingURL=plurals.es.js.map","import { toSnakeCase } from \"./strings.es.js\";\nimport { singular } from \"./plurals.es.js\";\n//#region src/names.ts\n/**\n* Generates a foreign key column name from a given string, typically a collection slug or name.\n* It singularizes the name, converts it to snake_case and appends '_id'.\n*\n* Singularization runs *before* snake-casing so that acronyms survive: `toSnakeCase`\n* splits on every capital, which turned \"URLs\" into \"ur_ls\" and then \"ur_l_id\".\n*\n* @param name The base name to convert to a foreign key.\n* @returns A foreign key name in the format 'singular_name_id'.\n*\n* @example\n* // returns \"user_id\"\n* generateForeignKeyName(\"users\")\n*\n* @example\n* // returns \"category_id\"\n* generateForeignKeyName(\"categories\")\n*\n* @example\n* // returns \"product_id\"\n* generateForeignKeyName(\"Product\")\n*\n*/\nfunction generateForeignKeyName(name) {\n\treturn `${toSnakeCase(singularizeForKey(name))}_id`;\n}\n/**\n* `singular()` handles real English plurals, but its final catch-all rule strips\n* any trailing \"s\", which mangles words that only look plural. Guard the two\n* cases that produce a column name nobody would recognise:\n*\n* - a double \"s\" ending is never a plural marker (\"address\", \"class\", \"process\"),\n* so stripping it yields \"addres\";\n* - a name that singularizes to nothing (the literal \"s\") would yield \"_id\".\n*/\nfunction singularizeForKey(name) {\n\tif (/ss$/i.test(name)) return name;\n\tconst result = singular(name);\n\treturn result.length > 0 ? result : name;\n}\n/**\n* What `generateForeignKeyName` returned before it learned to singularize:\n* snake-case the name, then chop one trailing \"s\".\n*\n* This is here to be *detected*, never to be generated. A database provisioned\n* under the old rule carries `categorie_id`, `addresse_id`, `children_id` or\n* `ur_l_id` where the current rule expects `category_id`, `address_id`,\n* `child_id` and `url_id` — and the boot-time schema ensure is additive, so it\n* would create the new column empty beside the populated old one and leave the\n* relation reading nothing. No error, no missing table: the failure is silent,\n* which is the only reason this function still exists.\n*\n* `ensureCollectionSchema` calls it to recognise that shape and say so.\n* Returns the same string as `generateForeignKeyName` for every regular plural,\n* so a caller can compare the two and act only when they differ.\n*/\nfunction legacyForeignKeyName(name) {\n\tconst snake = toSnakeCase(name);\n\treturn `${snake.endsWith(\"s\") ? snake.slice(0, -1) : snake}_id`;\n}\n/**\n* Truncate an identifier to what Postgres will actually store.\n*\n* Postgres silently truncates identifiers at NAMEDATALEN-1 = 63 **bytes**, so a\n* name generated longer than that is not the name the database ends up holding.\n* Anything that later looks the object up by the name it generated then misses.\n*\n* Byte length, not string length: NAMEDATALEN is a byte bound, and a multi-byte\n* character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.\n*\n* `TextEncoder` rather than `Buffer`, which is not a matter of taste: `Buffer`\n* is a Node global, and this package is imported by browser-facing ones. It\n* typechecked only where `@types/node` happened to be in scope, so\n* `packages/codegen` — whose tsconfig is `lib: [\"ESNext\", \"dom\"]` — could not\n* compile the file at all, and both of its suites failed to run. `TextEncoder`\n* and `TextDecoder` are standard in both runtimes and need no ambient types.\n*/\nfunction toPostgresIdentifier(name) {\n\treturn truncateToBytes(name, 63);\n}\n/**\n* {@link toPostgresIdentifier} with the bound lifted to a parameter.\n*\n* Exists for names that end in something load-bearing. Truncating at 63 keeps\n* the *head* of a name and discards the tail, which is right for a descriptive\n* identifier and wrong for a hashed one: the hash is the part that makes it\n* unique, and it is at the end. A caller that appends a fingerprint truncates\n* the readable head to `63 - <tail>` itself and then appends, so the bound is\n* still 63 and the hash always survives.\n*\n* `contracts/derived-names.txt` records what the alternative costs — a foreign\n* key frozen as `..._corres`, its `_fkey` suffix truncated away, so a second\n* foreign key on that table would derive a byte-identical name.\n*\n* One truncation rule, in one function, so the two cannot drift.\n*/\nfunction truncateToBytes(name, maxBytes) {\n\tconst bytes = new TextEncoder().encode(name);\n\tif (bytes.byteLength <= maxBytes) return name;\n\treturn new TextDecoder(\"utf-8\").decode(bytes.subarray(0, maxBytes)).replace(/�+$/, \"\");\n}\n/**\n* The API name a database column is served under.\n*\n* The wire name of a field is its property key, and Rebase's property keys are\n* camelCase — `displayName`, `createdAt`, `photoURL`. Columns are snake_case,\n* because an unquoted Postgres identifier folds to lower case and a camelCase\n* column is therefore reachable only as `\"authorId\"` forever: in hand-written\n* SQL, in psql, in an RLS policy body, in a dump, and in every third-party tool\n* that ever touches the database. So the two conventions are both right, and\n* this is the function that crosses between them.\n*\n* It exists because two sources of field names never crossed: a foreign key\n* derived from a relation (`author_id`) and a column read back by introspection\n* (`user_id`) both landed on the wire under their column name, while every\n* hand-authored collection next to them used camelCase. One API, two\n* conventions, and no rule a caller could infer from outside — those names are\n* also the `where` and `orderBy` keys, so it was not a matter of taste.\n*\n* Rules, in the order they matter:\n*\n* - **A name with no separator is returned unchanged.** `photoURL` stays\n* `photoURL` and `id` stays `id`. Lower-casing a single token is what makes\n* a \"camelCase\" helper destructive — `camelCase(\"photoURL\")` is `photourl` —\n* and this function is applied to names that are *already* keys.\n* - **Each following segment keeps its own casing** apart from an upper-cased\n* first letter, so `photo_URL` → `photoURL` rather than `photoUrl`.\n* - **The result may still not be a JavaScript identifier.** `2fa_enabled`\n* becomes `2faEnabled`, which is a perfectly good object key and still needs\n* quoting where one is written into generated source.\n*\n* Not the inverse of {@link toSnakeCase}: `toSnakeCase` tokenises on case\n* boundaries and would turn `photoURL` into `photo_url`. Round-tripping is not\n* a property either function promises, which is why a column name that a\n* property maps explicitly is always read off `columnName` rather than derived.\n*/\nfunction toWireKey(columnName) {\n\tif (!columnName) return columnName;\n\tconst segments = columnName.split(/[-_ ]+/).filter(Boolean);\n\tif (segments.length <= 1) return columnName;\n\treturn segments.map((segment, index) => index === 0 ? segment.charAt(0).toLowerCase() + segment.slice(1) : segment.charAt(0).toUpperCase() + segment.slice(1)).join(\"\");\n}\n/**\n* The first candidate key not already used, or a numbered fallback.\n*\n* Introspection turns a set of column names into a set of object keys, and the\n* mapping is not injective: `user_id` and `userId` are two columns and one\n* {@link toWireKey}, and two foreign keys can strip to the same relation name.\n* A duplicate key in a generated object literal is a TypeScript error, so the\n* whole collection stops compiling — and a duplicate key in a `Record` built at\n* runtime is worse, because it silently drops a column instead.\n*\n* The numbered tail is what makes this total: a function that returns a key it\n* cannot guarantee is free has only moved the duplicate one line down.\n*\n* Structurally typed on `has` so a `Map` of emitted blocks and a `Set` of taken\n* names both satisfy it. Lives here, in the package both introspection\n* producers and the admin's table import can reach, because they must resolve a\n* collision the same way or one database describes itself three ways.\n*/\nfunction firstFreeKey(candidates, taken) {\n\tfor (const candidate of candidates) if (!taken.has(candidate)) return candidate;\n\tconst base = candidates[candidates.length - 1];\n\tfor (let suffix = 2;; suffix++) {\n\t\tconst candidate = `${base}_${suffix}`;\n\t\tif (!taken.has(candidate)) return candidate;\n\t}\n}\n//#endregion\nexport { firstFreeKey, generateForeignKeyName, legacyForeignKeyName, toPostgresIdentifier, toWireKey, truncateToBytes };\n\n//# sourceMappingURL=names.es.js.map","import {\n CollectionConfig,\n Relation,\n ResolvedRelation\n} from \"@rebasepro/types\";\nimport { generateForeignKeyName, toSnakeCase } from \"@rebasepro/utils\";\n\nimport { getTableName } from \"./relations\";\n\n/**\n * Fill in a relation's defaults.\n *\n * This replaces `sanitizeRelation`, which had to work out *which kind of link\n * you meant* from whichever optional fields happened to be set — 194 lines of\n * it, including a pass that inspected the target collection's own relations to\n * decide whether a `many`/`inverse` pair was a one-to-many or the far side of a\n * many-to-many, wrapped in a `try/catch` that fell through to the wrong answer\n * when it could not tell. Two consumers running that logic at different moments\n * could reach different conclusions about the same relation.\n *\n * With the kind declared there is nothing to work out. What remains is\n * defaulting — a table name, a column name — which is deterministic, depends\n * only on the relation and its two endpoints, and cannot fail. That is why this\n * function returns rather than throws, and why it needs no cache to be\n * consistent.\n */\nexport function resolveRelation(\n relation: Relation,\n sourceCollection: CollectionConfig,\n propertyKey?: string\n): ResolvedRelation {\n const target = relation.target;\n if (typeof target !== \"function\") {\n throw new Error(\n `Relation${relation.relationName ? ` '${relation.relationName}'` : \"\"} on ` +\n `'${sourceCollection.slug}' has no \\`target\\`. Give it a thunk: \\`target: () => otherCollection\\`.`\n );\n }\n\n const targetCollection = callTarget(relation, sourceCollection, propertyKey, target);\n\n // The name is the address: the `include` key, the admin tab, and the\n // segment of a nested path. Declared name wins, then the declaring\n // property's key, then the target's slug.\n const relationName = relation.relationName ?? propertyKey ?? toSnakeCase(targetCollection.slug);\n\n const shared: Pick<ResolvedRelation, \"relationName\" | \"target\" | \"targetSlug\" | \"onUpdate\" | \"onDelete\" | \"overrides\"> = {\n relationName,\n // Normalised, not the thunk as written. Resolution reads the target once\n // and every later consumer calls it again — the driver building a join,\n // the DDL and policy generators, the admin's relation fields — so\n // handing back the raw thunk would give all of them the module namespace\n // `callTarget` just looked past, and the fix would hold only for the\n // fields resolution happens to read here. Still lazy: same call at the\n // same moment, one unwrap on the way out.\n target: () => unwrapModuleNamespace(target()) as CollectionConfig,\n targetSlug: targetCollection.slug,\n onUpdate: relation.onUpdate,\n onDelete: relation.onDelete,\n overrides: relation.overrides\n // No `validation`. Whether the link is required is a fact about the\n // *property*, and copying it onto the resolved relation gave the\n // question two answers that were free to disagree. Ask\n // `isRelationRequired(collection, relation)`, which reads the one.\n };\n\n const sourceName = toSnakeCase(sourceCollection.slug ?? sourceCollection.name);\n\n switch (relation.kind) {\n case \"belongsTo\":\n return {\n ...shared,\n kind: \"belongsTo\",\n cardinality: \"one\",\n writable: true,\n shared: false,\n localKey: relation.localKey ?? generateForeignKeyName(relationName),\n index: relation.index !== false\n };\n\n case \"hasOne\":\n return {\n ...shared,\n kind: \"hasOne\",\n cardinality: \"one\",\n writable: true,\n shared: false,\n foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName),\n sourceKey: relation.sourceKey\n };\n\n case \"hasMany\":\n return {\n ...shared,\n kind: \"hasMany\",\n cardinality: \"many\",\n writable: true,\n shared: false,\n foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName),\n // Not defaulted: the source's primary key needs the driver's\n // schema to resolve, which resolution does not have. `undefined`\n // means \"the primary key\" — see `ResolvedHasMany.sourceKey`.\n sourceKey: relation.sourceKey\n };\n\n case \"manyToMany\": {\n const sourceTable = getTableName(sourceCollection);\n const targetTable = getTableName(targetCollection);\n return {\n ...shared,\n kind: \"manyToMany\",\n cardinality: \"many\",\n writable: true,\n shared: true,\n through: {\n // Sorted so both sides of the same link derive the same\n // table without having to agree in advance.\n table: relation.through?.table ?? [sourceTable, targetTable].sort().join(\"_\"),\n sourceColumn: relation.through?.sourceColumn ?? generateForeignKeyName(sourceName),\n targetColumn: relation.through?.targetColumn ?? generateForeignKeyName(relationName),\n // `{}` rather than `undefined`, for the reason every other\n // field here is filled in: a consumer reads one shape and\n // does not have to decide what an absent payload means.\n properties: relation.through?.properties ?? {}\n }\n };\n }\n\n case \"via\":\n return {\n ...shared,\n kind: \"via\",\n cardinality: relation.cardinality,\n writable: false,\n // A join chain reaches rows that other parents reach too, and\n // Rebase does not know which hop, if any, is a link it owns.\n shared: true,\n joinPath: relation.joinPath\n };\n\n default: {\n // Exhaustive: a new kind is a compile error here, not a silent\n // fall-through to whatever shape happened to match first.\n const exhaustive: never = relation;\n throw new Error(`Unknown relation kind: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n\n/** How this relation is addressed in an error message, before it has a resolved name. */\nfunction describe(relation: Relation, sourceCollection: CollectionConfig, propertyKey?: string): string {\n const name = relation.relationName ?? propertyKey;\n return `Relation${name ? ` '${name}'` : \"\"} on '${sourceCollection.slug}'`;\n}\n\n/**\n * A module namespace, unwrapped to the collection it exports.\n *\n * A cycle transpiled to CommonJS does not hand the importing module the\n * *default export* — it hands it the module object, `{ __esModule: true,\n * default: … }`, captured before the exporting module finished evaluating. The\n * `default` slot fills in later, so by the time a lazy `target` thunk runs the\n * collection is sitting right there, one level down. Returning the namespace is\n * never a thing a thunk means to do, and there is exactly one reading of it.\n *\n * Only unwrapped when the inner value is itself a collection: a `default` that\n * is not one is a genuinely wrong thunk, and it should reach the error below\n * rather than be quietly swapped in.\n */\nfunction unwrapModuleNamespace(value: unknown): unknown {\n if (!value || typeof value !== \"object\") return value;\n if ((value as { slug?: unknown }).slug) return value;\n const inner = (value as { default?: unknown }).default;\n return inner && typeof inner === \"object\" && (inner as { slug?: unknown }).slug ? inner : value;\n}\n\n/**\n * Call the `target` thunk, and translate the ways an import cycle breaks it into\n * an error that names the cause — or, where the value is recoverable, into the\n * collection the thunk meant.\n *\n * The thunk exists to defer the reference until every module has finished\n * evaluating, and for a cycle that closes at import time it does. Two cycles\n * leave the binding permanently unusable:\n *\n * - **ESM/TDZ.** `const` and `class` bindings in a not-yet-evaluated module are\n * in the temporal dead zone, so reading one throws `ReferenceError: x is not\n * defined`. The stack points at the thunk — a one-line arrow function that is\n * obviously fine — and says nothing about the cycle that made it throw.\n * - **CJS interop, unresolved.** The half-initialised module object has no\n * `default` yet, the import resolves to `undefined`, and the thunk returns it\n * without complaint. That one used to surface here as \"did not resolve to a\n * collection\", which is true and unhelpful.\n *\n * Both mean the same thing, and the fix for both is the same: break the cycle,\n * or move the relation into the collection that does not close it.\n *\n * A third shape is *not* an error, and used to be reported as one. A loader that\n * transpiles ESM to CJS — jiti, which is what `rebase generate-sdk` and\n * `rebase build` load collections with — gives the module entered second in a\n * cycle a namespace object rather than the default export, and never replaces it\n * with a live binding. The thunk then returns `{ __esModule: true, default: … }`\n * holding the fully-initialised collection. Native ESM resolves the same thunk\n * to the collection directly, so this was a loader artefact reported as an\n * authoring mistake, and the advice it gave — make the target a lazy thunk — was\n * already satisfied by the code it was rejecting. Bidirectional relations make\n * these cycles unavoidable, and the lazy thunk is this framework's own answer to\n * them, so {@link unwrapModuleNamespace} takes the collection and moves on.\n */\nfunction callTarget(\n relation: Relation,\n sourceCollection: CollectionConfig,\n propertyKey: string | undefined,\n target: Relation[\"target\"]\n): ReturnType<Relation[\"target\"]> {\n let targetCollection: ReturnType<Relation[\"target\"]> | undefined;\n try {\n targetCollection = unwrapModuleNamespace(target()) as ReturnType<Relation[\"target\"]>;\n } catch (error) {\n // A ReferenceError from inside the thunk is a binding that was never\n // initialised — nothing else in a one-expression arrow can raise one.\n if (error instanceof ReferenceError) {\n throw new Error(\n `${describe(relation, sourceCollection, propertyKey)} targets a collection that is not ` +\n `initialized yet — almost always an import cycle between the two collection files. ` +\n `Break the cycle (move the shared piece into a third module, or import the target ` +\n `lazily) so the target's module finishes evaluating before the registry is built.`,\n { cause: error }\n );\n }\n throw error;\n }\n\n if (!targetCollection?.slug) {\n throw new Error(\n `${describe(relation, sourceCollection, propertyKey)} has a \\`target\\` that resolved to ` +\n `${targetCollection === undefined ? \"`undefined`\" : \"something that is not a collection\"}. ` +\n (targetCollection === undefined\n ? \"Under CommonJS interop an import cycle resolves the default import to `undefined`, \" +\n \"so check whether this collection and its target import each other. Otherwise the thunk \" +\n \"is returning the wrong value — it must return the collection itself, not a promise or a module.\"\n : typeof (targetCollection as { then?: unknown }).then === \"function\"\n ? \"The thunk returned a promise — `target: () => import(\\\"./other\\\")` is asynchronous. \" +\n \"Import the collection at the top of the file and return the binding: \" +\n \"`target: () => otherCollection`.\"\n : \"The thunk must return a collection config with a `slug`.\")\n );\n }\n\n return targetCollection;\n}\n","import { CollectionConfig, isRelationalCollectionConfig, Property, ResolvedRelation, RelationProperty } from \"@rebasepro/types\";\nimport { toSnakeCase, toWireKey } from \"@rebasepro/utils\";\n\nimport { resolveRelation } from \"./resolve-relation\";\n\n/**\n * Whether the target rows are shared with other parents — a many-to-many, or a\n * multi-hop `via` chain.\n *\n * Decides what a write \"through\" the relation may touch: a shared target\n * belongs to every parent that links it, so the parent owns the *link* and not\n * the row. The backend enforces that (an unlink rather than a delete) and the\n * admin renders it (remove-from-parent rather than delete).\n *\n * Now a field on the resolved relation rather than a re-derivation, so both\n * sides read the same answer instead of each computing one.\n */\nexport function isJunctionBackedRelation(relation: ResolvedRelation): boolean {\n return relation.shared;\n}\n\n/** WeakMap cache — same collection instance always yields the same relation map. */\nconst _resolvedRelationsCache = new WeakMap<CollectionConfig, Record<string, ResolvedRelation>>();\n\n/**\n * Every relation a collection declares, keyed by the name it is addressed by.\n *\n * A relation reaches the map from either of two places — the collection's\n * `relations` array, or a `relation` property that declares one inline — and is\n * keyed by its resolved `relationName`, which is what a nested path segment,\n * an `include` key and an admin tab all match against.\n *\n * Resolution no longer swallows failures. It used to wrap each relation in a\n * `try/catch` that dropped anything it could not work out, so a\n * mis-declared relation silently vanished instead of being reported; with the\n * kind declared, the only remaining failure is a `target` that does not resolve,\n * which is worth hearing about.\n */\nexport function resolveCollectionRelations(\n collection: CollectionConfig\n): Record<string, ResolvedRelation> {\n const cached = _resolvedRelationsCache.get(collection);\n if (cached) return cached;\n\n if (!isRelationalCollectionConfig(collection)) return {};\n\n const relations: Record<string, ResolvedRelation> = {};\n\n for (const relation of collection.relations ?? []) {\n const resolved = resolveRelation(relation, collection);\n relations[resolved.relationName] = resolved;\n }\n\n // A property declaring a relation inline is registered under the property\n // key as well: the fetch layer hydrates the result back onto that key, and\n // it is the name the admin addresses the field by.\n for (const [propertyKey, property] of Object.entries(collection.properties ?? {})) {\n if ((property as Property)?.type !== \"relation\") continue;\n const declared = (property as RelationProperty).relation;\n if (!declared || relations[propertyKey]) continue;\n\n relations[propertyKey] = resolveRelation(declared, collection, propertyKey);\n }\n\n _resolvedRelationsCache.set(collection, relations);\n return relations;\n}\n\n/**\n * The `type: \"relation\"` property that declares a link, or `undefined` for one\n * that only exists in the collection's `relations` array.\n *\n * Both declaration sites end up in {@link resolveCollectionRelations}, and only\n * one of them has a property to carry field-level facts — `name`, `admin`, and\n * the one this exists for, `validation.required`.\n */\nexport function relationDeclaringProperty(\n collection: CollectionConfig,\n relation: ResolvedRelation\n): RelationProperty | undefined {\n const resolved = resolveCollectionRelations(collection);\n for (const [key, raw] of Object.entries(collection.properties ?? {})) {\n const prop = raw as Property | undefined;\n if (prop?.type !== \"relation\") continue;\n // A relation declared inline is keyed by the property; one declared in\n // `relations` is keyed by its name, which the property addresses.\n if (resolved[key] === relation) return prop as RelationProperty;\n const addressed = (prop as RelationProperty).relation?.relationName;\n if (addressed && findRelation(resolved, addressed) === relation) return prop as RelationProperty;\n }\n return undefined;\n}\n\n/**\n * Must every row of this collection point at a target through this link?\n *\n * Read from the declaring property's `validation.required` — the same key every\n * other field uses, and the only place it lives.\n *\n * `RelationBase` carried its own `validation.required` until 0.18, which made\n * this two questions rather than one. They were answered by different readers:\n * the Postgres DDL generator asked the property (so the foreign-key column was\n * `NOT NULL`) and the SDK type generator asked the relation (so the generated\n * `Insert` type made the field optional). A `create()` that left the relation\n * out therefore typechecked and then failed at the database with a not-null\n * violation, and the two `required`s had to be written twice, identically, for\n * the pair to agree.\n *\n * A relation with no declaring property — an entry in `relations` nothing\n * points at — is not required. There is no field to fill in.\n */\nexport function isRelationRequired(collection: CollectionConfig, relation: ResolvedRelation): boolean {\n return Boolean(relationDeclaringProperty(collection, relation)?.validation?.required);\n}\n\n/**\n * The path of the collection a relation property points at, derived from the\n * property alone.\n *\n * A preview holds a property and a value and no collection, so it cannot call\n * `resolveRelationProperty`. It does not need to: both forms that carry a\n * target — the stamped `resolvedRelation` and the inline `relation` — name it\n * directly. Only the third form, a relation declared by name in the\n * collection's `relations` array, is out of reach, and that one has no target\n * to read without the collection anyway.\n *\n * This is what lets a preview render a relation column that arrived as a bare\n * foreign key: the id says *which* row, the declared target says *which\n * collection*, and `RelationPreview` fetches the rest. Without it a scalar id\n * is indistinguishable from a value of the wrong type.\n */\nexport function getRelationTargetPath(property: RelationProperty): string | undefined {\n const stamped = property.resolvedRelation?.targetSlug;\n if (stamped) return stamped;\n\n const target = property.relation?.target;\n if (typeof target !== \"function\") return undefined;\n try {\n return target()?.slug;\n } catch (_e) {\n // A thunk reaching into a module that has not finished initialising:\n // there is no target to name yet, and a preview is not worth throwing over.\n return undefined;\n }\n}\n\n/**\n * The table a collection reads and writes.\n *\n * `table` when it is set, otherwise `toSnakeCase(slug)` — which is what made it\n * safe to drop `table` from the required fields on the config type: the runtime\n * had always derived it, and the type was demanding a value it did not need.\n *\n * The `||` chain is load-bearing. `toSnakeCase(undefined)` returns `\"\"`, not\n * `undefined`, so the previous `??` chain short-circuited on the empty string\n * and the name fallback could never run — a safety net that read like one and\n * caught nothing. It was unreachable while `slug` was required; it stops being\n * unreachable the moment anything constructs a config without one.\n */\nexport function getTableName(collection: CollectionConfig): string {\n const declared = isRelationalCollectionConfig(collection) ? collection.table : undefined;\n return declared || toSnakeCase(collection.slug) || toSnakeCase(collection.name);\n}\n\n/**\n * A JavaScript identifier: what a generated `export const <name> =` needs.\n *\n * Deliberately the same shape the two schema generators already define\n * privately — this is the third place that needed it, and the first two guard\n * property keys and member accesses while nothing guarded the variable name\n * itself.\n */\nconst JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;\n\n/**\n * The variable name a generated table is bound to.\n *\n * Camel-cases underscores, and then guarantees the result is a legal\n * identifier. It did only the first, so a table name that is legal in Postgres\n * and not in JavaScript produced a `schema.generated.ts` that does not parse:\n *\n * `2024_archive` → `export const 2024Archive = pgTable(…)`\n * \"An identifier or keyword cannot immediately follow\n * a numeric literal\"\n * `reporting.events` → `export const reporting.events = pgTable(…)`\n * \"',' expected\"\n *\n * That file is imported by the server, so the failure is not one broken\n * collection — `rebase build` and `db push` fail at tsc for the whole\n * directory. And it is reachable from a documented flow: `rebase init` against\n * a database holding a table called `2024_archive` writes a collection file\n * that parses and a schema file that does not.\n *\n * **A no-op for every name that already worked**, which is what makes changing\n * a derived name safe here: the only inputs whose output changes are the ones\n * that produced a syntax error, and nothing can be running against those.\n * Separators become camel case rather than disappearing, so `reporting.events`\n * and `reporting_events` do not collide into one variable.\n */\nexport function getTableVarName(tableName: string): string {\n const camel = tableName.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());\n if (JS_IDENTIFIER.test(camel)) return camel;\n\n const sanitised = camel\n // Any other separator gets the same treatment `_` did, so two tables\n // differing only by separator keep differing.\n .replace(/[^A-Za-z0-9_$]+([A-Za-z0-9])?/g, (_, char?: string) =>\n (char ? char.toUpperCase() : \"\"))\n // A leading digit is legal in Postgres and not in JavaScript. Prefixed\n // rather than stripped, so `2024_archive` and `archive` stay distinct.\n .replace(/^([0-9])/, \"t$1\");\n\n return JS_IDENTIFIER.test(sanitised) ? sanitised : `t${sanitised}`;\n}\n\nexport function getEnumVarName(tableName: string, propName: string): string {\n const tableVar = getTableVarName(tableName);\n const propVar = propName.charAt(0).toUpperCase() + propName.slice(1);\n return `${tableVar}${propVar}`;\n}\n\nexport function getColumnName(fullColumn: string): string {\n return fullColumn.includes(\".\") ? fullColumn.split(\".\").pop()! : fullColumn;\n}\n\n/**\n * The field key a database column is served and addressed under.\n *\n * A column has two names and they are not the same name. `author_id` is what\n * Postgres stores; `authorId` is the key on the JSON row, the key in the\n * generated Drizzle table, and the key a caller writes in `where` and\n * `orderBy`. Every place that starts from a column and has to reach a row, a\n * Drizzle table or a payload goes through here, so there is one answer rather\n * than one per call site — the two that disagreed put `displayName` and\n * `author_id` on the same API.\n *\n * A declared property is the authority when there is one, because its key *is*\n * the wire name and `columnName` is the only thing that ever renamed the\n * column:\n *\n * 1. an explicit `columnName` equal to this column;\n * 2. a property whose key is literally the column (an author who wrote\n * `author_id:` meant `author_id` on the wire, and gets it);\n * 3. a property whose key snake-cases to the column, which is the default\n * mapping — `authorId` → `author_id`.\n *\n * With no property in the way — a foreign key derived from a relation, which\n * usually has none — the name is derived: {@link toWireKey}.\n *\n * Note the fallback is *not* the column verbatim. That was the old behaviour\n * and it is precisely the defect: a derived foreign key reached the wire under\n * its column name while every hand-authored field beside it was camelCase.\n */\nexport function fieldKeyForColumn(collection: CollectionConfig | undefined, column: string): string {\n const properties = collection?.properties;\n if (properties) {\n for (const [key, prop] of Object.entries(properties)) {\n const columnName = (prop as { columnName?: unknown } | undefined)?.columnName;\n if (typeof columnName === \"string\" && columnName === column) return key;\n }\n for (const key of Object.keys(properties)) {\n if (key === column) return key;\n if (toSnakeCase(key) === column) return key;\n }\n }\n return toWireKey(column);\n}\n\n/**\n * Look up a relation by key with forgiving normalization.\n *\n * `resolveCollectionRelations` stores each relation under a single canonical\n * key (no aliases). This helper tries the given key as-is, then falls back to\n * slug form (underscores → hyphens) and snake_case form (hyphens → underscores)\n * so that callers that receive a key from external input (URL path segments,\n * user-provided config, etc.) can still find the right entry.\n */\nexport function findRelation(\n resolvedRelations: Record<string, ResolvedRelation>,\n key: string\n): ResolvedRelation | undefined {\n // Exact match first\n if (resolvedRelations[key]) return resolvedRelations[key];\n\n // Try slug form (e.g. \"company_id\" → \"company-id\")\n const slugKey = key.replace(/_/g, \"-\");\n if (slugKey !== key && resolvedRelations[slugKey]) return resolvedRelations[slugKey];\n\n // Try snake_case form (e.g. \"company-id\" → \"company_id\")\n const snakeKey = key.replace(/-/g, \"_\");\n if (snakeKey !== key && resolvedRelations[snakeKey]) return resolvedRelations[snakeKey];\n\n return undefined;\n}\n","import type { CollectionConfig, FieldAccess, Property } from \"@rebasepro/types\";\nimport { fieldKeyForColumn, findRelation, resolveCollectionRelations } from \"../util/relations\";\n\n/**\n * Field-level access control: one mechanism, read by every enforcement point.\n *\n * A collection's `securityRules` decide which *rows* a caller reaches;\n * `property.access` decides which *fields* of a reached row they see and may\n * set. The two are independent — a field rule never widens row access, and a\n * row a caller cannot read has no fields to talk about.\n *\n * `excludeFromApi` is sugar for `access: { read: [], write: [] }` and is\n * normalised into it by {@link effectiveAccess}, which is the only place either\n * spelling is read. It used to be its own code path in five files — the read\n * strip, the write refusal, the SDK generator, the OpenAPI schema builder and\n * the filter-parameter builder — and the second rule would have made ten.\n * There is one predicate now, and the flag is a shorthand for it.\n *\n * @module\n */\n\n/**\n * The caller a field rule is judged against: whatever the call context carries\n * as the user's application roles.\n *\n * `undefined` is the trusted server plane — an in-process `rebase.data` call\n * with no request behind it, the auth adapter writing a password hash, a\n * migration. Every API boundary has a viewer: an unauthenticated REST request is\n * scoped as `{ uid: ANONYMOUS_USER_ID, roles: [\"anon\"] }` before it reaches a\n * driver, so \"no viewer\" cannot be reached from outside.\n */\nexport interface FieldViewer {\n roles?: readonly string[];\n}\n\n/**\n * The role that satisfies any non-empty list.\n *\n * The same arm every baseline policy carries: `security_rules` injects\n * `rolesOverlap(['admin'])` into the default read and write policies, and\n * `rebase.dataAsAdmin` is scoped with `{ uid: \"service\", roles: [\"admin\"] }`.\n * Without this an author could declare `access: { read: [\"hr\"] }` and lock the\n * administrator out of a column of their own database — and lock the Studio out\n * of rendering it.\n */\nexport const ADMIN_ROLE = \"admin\";\n\n/**\n * What a property's access rules actually are, with `excludeFromApi` expanded.\n *\n * Returns `undefined` when the property constrains nothing, so callers can skip\n * the whole check for the overwhelmingly common case.\n */\nexport function effectiveAccess(property: Property | undefined): FieldAccess | undefined {\n if (!property) return undefined;\n if (property.excludeFromApi) return EXCLUDED_ACCESS;\n const access = property.access;\n if (!access) return undefined;\n if (access.read === undefined && access.write === undefined) return undefined;\n return access;\n}\n\n/** The rule `excludeFromApi: true` expands to. Frozen: it is shared by every caller. */\nconst EXCLUDED_ACCESS: FieldAccess = Object.freeze({ read: Object.freeze([]), write: Object.freeze([]) });\n\n/**\n * Does a caller holding `roles` satisfy `allowed`?\n *\n * Three cases, and the middle one is the one worth stating out loud:\n *\n * - `allowed` omitted — the field carries no rule of its own, so the row's\n * policies have already answered. True.\n * - `allowed` empty — nobody, at any privilege, through any API. Not the admin,\n * not the service key, not the trusted plane reading on a caller's behalf.\n * This is what `excludeFromApi` has always meant on the read side, and\n * collapsing the two spellings means the empty list has to keep meaning it.\n * - `allowed` non-empty — one of the named roles, or `admin`, or no viewer at\n * all (the trusted server plane, which is not an API caller).\n */\nfunction satisfies(allowed: readonly string[] | undefined, viewer: FieldViewer | undefined): boolean {\n if (allowed === undefined) return true;\n if (allowed.length === 0) return false;\n if (!viewer) return true;\n const roles = viewer.roles;\n if (!roles || roles.length === 0) return false;\n return roles.includes(ADMIN_ROLE) || allowed.some(role => roles.includes(role));\n}\n\n/** May this caller receive this field's value? */\nexport function canReadField(property: Property | undefined, viewer: FieldViewer | undefined): boolean {\n const access = effectiveAccess(property);\n return access ? satisfies(access.read, viewer) : true;\n}\n\n/** May this caller set this field's value? */\nexport function canWriteField(property: Property | undefined, viewer: FieldViewer | undefined): boolean {\n const access = effectiveAccess(property);\n return access ? satisfies(access.write, viewer) : true;\n}\n\n/**\n * The names on this collection a caller may not touch, in the two spellings a\n * caller can write them in.\n *\n * `declared` is the property keys, which is what has to leave a *known-fields*\n * set. `refused` is those plus the physical column names behind them: a caller\n * who knows the table can send `password_hash` as readily as `passwordHash`, and\n * a rule that only knew the wire name would be one rename away from useless.\n *\n * A restricted to-one relation takes its foreign key with it. `bandId: 7` names\n * the band exactly as `band: { id: 7 }` does, and the row carries the column\n * beside the relation — so a rule on the relation that left the key alone\n * withheld the value from the row and still let `?bandId=7` ask for it.\n *\n * `kind` picks which half of the rule is read; nothing else differs.\n */\nexport function restrictedFieldNames(\n collection: CollectionConfig,\n viewer: FieldViewer | undefined,\n kind: \"read\" | \"write\"\n): { declared: string[]; refused: Set<string> } {\n const allowed = kind === \"read\" ? canReadField : canWriteField;\n return namesWhere(collection, property => !allowed(property, viewer));\n}\n\n/**\n * The names on this collection that the API does not have, in every spelling\n * a caller could write them in — the `excludeFromApi` properties, their column\n * names, and the foreign key of an excluded to-one relation.\n *\n * Not the same set as {@link restrictedFieldNames}. A field restricted by role\n * is public by name: OpenAPI and `list_collections` describe it, and refusing a\n * query on it with \"not readable\" tells the caller nothing they could not read\n * there. An `excludeFromApi` field is described nowhere, so a query naming one\n * has to be answered as a query naming a column that does not exist — anything\n * else confirms that it does.\n */\nexport function apiHiddenFieldNames(collection: CollectionConfig): Set<string> {\n return namesWhere(collection, property => Boolean(property?.excludeFromApi)).refused;\n}\n\nfunction namesWhere(\n collection: CollectionConfig,\n restricted: (property: Property) => boolean\n): { declared: string[]; refused: Set<string> } {\n const declared: string[] = [];\n const refused = new Set<string>();\n const properties = collection.properties ?? {};\n // Resolved only when a restricted property is a relation, which is rare:\n // the read strip runs this on every row.\n let relations: ReturnType<typeof resolveCollectionRelations> | undefined;\n\n const restrict = (name: string) => {\n if (name in properties && !declared.includes(name)) declared.push(name);\n refused.add(name);\n };\n\n for (const [name, property] of Object.entries(properties)) {\n if (!restricted(property as Property)) continue;\n restrict(name);\n const columnName = (property as Property).columnName;\n if (columnName) refused.add(columnName);\n if ((property as Property).type === \"relation\") {\n relations ??= resolveCollectionRelations(collection);\n const relation = findRelation(relations, name);\n if (relation?.kind === \"belongsTo\") {\n restrict(fieldKeyForColumn(collection, relation.localKey));\n refused.add(relation.localKey);\n }\n }\n }\n return { declared, refused };\n}\n\n/**\n * True when nothing on this collection restricts a field, for either direction.\n *\n * Every read of every row runs through the strip, so the collection that has no\n * rules — which is almost all of them — has to cost one property walk and no\n * allocation.\n */\nexport function hasFieldAccessRules(collection: CollectionConfig): boolean {\n for (const property of Object.values(collection.properties ?? {})) {\n if (effectiveAccess(property as Property)) return true;\n }\n return false;\n}\n","import { ApiError } from \"../errors\";\n\n/**\n * The two query parameters soft delete adds to the REST surface.\n *\n * Kept in their own module so the call sites in `query-parser.ts` and the\n * delete routes are a single line each: the parsing rules belong to soft\n * delete, not to the parser, and a rule spread across the two files that read\n * it is a rule that drifts.\n */\n\n/** `?deleted=` — what to do about rows a soft delete has stamped. */\nexport const DELETED_QUERY_PARAM = \"deleted\";\n/** `?hard=` — ask for a real `DELETE` on a soft-delete collection. */\nexport const HARD_DELETE_QUERY_PARAM = \"hard\";\n\n/**\n * `?deleted=include|only` → the driver's `withDeleted`.\n *\n * Spelled `deleted` on the wire and `withDeleted` in the driver, deliberately:\n * the URL reads as a question about the rows (`?deleted=only` — \"only the\n * deleted ones\"), and the driver option reads as an instruction about the query.\n *\n * A value neither word is a 400 rather than a silent fallback to the default.\n * `?deleted=true` quietly hiding every deleted row is the worst of both: it\n * looks like it worked and answers the opposite question. Absent is the\n * default, which is \"hide them\".\n */\nexport function parseWithDeleted(raw: unknown): boolean | \"only\" | undefined {\n if (raw === undefined || raw === null || raw === \"\") return undefined;\n const value = String(raw).trim().toLowerCase();\n if (value === \"include\") return true;\n if (value === \"only\") return \"only\";\n throw ApiError.badRequest(\n `Invalid \\`?${DELETED_QUERY_PARAM}=${String(raw)}\\`. It takes 'include' (live rows and deleted ones) ` +\n \"or 'only' (deleted rows alone). Omit it to see only the live rows.\",\n \"INVALID_DELETED_PARAM\"\n );\n}\n\n/**\n * `?hard=true` → a real `DELETE` on a collection that soft-deletes.\n *\n * Needs no permission beyond the delete it replaces: it is the same verb, and a\n * second access-control surface for one operation is a second thing to get\n * wrong. What it changes is whether the row can be restored.\n *\n * Only the exact words `true` and `1` mean yes. Anything else is a 400, not a\n * \"no\" — a typo that silently soft-deletes when the caller asked to purge is a\n * caller who believes the data is gone.\n */\nexport function parseHardDelete(raw: unknown): boolean {\n if (raw === undefined || raw === null || raw === \"\") return false;\n const value = String(raw).trim().toLowerCase();\n if (value === \"true\" || value === \"1\") return true;\n if (value === \"false\" || value === \"0\") return false;\n throw ApiError.badRequest(\n `Invalid \\`?${HARD_DELETE_QUERY_PARAM}=${String(raw)}\\`. It takes 'true' or 'false'.`,\n \"INVALID_HARD_PARAM\"\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"soft-delete-params-BAgpmBvp.js","names":[],"sources":["../../utils/dist/plurals.es.js","../../utils/dist/names.es.js","../../common/src/util/resolve-relation.ts","../../common/src/util/relations.ts","../../common/src/collections/field-access.ts","../src/api/rest/soft-delete-params.ts"],"sourcesContent":["//#region src/plurals.ts\n/**\n* Returns the plural of an English word.\n*\n* @param {string} word\n* @param {number} [amount]\n* @returns {string}\n*/\nfunction plural(word, amount) {\n\tif (amount !== void 0 && amount === 1) return word;\n\tconst plurals = {\n\t\t\"(quiz)$\": \"$1zes\",\n\t\t\"^(ox)$\": \"$1en\",\n\t\t\"([m|l])ouse$\": \"$1ice\",\n\t\t\"(matr|vert|ind)ix|ex$\": \"$1ices\",\n\t\t\"(x|ch|ss|sh)$\": \"$1es\",\n\t\t\"([^aeiouy]|qu)y$\": \"$1ies\",\n\t\t\"(hive)$\": \"$1s\",\n\t\t\"(?:([^f])fe|([lr])f)$\": \"$1$2ves\",\n\t\t\"(shea|lea|loa|thie)f$\": \"$1ves\",\n\t\tsis$: \"ses\",\n\t\t\"([ti])um$\": \"$1a\",\n\t\t\"(tomat|potat|ech|her|vet)o$\": \"$1oes\",\n\t\t\"(bu)s$\": \"$1ses\",\n\t\t\"(alias)$\": \"$1es\",\n\t\t\"(octop)us$\": \"$1i\",\n\t\t\"(ax|test)is$\": \"$1es\",\n\t\t\"(us)$\": \"$1es\",\n\t\t\"([^s]+)$\": \"$1s\"\n\t};\n\tconst irregular = {\n\t\tmove: \"moves\",\n\t\tfoot: \"feet\",\n\t\tgoose: \"geese\",\n\t\tsex: \"sexes\",\n\t\tchild: \"children\",\n\t\tman: \"men\",\n\t\ttooth: \"teeth\",\n\t\tperson: \"people\"\n\t};\n\tif ([\n\t\t\"sheep\",\n\t\t\"fish\",\n\t\t\"deer\",\n\t\t\"moose\",\n\t\t\"series\",\n\t\t\"species\",\n\t\t\"money\",\n\t\t\"rice\",\n\t\t\"information\",\n\t\t\"equipment\",\n\t\t\"bison\",\n\t\t\"cod\",\n\t\t\"offspring\",\n\t\t\"pike\",\n\t\t\"salmon\",\n\t\t\"shrimp\",\n\t\t\"swine\",\n\t\t\"trout\",\n\t\t\"aircraft\",\n\t\t\"hovercraft\",\n\t\t\"spacecraft\",\n\t\t\"sugar\",\n\t\t\"tuna\",\n\t\t\"you\",\n\t\t\"wood\"\n\t].indexOf(word.toLowerCase()) >= 0) return word;\n\tfor (const w in irregular) {\n\t\tconst pattern = new RegExp(`${w}$`, \"i\");\n\t\tconst replace = irregular[w];\n\t\tif (pattern.test(word)) return word.replace(pattern, replace);\n\t}\n\tfor (const reg in plurals) {\n\t\tconst pattern = new RegExp(reg, \"i\");\n\t\tif (pattern.test(word)) return word.replace(pattern, plurals[reg]);\n\t}\n\treturn word;\n}\n/**\n* Returns the singular of an English word.\n*\n* @param {string} word\n* @param {number} [amount]\n* @returns {string}\n*/\nfunction singular(word, amount) {\n\tif (amount !== void 0 && amount !== 1) return word;\n\tconst singulars = {\n\t\t\"(quiz)zes$\": \"$1\",\n\t\t\"(matr)ices$\": \"$1ix\",\n\t\t\"(vert|ind)ices$\": \"$1ex\",\n\t\t\"^(ox)en$\": \"$1\",\n\t\t\"(alias)es$\": \"$1\",\n\t\t\"(octop|vir)i$\": \"$1us\",\n\t\t\"(cris|ax|test)es$\": \"$1is\",\n\t\t\"(shoe)s$\": \"$1\",\n\t\t\"(o)es$\": \"$1\",\n\t\t\"(bus)es$\": \"$1\",\n\t\t\"([m|l])ice$\": \"$1ouse\",\n\t\t\"(x|ch|ss|sh)es$\": \"$1\",\n\t\t\"(m)ovies$\": \"$1ovie\",\n\t\t\"(s)eries$\": \"$1eries\",\n\t\t\"([^aeiouy]|qu)ies$\": \"$1y\",\n\t\t\"([lr])ves$\": \"$1f\",\n\t\t\"(tive)s$\": \"$1\",\n\t\t\"(hive)s$\": \"$1\",\n\t\t\"(li|wi|kni)ves$\": \"$1fe\",\n\t\t\"(shea|loa|lea|thie)ves$\": \"$1f\",\n\t\t\"(^analy)ses$\": \"$1sis\",\n\t\t\"((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$\": \"$1$2sis\",\n\t\t\"([ti])a$\": \"$1um\",\n\t\t\"(n)ews$\": \"$1ews\",\n\t\t\"(h|bl)ouses$\": \"$1ouse\",\n\t\t\"(corpse)s$\": \"$1\",\n\t\t\"(us)es$\": \"$1\",\n\t\ts$: \"\"\n\t};\n\tconst irregular = {\n\t\tmove: \"moves\",\n\t\tfoot: \"feet\",\n\t\tgoose: \"geese\",\n\t\tsex: \"sexes\",\n\t\tchild: \"children\",\n\t\tman: \"men\",\n\t\ttooth: \"teeth\",\n\t\tperson: \"people\"\n\t};\n\tif ([\n\t\t\"sheep\",\n\t\t\"fish\",\n\t\t\"deer\",\n\t\t\"moose\",\n\t\t\"series\",\n\t\t\"species\",\n\t\t\"money\",\n\t\t\"rice\",\n\t\t\"information\",\n\t\t\"equipment\",\n\t\t\"bison\",\n\t\t\"cod\",\n\t\t\"offspring\",\n\t\t\"pike\",\n\t\t\"salmon\",\n\t\t\"shrimp\",\n\t\t\"swine\",\n\t\t\"trout\",\n\t\t\"aircraft\",\n\t\t\"hovercraft\",\n\t\t\"spacecraft\",\n\t\t\"sugar\",\n\t\t\"tuna\",\n\t\t\"you\",\n\t\t\"wood\"\n\t].indexOf(word.toLowerCase()) >= 0) return word;\n\tfor (const w in irregular) {\n\t\tconst pattern = new RegExp(`${irregular[w]}$`, \"i\");\n\t\tif (pattern.test(word)) return word.replace(pattern, w);\n\t}\n\tfor (const reg in singulars) {\n\t\tconst pattern = new RegExp(reg, \"i\");\n\t\tif (pattern.test(word)) return word.replace(pattern, singulars[reg]);\n\t}\n\treturn word;\n}\n//#endregion\nexport { plural, singular };\n\n//# sourceMappingURL=plurals.es.js.map","import { toSnakeCase } from \"./strings.es.js\";\nimport { singular } from \"./plurals.es.js\";\n//#region src/names.ts\n/**\n* Generates a foreign key column name from a given string, typically a collection slug or name.\n* It singularizes the name, converts it to snake_case and appends '_id'.\n*\n* Singularization runs *before* snake-casing so that acronyms survive: `toSnakeCase`\n* splits on every capital, which turned \"URLs\" into \"ur_ls\" and then \"ur_l_id\".\n*\n* @param name The base name to convert to a foreign key.\n* @returns A foreign key name in the format 'singular_name_id'.\n*\n* @example\n* // returns \"user_id\"\n* generateForeignKeyName(\"users\")\n*\n* @example\n* // returns \"category_id\"\n* generateForeignKeyName(\"categories\")\n*\n* @example\n* // returns \"product_id\"\n* generateForeignKeyName(\"Product\")\n*\n*/\nfunction generateForeignKeyName(name) {\n\treturn `${toSnakeCase(singularizeForKey(name))}_id`;\n}\n/**\n* `singular()` handles real English plurals, but its final catch-all rule strips\n* any trailing \"s\", which mangles words that only look plural. Guard the two\n* cases that produce a column name nobody would recognise:\n*\n* - a double \"s\" ending is never a plural marker (\"address\", \"class\", \"process\"),\n* so stripping it yields \"addres\";\n* - a name that singularizes to nothing (the literal \"s\") would yield \"_id\".\n*/\nfunction singularizeForKey(name) {\n\tif (/ss$/i.test(name)) return name;\n\tconst result = singular(name);\n\treturn result.length > 0 ? result : name;\n}\n/**\n* What `generateForeignKeyName` returned before it learned to singularize:\n* snake-case the name, then chop one trailing \"s\".\n*\n* This is here to be *detected*, never to be generated. A database provisioned\n* under the old rule carries `categorie_id`, `addresse_id`, `children_id` or\n* `ur_l_id` where the current rule expects `category_id`, `address_id`,\n* `child_id` and `url_id` — and the boot-time schema ensure is additive, so it\n* would create the new column empty beside the populated old one and leave the\n* relation reading nothing. No error, no missing table: the failure is silent,\n* which is the only reason this function still exists.\n*\n* `ensureCollectionSchema` calls it to recognise that shape and say so.\n* Returns the same string as `generateForeignKeyName` for every regular plural,\n* so a caller can compare the two and act only when they differ.\n*/\nfunction legacyForeignKeyName(name) {\n\tconst snake = toSnakeCase(name);\n\treturn `${snake.endsWith(\"s\") ? snake.slice(0, -1) : snake}_id`;\n}\n/**\n* Truncate an identifier to what Postgres will actually store.\n*\n* Postgres silently truncates identifiers at NAMEDATALEN-1 = 63 **bytes**, so a\n* name generated longer than that is not the name the database ends up holding.\n* Anything that later looks the object up by the name it generated then misses.\n*\n* Byte length, not string length: NAMEDATALEN is a byte bound, and a multi-byte\n* character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.\n*\n* `TextEncoder` rather than `Buffer`, which is not a matter of taste: `Buffer`\n* is a Node global, and this package is imported by browser-facing ones. It\n* typechecked only where `@types/node` happened to be in scope, so\n* `packages/codegen` — whose tsconfig is `lib: [\"ESNext\", \"dom\"]` — could not\n* compile the file at all, and both of its suites failed to run. `TextEncoder`\n* and `TextDecoder` are standard in both runtimes and need no ambient types.\n*/\nfunction toPostgresIdentifier(name) {\n\treturn truncateToBytes(name, 63);\n}\n/**\n* {@link toPostgresIdentifier} with the bound lifted to a parameter.\n*\n* Exists for names that end in something load-bearing. Truncating at 63 keeps\n* the *head* of a name and discards the tail, which is right for a descriptive\n* identifier and wrong for a hashed one: the hash is the part that makes it\n* unique, and it is at the end. A caller that appends a fingerprint truncates\n* the readable head to `63 - <tail>` itself and then appends, so the bound is\n* still 63 and the hash always survives.\n*\n* `contracts/derived-names.txt` records what the alternative costs — a foreign\n* key frozen as `..._corres`, its `_fkey` suffix truncated away, so a second\n* foreign key on that table would derive a byte-identical name.\n*\n* One truncation rule, in one function, so the two cannot drift.\n*/\nfunction truncateToBytes(name, maxBytes) {\n\tconst bytes = new TextEncoder().encode(name);\n\tif (bytes.byteLength <= maxBytes) return name;\n\treturn new TextDecoder(\"utf-8\").decode(bytes.subarray(0, maxBytes)).replace(/�+$/, \"\");\n}\n/**\n* The API name a database column is served under.\n*\n* The wire name of a field is its property key, and Rebase's property keys are\n* camelCase — `displayName`, `createdAt`, `photoURL`. Columns are snake_case,\n* because an unquoted Postgres identifier folds to lower case and a camelCase\n* column is therefore reachable only as `\"authorId\"` forever: in hand-written\n* SQL, in psql, in an RLS policy body, in a dump, and in every third-party tool\n* that ever touches the database. So the two conventions are both right, and\n* this is the function that crosses between them.\n*\n* It exists because two sources of field names never crossed: a foreign key\n* derived from a relation (`author_id`) and a column read back by introspection\n* (`user_id`) both landed on the wire under their column name, while every\n* hand-authored collection next to them used camelCase. One API, two\n* conventions, and no rule a caller could infer from outside — those names are\n* also the `where` and `orderBy` keys, so it was not a matter of taste.\n*\n* Rules, in the order they matter:\n*\n* - **A name with no separator is returned unchanged.** `photoURL` stays\n* `photoURL` and `id` stays `id`. Lower-casing a single token is what makes\n* a \"camelCase\" helper destructive — `camelCase(\"photoURL\")` is `photourl` —\n* and this function is applied to names that are *already* keys.\n* - **Each following segment keeps its own casing** apart from an upper-cased\n* first letter, so `photo_URL` → `photoURL` rather than `photoUrl`.\n* - **The result may still not be a JavaScript identifier.** `2fa_enabled`\n* becomes `2faEnabled`, which is a perfectly good object key and still needs\n* quoting where one is written into generated source.\n*\n* Not the inverse of {@link toSnakeCase}: `toSnakeCase` tokenises on case\n* boundaries and would turn `photoURL` into `photo_url`. Round-tripping is not\n* a property either function promises, which is why a column name that a\n* property maps explicitly is always read off `columnName` rather than derived.\n*/\nfunction toWireKey(columnName) {\n\tif (!columnName) return columnName;\n\tconst segments = columnName.split(/[-_ ]+/).filter(Boolean);\n\tif (segments.length <= 1) return columnName;\n\treturn segments.map((segment, index) => index === 0 ? segment.charAt(0).toLowerCase() + segment.slice(1) : segment.charAt(0).toUpperCase() + segment.slice(1)).join(\"\");\n}\n/**\n* The first candidate key not already used, or a numbered fallback.\n*\n* Introspection turns a set of column names into a set of object keys, and the\n* mapping is not injective: `user_id` and `userId` are two columns and one\n* {@link toWireKey}, and two foreign keys can strip to the same relation name.\n* A duplicate key in a generated object literal is a TypeScript error, so the\n* whole collection stops compiling — and a duplicate key in a `Record` built at\n* runtime is worse, because it silently drops a column instead.\n*\n* The numbered tail is what makes this total: a function that returns a key it\n* cannot guarantee is free has only moved the duplicate one line down.\n*\n* Structurally typed on `has` so a `Map` of emitted blocks and a `Set` of taken\n* names both satisfy it. Lives here, in the package both introspection\n* producers and the admin's table import can reach, because they must resolve a\n* collision the same way or one database describes itself three ways.\n*/\nfunction firstFreeKey(candidates, taken) {\n\tfor (const candidate of candidates) if (!taken.has(candidate)) return candidate;\n\tconst base = candidates[candidates.length - 1];\n\tfor (let suffix = 2;; suffix++) {\n\t\tconst candidate = `${base}_${suffix}`;\n\t\tif (!taken.has(candidate)) return candidate;\n\t}\n}\n//#endregion\nexport { firstFreeKey, generateForeignKeyName, legacyForeignKeyName, toPostgresIdentifier, toWireKey, truncateToBytes };\n\n//# sourceMappingURL=names.es.js.map","import {\n CollectionConfig,\n Relation,\n ResolvedRelation\n} from \"@rebasepro/types\";\nimport { generateForeignKeyName, toSnakeCase } from \"@rebasepro/utils\";\n\nimport { getTableName } from \"./relations\";\n\n/**\n * Fill in a relation's defaults.\n *\n * This replaces `sanitizeRelation`, which had to work out *which kind of link\n * you meant* from whichever optional fields happened to be set — 194 lines of\n * it, including a pass that inspected the target collection's own relations to\n * decide whether a `many`/`inverse` pair was a one-to-many or the far side of a\n * many-to-many, wrapped in a `try/catch` that fell through to the wrong answer\n * when it could not tell. Two consumers running that logic at different moments\n * could reach different conclusions about the same relation.\n *\n * With the kind declared there is nothing to work out. What remains is\n * defaulting — a table name, a column name — which is deterministic, depends\n * only on the relation and its two endpoints, and cannot fail. That is why this\n * function returns rather than throws, and why it needs no cache to be\n * consistent.\n */\nexport function resolveRelation(\n relation: Relation,\n sourceCollection: CollectionConfig,\n propertyKey?: string\n): ResolvedRelation {\n const target = relation.target;\n if (typeof target !== \"function\") {\n throw new Error(\n `Relation${relation.relationName ? ` '${relation.relationName}'` : \"\"} on ` +\n `'${sourceCollection.slug}' has no \\`target\\`. Give it a thunk: \\`target: () => otherCollection\\`.`\n );\n }\n\n const targetCollection = callTarget(relation, sourceCollection, propertyKey, target);\n\n // The name is the address: the `include` key, the admin tab, and the\n // segment of a nested path. Declared name wins, then the declaring\n // property's key, then the target's slug.\n const relationName = relation.relationName ?? propertyKey ?? toSnakeCase(targetCollection.slug);\n\n const shared: Pick<ResolvedRelation, \"relationName\" | \"target\" | \"targetSlug\" | \"onUpdate\" | \"onDelete\" | \"overrides\"> = {\n relationName,\n // Normalised, not the thunk as written. Resolution reads the target once\n // and every later consumer calls it again — the driver building a join,\n // the DDL and policy generators, the admin's relation fields — so\n // handing back the raw thunk would give all of them the module namespace\n // `callTarget` just looked past, and the fix would hold only for the\n // fields resolution happens to read here. Still lazy: same call at the\n // same moment, one unwrap on the way out.\n target: () => unwrapModuleNamespace(target()) as CollectionConfig,\n targetSlug: targetCollection.slug,\n onUpdate: relation.onUpdate,\n onDelete: relation.onDelete,\n overrides: relation.overrides\n // No `validation`. Whether the link is required is a fact about the\n // *property*, and copying it onto the resolved relation gave the\n // question two answers that were free to disagree. Ask\n // `isRelationRequired(collection, relation)`, which reads the one.\n };\n\n const sourceName = toSnakeCase(sourceCollection.slug ?? sourceCollection.name);\n\n switch (relation.kind) {\n case \"belongsTo\":\n return {\n ...shared,\n kind: \"belongsTo\",\n cardinality: \"one\",\n writable: true,\n shared: false,\n localKey: relation.localKey ?? generateForeignKeyName(relationName),\n index: relation.index !== false\n };\n\n case \"hasOne\":\n return {\n ...shared,\n kind: \"hasOne\",\n cardinality: \"one\",\n writable: true,\n shared: false,\n foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName),\n sourceKey: relation.sourceKey\n };\n\n case \"hasMany\":\n return {\n ...shared,\n kind: \"hasMany\",\n cardinality: \"many\",\n writable: true,\n shared: false,\n foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName),\n // Not defaulted: the source's primary key needs the driver's\n // schema to resolve, which resolution does not have. `undefined`\n // means \"the primary key\" — see `ResolvedHasMany.sourceKey`.\n sourceKey: relation.sourceKey\n };\n\n case \"manyToMany\": {\n const sourceTable = getTableName(sourceCollection);\n const targetTable = getTableName(targetCollection);\n return {\n ...shared,\n kind: \"manyToMany\",\n cardinality: \"many\",\n writable: true,\n shared: true,\n through: {\n // Sorted so both sides of the same link derive the same\n // table without having to agree in advance.\n table: relation.through?.table ?? [sourceTable, targetTable].sort().join(\"_\"),\n sourceColumn: relation.through?.sourceColumn ?? generateForeignKeyName(sourceName),\n targetColumn: relation.through?.targetColumn ?? generateForeignKeyName(relationName),\n // `{}` rather than `undefined`, for the reason every other\n // field here is filled in: a consumer reads one shape and\n // does not have to decide what an absent payload means.\n properties: relation.through?.properties ?? {}\n }\n };\n }\n\n case \"via\":\n return {\n ...shared,\n kind: \"via\",\n cardinality: relation.cardinality,\n writable: false,\n // A join chain reaches rows that other parents reach too, and\n // Rebase does not know which hop, if any, is a link it owns.\n shared: true,\n joinPath: relation.joinPath\n };\n\n default: {\n // Exhaustive: a new kind is a compile error here, not a silent\n // fall-through to whatever shape happened to match first.\n const exhaustive: never = relation;\n throw new Error(`Unknown relation kind: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n\n/** How this relation is addressed in an error message, before it has a resolved name. */\nfunction describe(relation: Relation, sourceCollection: CollectionConfig, propertyKey?: string): string {\n const name = relation.relationName ?? propertyKey;\n return `Relation${name ? ` '${name}'` : \"\"} on '${sourceCollection.slug}'`;\n}\n\n/**\n * A module namespace, unwrapped to the collection it exports.\n *\n * A cycle transpiled to CommonJS does not hand the importing module the\n * *default export* — it hands it the module object, `{ __esModule: true,\n * default: … }`, captured before the exporting module finished evaluating. The\n * `default` slot fills in later, so by the time a lazy `target` thunk runs the\n * collection is sitting right there, one level down. Returning the namespace is\n * never a thing a thunk means to do, and there is exactly one reading of it.\n *\n * Only unwrapped when the inner value is itself a collection: a `default` that\n * is not one is a genuinely wrong thunk, and it should reach the error below\n * rather than be quietly swapped in.\n */\nfunction unwrapModuleNamespace(value: unknown): unknown {\n if (!value || typeof value !== \"object\") return value;\n if ((value as { slug?: unknown }).slug) return value;\n const inner = (value as { default?: unknown }).default;\n return inner && typeof inner === \"object\" && (inner as { slug?: unknown }).slug ? inner : value;\n}\n\n/**\n * Call the `target` thunk, and translate the ways an import cycle breaks it into\n * an error that names the cause — or, where the value is recoverable, into the\n * collection the thunk meant.\n *\n * The thunk exists to defer the reference until every module has finished\n * evaluating, and for a cycle that closes at import time it does. Two cycles\n * leave the binding permanently unusable:\n *\n * - **ESM/TDZ.** `const` and `class` bindings in a not-yet-evaluated module are\n * in the temporal dead zone, so reading one throws `ReferenceError: x is not\n * defined`. The stack points at the thunk — a one-line arrow function that is\n * obviously fine — and says nothing about the cycle that made it throw.\n * - **CJS interop, unresolved.** The half-initialised module object has no\n * `default` yet, the import resolves to `undefined`, and the thunk returns it\n * without complaint. That one used to surface here as \"did not resolve to a\n * collection\", which is true and unhelpful.\n *\n * Both mean the same thing, and the fix for both is the same: break the cycle,\n * or move the relation into the collection that does not close it.\n *\n * A third shape is *not* an error, and used to be reported as one. A loader that\n * transpiles ESM to CJS — jiti, which is what `rebase generate-sdk` and\n * `rebase build` load collections with — gives the module entered second in a\n * cycle a namespace object rather than the default export, and never replaces it\n * with a live binding. The thunk then returns `{ __esModule: true, default: … }`\n * holding the fully-initialised collection. Native ESM resolves the same thunk\n * to the collection directly, so this was a loader artefact reported as an\n * authoring mistake, and the advice it gave — make the target a lazy thunk — was\n * already satisfied by the code it was rejecting. Bidirectional relations make\n * these cycles unavoidable, and the lazy thunk is this framework's own answer to\n * them, so {@link unwrapModuleNamespace} takes the collection and moves on.\n */\nfunction callTarget(\n relation: Relation,\n sourceCollection: CollectionConfig,\n propertyKey: string | undefined,\n target: Relation[\"target\"]\n): ReturnType<Relation[\"target\"]> {\n let targetCollection: ReturnType<Relation[\"target\"]> | undefined;\n try {\n targetCollection = unwrapModuleNamespace(target()) as ReturnType<Relation[\"target\"]>;\n } catch (error) {\n // A ReferenceError from inside the thunk is a binding that was never\n // initialised — nothing else in a one-expression arrow can raise one.\n if (error instanceof ReferenceError) {\n throw new Error(\n `${describe(relation, sourceCollection, propertyKey)} targets a collection that is not ` +\n `initialized yet — almost always an import cycle between the two collection files. ` +\n `Break the cycle (move the shared piece into a third module, or import the target ` +\n `lazily) so the target's module finishes evaluating before the registry is built.`,\n { cause: error }\n );\n }\n throw error;\n }\n\n if (!targetCollection?.slug) {\n throw new Error(\n `${describe(relation, sourceCollection, propertyKey)} has a \\`target\\` that resolved to ` +\n `${targetCollection === undefined ? \"`undefined`\" : \"something that is not a collection\"}. ` +\n (targetCollection === undefined\n ? \"Under CommonJS interop an import cycle resolves the default import to `undefined`, \" +\n \"so check whether this collection and its target import each other. Otherwise the thunk \" +\n \"is returning the wrong value — it must return the collection itself, not a promise or a module.\"\n : typeof (targetCollection as { then?: unknown }).then === \"function\"\n ? \"The thunk returned a promise — `target: () => import(\\\"./other\\\")` is asynchronous. \" +\n \"Import the collection at the top of the file and return the binding: \" +\n \"`target: () => otherCollection`.\"\n : \"The thunk must return a collection config with a `slug`.\")\n );\n }\n\n return targetCollection;\n}\n","import { CollectionConfig, isRelationalCollectionConfig, Property, ResolvedRelation, RelationProperty } from \"@rebasepro/types\";\nimport { toSnakeCase, toWireKey } from \"@rebasepro/utils\";\n\nimport { resolveRelation } from \"./resolve-relation\";\n\n/**\n * Whether the target rows are shared with other parents — a many-to-many, or a\n * multi-hop `via` chain.\n *\n * Decides what a write \"through\" the relation may touch: a shared target\n * belongs to every parent that links it, so the parent owns the *link* and not\n * the row. The backend enforces that (an unlink rather than a delete) and the\n * admin renders it (remove-from-parent rather than delete).\n *\n * Now a field on the resolved relation rather than a re-derivation, so both\n * sides read the same answer instead of each computing one.\n */\nexport function isJunctionBackedRelation(relation: ResolvedRelation): boolean {\n return relation.shared;\n}\n\n/** WeakMap cache — same collection instance always yields the same relation map. */\nconst _resolvedRelationsCache = new WeakMap<CollectionConfig, Record<string, ResolvedRelation>>();\n\n/**\n * Every relation a collection declares, keyed by the name it is addressed by.\n *\n * A relation reaches the map from either of two places — the collection's\n * `relations` array, or a `relation` property that declares one inline — and is\n * keyed by its resolved `relationName`, which is what a nested path segment,\n * an `include` key and an admin tab all match against.\n *\n * Resolution no longer swallows failures. It used to wrap each relation in a\n * `try/catch` that dropped anything it could not work out, so a\n * mis-declared relation silently vanished instead of being reported; with the\n * kind declared, the only remaining failure is a `target` that does not resolve,\n * which is worth hearing about.\n */\nexport function resolveCollectionRelations(\n collection: CollectionConfig\n): Record<string, ResolvedRelation> {\n const cached = _resolvedRelationsCache.get(collection);\n if (cached) return cached;\n\n if (!isRelationalCollectionConfig(collection)) return {};\n\n const relations: Record<string, ResolvedRelation> = {};\n\n for (const relation of collection.relations ?? []) {\n const resolved = resolveRelation(relation, collection);\n relations[resolved.relationName] = resolved;\n }\n\n // A property declaring a relation inline is registered under the property\n // key as well: the fetch layer hydrates the result back onto that key, and\n // it is the name the admin addresses the field by.\n for (const [propertyKey, property] of Object.entries(collection.properties ?? {})) {\n if ((property as Property)?.type !== \"relation\") continue;\n const declared = (property as RelationProperty).relation;\n if (!declared || relations[propertyKey]) continue;\n\n relations[propertyKey] = resolveRelation(declared, collection, propertyKey);\n }\n\n _resolvedRelationsCache.set(collection, relations);\n return relations;\n}\n\n/**\n * The `type: \"relation\"` property that declares a link, or `undefined` for one\n * that only exists in the collection's `relations` array.\n *\n * Both declaration sites end up in {@link resolveCollectionRelations}, and only\n * one of them has a property to carry field-level facts — `name`, `admin`, and\n * the one this exists for, `validation.required`.\n */\nexport function relationDeclaringProperty(\n collection: CollectionConfig,\n relation: ResolvedRelation\n): RelationProperty | undefined {\n const resolved = resolveCollectionRelations(collection);\n for (const [key, raw] of Object.entries(collection.properties ?? {})) {\n const prop = raw as Property | undefined;\n if (prop?.type !== \"relation\") continue;\n // A relation declared inline is keyed by the property; one declared in\n // `relations` is keyed by its name, which the property addresses.\n if (resolved[key] === relation) return prop as RelationProperty;\n const addressed = (prop as RelationProperty).relation?.relationName;\n if (addressed && findRelation(resolved, addressed) === relation) return prop as RelationProperty;\n }\n return undefined;\n}\n\n/**\n * Must every row of this collection point at a target through this link?\n *\n * Read from the declaring property's `validation.required` — the same key every\n * other field uses, and the only place it lives.\n *\n * `RelationBase` carried its own `validation.required` until 0.18, which made\n * this two questions rather than one. They were answered by different readers:\n * the Postgres DDL generator asked the property (so the foreign-key column was\n * `NOT NULL`) and the SDK type generator asked the relation (so the generated\n * `Insert` type made the field optional). A `create()` that left the relation\n * out therefore typechecked and then failed at the database with a not-null\n * violation, and the two `required`s had to be written twice, identically, for\n * the pair to agree.\n *\n * A relation with no declaring property — an entry in `relations` nothing\n * points at — is not required. There is no field to fill in.\n */\nexport function isRelationRequired(collection: CollectionConfig, relation: ResolvedRelation): boolean {\n return Boolean(relationDeclaringProperty(collection, relation)?.validation?.required);\n}\n\n/**\n * The path of the collection a relation property points at, derived from the\n * property alone.\n *\n * A preview holds a property and a value and no collection, so it cannot call\n * `resolveRelationProperty`. It does not need to: both forms that carry a\n * target — the stamped `resolvedRelation` and the inline `relation` — name it\n * directly. Only the third form, a relation declared by name in the\n * collection's `relations` array, is out of reach, and that one has no target\n * to read without the collection anyway.\n *\n * This is what lets a preview render a relation column that arrived as a bare\n * foreign key: the id says *which* row, the declared target says *which\n * collection*, and `RelationPreview` fetches the rest. Without it a scalar id\n * is indistinguishable from a value of the wrong type.\n */\nexport function getRelationTargetPath(property: RelationProperty): string | undefined {\n const stamped = property.resolvedRelation?.targetSlug;\n if (stamped) return stamped;\n\n const target = property.relation?.target;\n if (typeof target !== \"function\") return undefined;\n try {\n return target()?.slug;\n } catch (_e) {\n // A thunk reaching into a module that has not finished initialising:\n // there is no target to name yet, and a preview is not worth throwing over.\n return undefined;\n }\n}\n\n/**\n * The table a collection reads and writes.\n *\n * `table` when it is set, otherwise `toSnakeCase(slug)` — which is what made it\n * safe to drop `table` from the required fields on the config type: the runtime\n * had always derived it, and the type was demanding a value it did not need.\n *\n * The `||` chain is load-bearing. `toSnakeCase(undefined)` returns `\"\"`, not\n * `undefined`, so the previous `??` chain short-circuited on the empty string\n * and the name fallback could never run — a safety net that read like one and\n * caught nothing. It was unreachable while `slug` was required; it stops being\n * unreachable the moment anything constructs a config without one.\n */\nexport function getTableName(collection: CollectionConfig): string {\n const declared = isRelationalCollectionConfig(collection) ? collection.table : undefined;\n return declared || toSnakeCase(collection.slug) || toSnakeCase(collection.name);\n}\n\n/**\n * A JavaScript identifier: what a generated `export const <name> =` needs.\n *\n * Deliberately the same shape the two schema generators already define\n * privately — this is the third place that needed it, and the first two guard\n * property keys and member accesses while nothing guarded the variable name\n * itself.\n */\nconst JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;\n\n/**\n * The variable name a generated table is bound to.\n *\n * Camel-cases underscores, and then guarantees the result is a legal\n * identifier. It did only the first, so a table name that is legal in Postgres\n * and not in JavaScript produced a `schema.generated.ts` that does not parse:\n *\n * `2024_archive` → `export const 2024Archive = pgTable(…)`\n * \"An identifier or keyword cannot immediately follow\n * a numeric literal\"\n * `reporting.events` → `export const reporting.events = pgTable(…)`\n * \"',' expected\"\n *\n * That file is imported by the server, so the failure is not one broken\n * collection — `rebase build` and `db push` fail at tsc for the whole\n * directory. And it is reachable from a documented flow: `rebase init` against\n * a database holding a table called `2024_archive` writes a collection file\n * that parses and a schema file that does not.\n *\n * **A no-op for every name that already worked**, which is what makes changing\n * a derived name safe here: the only inputs whose output changes are the ones\n * that produced a syntax error, and nothing can be running against those.\n * Separators become camel case rather than disappearing, so `reporting.events`\n * and `reporting_events` do not collide into one variable.\n */\nexport function getTableVarName(tableName: string): string {\n const camel = tableName.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());\n if (JS_IDENTIFIER.test(camel)) return camel;\n\n const sanitised = camel\n // Any other separator gets the same treatment `_` did, so two tables\n // differing only by separator keep differing.\n .replace(/[^A-Za-z0-9_$]+([A-Za-z0-9])?/g, (_, char?: string) =>\n (char ? char.toUpperCase() : \"\"))\n // A leading digit is legal in Postgres and not in JavaScript. Prefixed\n // rather than stripped, so `2024_archive` and `archive` stay distinct.\n .replace(/^([0-9])/, \"t$1\");\n\n return JS_IDENTIFIER.test(sanitised) ? sanitised : `t${sanitised}`;\n}\n\nexport function getEnumVarName(tableName: string, propName: string): string {\n const tableVar = getTableVarName(tableName);\n const propVar = propName.charAt(0).toUpperCase() + propName.slice(1);\n return `${tableVar}${propVar}`;\n}\n\nexport function getColumnName(fullColumn: string): string {\n return fullColumn.includes(\".\") ? fullColumn.split(\".\").pop()! : fullColumn;\n}\n\n/**\n * The field key a database column is served and addressed under.\n *\n * A column has two names and they are not the same name. `author_id` is what\n * Postgres stores; `authorId` is the key on the JSON row, the key in the\n * generated Drizzle table, and the key a caller writes in `where` and\n * `orderBy`. Every place that starts from a column and has to reach a row, a\n * Drizzle table or a payload goes through here, so there is one answer rather\n * than one per call site — the two that disagreed put `displayName` and\n * `author_id` on the same API.\n *\n * A declared property is the authority when there is one, because its key *is*\n * the wire name and `columnName` is the only thing that ever renamed the\n * column:\n *\n * 1. an explicit `columnName` equal to this column;\n * 2. a property whose key is literally the column (an author who wrote\n * `author_id:` meant `author_id` on the wire, and gets it);\n * 3. a property whose key snake-cases to the column, which is the default\n * mapping — `authorId` → `author_id`.\n *\n * With no property in the way — a foreign key derived from a relation, which\n * usually has none — the name is derived: {@link toWireKey}.\n *\n * Note the fallback is *not* the column verbatim. That was the old behaviour\n * and it is precisely the defect: a derived foreign key reached the wire under\n * its column name while every hand-authored field beside it was camelCase.\n */\nexport function fieldKeyForColumn(collection: CollectionConfig | undefined, column: string): string {\n const properties = collection?.properties;\n if (properties) {\n for (const [key, prop] of Object.entries(properties)) {\n const columnName = (prop as { columnName?: unknown } | undefined)?.columnName;\n if (typeof columnName === \"string\" && columnName === column) return key;\n }\n for (const key of Object.keys(properties)) {\n if (key === column) return key;\n if (toSnakeCase(key) === column) return key;\n }\n }\n return toWireKey(column);\n}\n\n/**\n * Look up a relation by key with forgiving normalization.\n *\n * `resolveCollectionRelations` stores each relation under a single canonical\n * key (no aliases). This helper tries the given key as-is, then falls back to\n * slug form (underscores → hyphens) and snake_case form (hyphens → underscores)\n * so that callers that receive a key from external input (URL path segments,\n * user-provided config, etc.) can still find the right entry.\n */\nexport function findRelation(\n resolvedRelations: Record<string, ResolvedRelation>,\n key: string\n): ResolvedRelation | undefined {\n // Exact match first\n if (resolvedRelations[key]) return resolvedRelations[key];\n\n // Try slug form (e.g. \"company_id\" → \"company-id\")\n const slugKey = key.replace(/_/g, \"-\");\n if (slugKey !== key && resolvedRelations[slugKey]) return resolvedRelations[slugKey];\n\n // Try snake_case form (e.g. \"company-id\" → \"company_id\")\n const snakeKey = key.replace(/-/g, \"_\");\n if (snakeKey !== key && resolvedRelations[snakeKey]) return resolvedRelations[snakeKey];\n\n return undefined;\n}\n","import type { CollectionConfig, FieldAccess, Property } from \"@rebasepro/types\";\nimport { fieldKeyForColumn, findRelation, resolveCollectionRelations } from \"../util/relations\";\n\n/**\n * Field-level access control: one mechanism, read by every enforcement point.\n *\n * A collection's `securityRules` decide which *rows* a caller reaches;\n * `property.access` decides which *fields* of a reached row they see and may\n * set. The two are independent — a field rule never widens row access, and a\n * row a caller cannot read has no fields to talk about.\n *\n * `excludeFromApi` is sugar for `access: { read: [], write: [] }` and is\n * normalised into it by {@link effectiveAccess}, which is the only place either\n * spelling is read. It used to be its own code path in five files — the read\n * strip, the write refusal, the SDK generator, the OpenAPI schema builder and\n * the filter-parameter builder — and the second rule would have made ten.\n * There is one predicate now, and the flag is a shorthand for it.\n *\n * @module\n */\n\n/**\n * The caller a field rule is judged against: whatever the call context carries\n * as the user's application roles.\n *\n * `undefined` is the trusted server plane — an in-process `rebase.data` call\n * with no request behind it, the auth adapter writing a password hash, a\n * migration. Every API boundary has a viewer: an unauthenticated REST request is\n * scoped as `{ uid: ANONYMOUS_USER_ID, roles: [\"anon\"] }` before it reaches a\n * driver, so \"no viewer\" cannot be reached from outside.\n */\nexport interface FieldViewer {\n roles?: readonly string[];\n}\n\n/**\n * The role that satisfies any non-empty list.\n *\n * The same arm every baseline policy carries: `security_rules` injects\n * `rolesOverlap(['admin'])` into the default read and write policies, and\n * `rebase.dataAsAdmin` is scoped with `{ uid: \"service\", roles: [\"admin\"] }`.\n * Without this an author could declare `access: { read: [\"hr\"] }` and lock the\n * administrator out of a column of their own database — and lock the Studio out\n * of rendering it.\n */\nexport const ADMIN_ROLE = \"admin\";\n\n/**\n * What a property's access rules actually are, with `excludeFromApi` expanded.\n *\n * Returns `undefined` when the property constrains nothing, so callers can skip\n * the whole check for the overwhelmingly common case.\n */\nexport function effectiveAccess(property: Property | undefined): FieldAccess | undefined {\n if (!property) return undefined;\n if (property.excludeFromApi) return EXCLUDED_ACCESS;\n const access = property.access;\n if (!access) return undefined;\n if (access.read === undefined && access.write === undefined) return undefined;\n return access;\n}\n\n/** The rule `excludeFromApi: true` expands to. Frozen: it is shared by every caller. */\nconst EXCLUDED_ACCESS: FieldAccess = Object.freeze({ read: Object.freeze([]), write: Object.freeze([]) });\n\n/**\n * Does a caller holding `roles` satisfy `allowed`?\n *\n * Three cases, and the middle one is the one worth stating out loud:\n *\n * - `allowed` omitted — the field carries no rule of its own, so the row's\n * policies have already answered. True.\n * - `allowed` empty — nobody, at any privilege, through any API. Not the admin,\n * not the service key, not the trusted plane reading on a caller's behalf.\n * This is what `excludeFromApi` has always meant on the read side, and\n * collapsing the two spellings means the empty list has to keep meaning it.\n * - `allowed` non-empty — one of the named roles, or `admin`, or no viewer at\n * all (the trusted server plane, which is not an API caller).\n */\nfunction satisfies(allowed: readonly string[] | undefined, viewer: FieldViewer | undefined): boolean {\n if (allowed === undefined) return true;\n if (allowed.length === 0) return false;\n if (!viewer) return true;\n const roles = viewer.roles;\n if (!roles || roles.length === 0) return false;\n return roles.includes(ADMIN_ROLE) || allowed.some(role => roles.includes(role));\n}\n\n/** May this caller receive this field's value? */\nexport function canReadField(property: Property | undefined, viewer: FieldViewer | undefined): boolean {\n const access = effectiveAccess(property);\n return access ? satisfies(access.read, viewer) : true;\n}\n\n/** May this caller set this field's value? */\nexport function canWriteField(property: Property | undefined, viewer: FieldViewer | undefined): boolean {\n const access = effectiveAccess(property);\n return access ? satisfies(access.write, viewer) : true;\n}\n\n/**\n * The names on this collection a caller may not touch, in the two spellings a\n * caller can write them in.\n *\n * `declared` is the property keys, which is what has to leave a *known-fields*\n * set. `refused` is those plus the physical column names behind them: a caller\n * who knows the table can send `password_hash` as readily as `passwordHash`, and\n * a rule that only knew the wire name would be one rename away from useless.\n *\n * A restricted to-one relation takes its foreign key with it. `bandId: 7` names\n * the band exactly as `band: { id: 7 }` does, and the row carries the column\n * beside the relation — so a rule on the relation that left the key alone\n * withheld the value from the row and still let `?bandId=7` ask for it.\n *\n * `kind` picks which half of the rule is read; nothing else differs.\n */\nexport function restrictedFieldNames(\n collection: CollectionConfig,\n viewer: FieldViewer | undefined,\n kind: \"read\" | \"write\"\n): { declared: string[]; refused: Set<string> } {\n const allowed = kind === \"read\" ? canReadField : canWriteField;\n return namesWhere(collection, property => !allowed(property, viewer));\n}\n\n/**\n * The names on this collection that the API does not have, in every spelling\n * a caller could write them in — the `excludeFromApi` properties, their column\n * names, and the foreign key of an excluded to-one relation.\n *\n * Not the same set as {@link restrictedFieldNames}. A field restricted by role\n * is public by name: OpenAPI and `list_collections` describe it, and refusing a\n * query on it with \"not readable\" tells the caller nothing they could not read\n * there. An `excludeFromApi` field is described nowhere, so a query naming one\n * has to be answered as a query naming a column that does not exist — anything\n * else confirms that it does.\n */\nexport function apiHiddenFieldNames(collection: CollectionConfig): Set<string> {\n return namesWhere(collection, property => Boolean(property?.excludeFromApi)).refused;\n}\n\nfunction namesWhere(\n collection: CollectionConfig,\n restricted: (property: Property) => boolean\n): { declared: string[]; refused: Set<string> } {\n const declared: string[] = [];\n const refused = new Set<string>();\n const properties = collection.properties ?? {};\n // Resolved only when a restricted property is a relation, which is rare:\n // the read strip runs this on every row.\n let relations: ReturnType<typeof resolveCollectionRelations> | undefined;\n\n const restrict = (name: string) => {\n if (name in properties && !declared.includes(name)) declared.push(name);\n refused.add(name);\n };\n\n for (const [name, property] of Object.entries(properties)) {\n if (!restricted(property as Property)) continue;\n restrict(name);\n const columnName = (property as Property).columnName;\n if (columnName) refused.add(columnName);\n if ((property as Property).type === \"relation\") {\n relations ??= resolveCollectionRelations(collection);\n const relation = findRelation(relations, name);\n if (relation?.kind === \"belongsTo\") {\n restrict(fieldKeyForColumn(collection, relation.localKey));\n refused.add(relation.localKey);\n }\n }\n }\n return { declared, refused };\n}\n\n/**\n * True when nothing on this collection restricts a field, for either direction.\n *\n * Every read of every row runs through the strip, so the collection that has no\n * rules — which is almost all of them — has to cost one property walk and no\n * allocation.\n */\nexport function hasFieldAccessRules(collection: CollectionConfig): boolean {\n for (const property of Object.values(collection.properties ?? {})) {\n if (effectiveAccess(property as Property)) return true;\n }\n return false;\n}\n","import { ApiError } from \"../errors\";\n\n/**\n * The two query parameters soft delete adds to the REST surface.\n *\n * Kept in their own module so the call sites in `query-parser.ts` and the\n * delete routes are a single line each: the parsing rules belong to soft\n * delete, not to the parser, and a rule spread across the two files that read\n * it is a rule that drifts.\n */\n\n/** `?deleted=` — what to do about rows a soft delete has stamped. */\nexport const DELETED_QUERY_PARAM = \"deleted\";\n/** `?hard=` — ask for a real `DELETE` on a soft-delete collection. */\nexport const HARD_DELETE_QUERY_PARAM = \"hard\";\n\n/**\n * `?deleted=include|only` → the driver's `withDeleted`.\n *\n * Spelled `deleted` on the wire and `withDeleted` in the driver, deliberately:\n * the URL reads as a question about the rows (`?deleted=only` — \"only the\n * deleted ones\"), and the driver option reads as an instruction about the query.\n *\n * A value neither word is a 400 rather than a silent fallback to the default.\n * `?deleted=true` quietly hiding every deleted row is the worst of both: it\n * looks like it worked and answers the opposite question. Absent is the\n * default, which is \"hide them\".\n */\nexport function parseWithDeleted(raw: unknown): boolean | \"only\" | undefined {\n if (raw === undefined || raw === null || raw === \"\") return undefined;\n const value = String(raw).trim().toLowerCase();\n if (value === \"include\") return true;\n if (value === \"only\") return \"only\";\n throw ApiError.badRequest(\n `Invalid \\`?${DELETED_QUERY_PARAM}=${String(raw)}\\`. It takes 'include' (live rows and deleted ones) ` +\n \"or 'only' (deleted rows alone). Omit it to see only the live rows.\",\n \"INVALID_DELETED_PARAM\"\n );\n}\n\n/**\n * `?hard=true` → a real `DELETE` on a collection that soft-deletes.\n *\n * Needs no permission beyond the delete it replaces: it is the same verb, and a\n * second access-control surface for one operation is a second thing to get\n * wrong. What it changes is whether the row can be restored.\n *\n * Only the exact words `true` and `1` mean yes. Anything else is a 400, not a\n * \"no\" — a typo that silently soft-deletes when the caller asked to purge is a\n * caller who believes the data is gone.\n */\nexport function parseHardDelete(raw: unknown): boolean {\n if (raw === undefined || raw === null || raw === \"\") return false;\n const value = String(raw).trim().toLowerCase();\n if (value === \"true\" || value === \"1\") return true;\n if (value === \"false\" || value === \"0\") return false;\n throw ApiError.badRequest(\n `Invalid \\`?${HARD_DELETE_QUERY_PARAM}=${String(raw)}\\`. It takes 'true' or 'false'.`,\n \"INVALID_HARD_PARAM\"\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;AAqFA,SAAS,SAAS,MAAM,QAAQ;CAC/B,IAAI,WAAW,KAAK,KAAK,WAAW,GAAG,OAAO;CAC9C,MAAM,YAAY;EACjB,cAAc;EACd,eAAe;EACf,mBAAmB;EACnB,YAAY;EACZ,cAAc;EACd,iBAAiB;EACjB,qBAAqB;EACrB,YAAY;EACZ,UAAU;EACV,YAAY;EACZ,eAAe;EACf,mBAAmB;EACnB,aAAa;EACb,aAAa;EACb,sBAAsB;EACtB,cAAc;EACd,YAAY;EACZ,YAAY;EACZ,mBAAmB;EACnB,2BAA2B;EAC3B,gBAAgB;EAChB,iEAAiE;EACjE,YAAY;EACZ,WAAW;EACX,gBAAgB;EAChB,cAAc;EACd,WAAW;EACX,IAAI;CACL;CACA,MAAM,YAAY;EACjB,MAAM;EACN,MAAM;EACN,OAAO;EACP,KAAK;EACL,OAAO;EACP,KAAK;EACL,OAAO;EACP,QAAQ;CACT;CACA,IAAI;EACH;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACD,CAAC,CAAC,QAAQ,KAAK,YAAY,CAAC,KAAK,GAAG,OAAO;CAC3C,KAAK,MAAM,KAAK,WAAW;EAC1B,MAAM,UAAU,IAAI,OAAO,GAAG,UAAU,GAAG,IAAI,GAAG;EAClD,IAAI,QAAQ,KAAK,IAAI,GAAG,OAAO,KAAK,QAAQ,SAAS,CAAC;CACvD;CACA,KAAK,MAAM,OAAO,WAAW;EAC5B,MAAM,UAAU,IAAI,OAAO,KAAK,GAAG;EACnC,IAAI,QAAQ,KAAK,IAAI,GAAG,OAAO,KAAK,QAAQ,SAAS,UAAU,IAAI;CACpE;CACA,OAAO;AACR;;;;;;;;;;;;;;;;;;;;;;;;;;ACzIA,SAAS,uBAAuB,MAAM;CACrC,OAAO,GAAG,YAAY,kBAAkB,IAAI,CAAC,EAAE;AAChD;;;;;;;;;;AAUA,SAAS,kBAAkB,MAAM;CAChC,IAAI,OAAO,KAAK,IAAI,GAAG,OAAO;CAC9B,MAAM,SAAS,SAAS,IAAI;CAC5B,OAAO,OAAO,SAAS,IAAI,SAAS;AACrC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiGA,SAAS,UAAU,YAAY;CAC9B,IAAI,CAAC,YAAY,OAAO;CACxB,MAAM,WAAW,WAAW,MAAM,QAAQ,CAAC,CAAC,OAAO,OAAO;CAC1D,IAAI,SAAS,UAAU,GAAG,OAAO;CACjC,OAAO,SAAS,KAAK,SAAS,UAAU,UAAU,IAAI,QAAQ,OAAO,CAAC,CAAC,CAAC,YAAY,IAAI,QAAQ,MAAM,CAAC,IAAI,QAAQ,OAAO,CAAC,CAAC,CAAC,YAAY,IAAI,QAAQ,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE;AACvK;;;;;;;;;;;;;;;;;;;;ACtHA,SAAgB,gBACZ,UACA,kBACA,aACgB;CAChB,MAAM,SAAS,SAAS;CACxB,IAAI,OAAO,WAAW,YAClB,MAAM,IAAI,MACN,WAAW,SAAS,eAAe,KAAK,SAAS,aAAa,KAAK,GAAG,OAClE,iBAAiB,KAAK,yEAC9B;CAGJ,MAAM,mBAAmB,WAAW,UAAU,kBAAkB,aAAa,MAAM;CAKnF,MAAM,eAAe,SAAS,gBAAgB,eAAe,YAAY,iBAAiB,IAAI;CAE9F,MAAM,SAAmH;EACrH;EAQA,cAAc,sBAAsB,OAAO,CAAC;EAC5C,YAAY,iBAAiB;EAC7B,UAAU,SAAS;EACnB,UAAU,SAAS;EACnB,WAAW,SAAS;CAKxB;CAEA,MAAM,aAAa,YAAY,iBAAiB,QAAQ,iBAAiB,IAAI;CAE7E,QAAQ,SAAS,MAAjB;EACI,KAAK,aACD,OAAO;GACH,GAAG;GACH,MAAM;GACN,aAAa;GACb,UAAU;GACV,QAAQ;GACR,UAAU,SAAS,YAAY,uBAAuB,YAAY;GAClE,OAAO,SAAS,UAAU;EAC9B;EAEJ,KAAK,UACD,OAAO;GACH,GAAG;GACH,MAAM;GACN,aAAa;GACb,UAAU;GACV,QAAQ;GACR,oBAAoB,SAAS,sBAAsB,uBAAuB,UAAU;GACpF,WAAW,SAAS;EACxB;EAEJ,KAAK,WACD,OAAO;GACH,GAAG;GACH,MAAM;GACN,aAAa;GACb,UAAU;GACV,QAAQ;GACR,oBAAoB,SAAS,sBAAsB,uBAAuB,UAAU;GAIpF,WAAW,SAAS;EACxB;EAEJ,KAAK,cAAc;GACf,MAAM,cAAc,aAAa,gBAAgB;GACjD,MAAM,cAAc,aAAa,gBAAgB;GACjD,OAAO;IACH,GAAG;IACH,MAAM;IACN,aAAa;IACb,UAAU;IACV,QAAQ;IACR,SAAS;KAGL,OAAO,SAAS,SAAS,SAAS,CAAC,aAAa,WAAW,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,GAAG;KAC5E,cAAc,SAAS,SAAS,gBAAgB,uBAAuB,UAAU;KACjF,cAAc,SAAS,SAAS,gBAAgB,uBAAuB,YAAY;KAInF,YAAY,SAAS,SAAS,cAAc,CAAC;IACjD;GACJ;EACJ;EAEA,KAAK,OACD,OAAO;GACH,GAAG;GACH,MAAM;GACN,aAAa,SAAS;GACtB,UAAU;GAGV,QAAQ;GACR,UAAU,SAAS;EACvB;EAEJ,SAII,MAAM,IAAI,MAAM,0BAA0B,KAAK,UAAU,QAAU,GAAG;CAE9E;AACJ;;AAGA,SAAS,SAAS,UAAoB,kBAAoC,aAA8B;CACpG,MAAM,OAAO,SAAS,gBAAgB;CACtC,OAAO,WAAW,OAAO,KAAK,KAAK,KAAK,GAAG,OAAO,iBAAiB,KAAK;AAC5E;;;;;;;;;;;;;;;AAgBA,SAAS,sBAAsB,OAAyB;CACpD,IAAI,CAAC,SAAS,OAAO,UAAU,UAAU,OAAO;CAChD,IAAK,MAA6B,MAAM,OAAO;CAC/C,MAAM,QAAS,MAAgC;CAC/C,OAAO,SAAS,OAAO,UAAU,YAAa,MAA6B,OAAO,QAAQ;AAC9F;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmCA,SAAS,WACL,UACA,kBACA,aACA,QAC8B;CAC9B,IAAI;CACJ,IAAI;EACA,mBAAmB,sBAAsB,OAAO,CAAC;CACrD,SAAS,OAAO;EAGZ,IAAI,iBAAiB,gBACjB,MAAM,IAAI,MACN,GAAG,SAAS,UAAU,kBAAkB,WAAW,EAAE,wRAIrD,EAAE,OAAO,MAAM,CACnB;EAEJ,MAAM;CACV;CAEA,IAAI,CAAC,kBAAkB,MACnB,MAAM,IAAI,MACN,GAAG,SAAS,UAAU,kBAAkB,WAAW,EAAE,qCAClD,qBAAqB,KAAA,IAAY,gBAAgB,qCAAqC,OACxF,qBAAqB,KAAA,IAChB,8QAGA,OAAQ,iBAAwC,SAAS,aACrD,8LAGA,2DACd;CAGJ,OAAO;AACX;;;;;;;;;;;;;;;ACzOA,SAAgB,yBAAyB,UAAqC;CAC1E,OAAO,SAAS;AACpB;;AAGA,IAAM,0CAA0B,IAAI,QAA4D;;;;;;;;;;;;;;;AAgBhG,SAAgB,2BACZ,YACgC;CAChC,MAAM,SAAS,wBAAwB,IAAI,UAAU;CACrD,IAAI,QAAQ,OAAO;CAEnB,IAAI,CAAC,6BAA6B,UAAU,GAAG,OAAO,CAAC;CAEvD,MAAM,YAA8C,CAAC;CAErD,KAAK,MAAM,YAAY,WAAW,aAAa,CAAC,GAAG;EAC/C,MAAM,WAAW,gBAAgB,UAAU,UAAU;EACrD,UAAU,SAAS,gBAAgB;CACvC;CAKA,KAAK,MAAM,CAAC,aAAa,aAAa,OAAO,QAAQ,WAAW,cAAc,CAAC,CAAC,GAAG;EAC/E,IAAK,UAAuB,SAAS,YAAY;EACjD,MAAM,WAAY,SAA8B;EAChD,IAAI,CAAC,YAAY,UAAU,cAAc;EAEzC,UAAU,eAAe,gBAAgB,UAAU,YAAY,WAAW;CAC9E;CAEA,wBAAwB,IAAI,YAAY,SAAS;CACjD,OAAO;AACX;;;;;;;;;AAUA,SAAgB,0BACZ,YACA,UAC4B;CAC5B,MAAM,WAAW,2BAA2B,UAAU;CACtD,KAAK,MAAM,CAAC,KAAK,QAAQ,OAAO,QAAQ,WAAW,cAAc,CAAC,CAAC,GAAG;EAClE,MAAM,OAAO;EACb,IAAI,MAAM,SAAS,YAAY;EAG/B,IAAI,SAAS,SAAS,UAAU,OAAO;EACvC,MAAM,YAAa,KAA0B,UAAU;EACvD,IAAI,aAAa,aAAa,UAAU,SAAS,MAAM,UAAU,OAAO;CAC5E;AAEJ;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,mBAAmB,YAA8B,UAAqC;CAClG,OAAO,QAAQ,0BAA0B,YAAY,QAAQ,CAAC,EAAE,YAAY,QAAQ;AACxF;;;;;;;;;;;;;;AA8CA,SAAgB,aAAa,YAAsC;CAE/D,QADiB,6BAA6B,UAAU,IAAI,WAAW,QAAQ,KAAA,MAC5D,YAAY,WAAW,IAAI,KAAK,YAAY,WAAW,IAAI;AAClF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2FA,SAAgB,kBAAkB,YAA0C,QAAwB;CAChG,MAAM,aAAa,YAAY;CAC/B,IAAI,YAAY;EACZ,KAAK,MAAM,CAAC,KAAK,SAAS,OAAO,QAAQ,UAAU,GAAG;GAClD,MAAM,aAAc,MAA+C;GACnE,IAAI,OAAO,eAAe,YAAY,eAAe,QAAQ,OAAO;EACxE;EACA,KAAK,MAAM,OAAO,OAAO,KAAK,UAAU,GAAG;GACvC,IAAI,QAAQ,QAAQ,OAAO;GAC3B,IAAI,YAAY,GAAG,MAAM,QAAQ,OAAO;EAC5C;CACJ;CACA,OAAO,UAAU,MAAM;AAC3B;;;;;;;;;;AAWA,SAAgB,aACZ,mBACA,KAC4B;CAE5B,IAAI,kBAAkB,MAAM,OAAO,kBAAkB;CAGrD,MAAM,UAAU,IAAI,QAAQ,MAAM,GAAG;CACrC,IAAI,YAAY,OAAO,kBAAkB,UAAU,OAAO,kBAAkB;CAG5E,MAAM,WAAW,IAAI,QAAQ,MAAM,GAAG;CACtC,IAAI,aAAa,OAAO,kBAAkB,WAAW,OAAO,kBAAkB;AAGlF;;;;;;;AChPA,SAAgB,gBAAgB,UAAyD;CACrF,IAAI,CAAC,UAAU,OAAO,KAAA;CACtB,IAAI,SAAS,gBAAgB,OAAO;CACpC,MAAM,SAAS,SAAS;CACxB,IAAI,CAAC,QAAQ,OAAO,KAAA;CACpB,IAAI,OAAO,SAAS,KAAA,KAAa,OAAO,UAAU,KAAA,GAAW,OAAO,KAAA;CACpE,OAAO;AACX;;AAGA,IAAM,kBAA+B,OAAO,OAAO;CAAE,MAAM,OAAO,OAAO,CAAC,CAAC;CAAG,OAAO,OAAO,OAAO,CAAC,CAAC;AAAE,CAAC;;;;;;;;;;;;;;;AAgBxG,SAAS,UAAU,SAAwC,QAA0C;CACjG,IAAI,YAAY,KAAA,GAAW,OAAO;CAClC,IAAI,QAAQ,WAAW,GAAG,OAAO;CACjC,IAAI,CAAC,QAAQ,OAAO;CACpB,MAAM,QAAQ,OAAO;CACrB,IAAI,CAAC,SAAS,MAAM,WAAW,GAAG,OAAO;CACzC,OAAO,MAAM,SAAA,OAAmB,KAAK,QAAQ,MAAK,SAAQ,MAAM,SAAS,IAAI,CAAC;AAClF;;AAGA,SAAgB,aAAa,UAAgC,QAA0C;CACnG,MAAM,SAAS,gBAAgB,QAAQ;CACvC,OAAO,SAAS,UAAU,OAAO,MAAM,MAAM,IAAI;AACrD;;AAGA,SAAgB,cAAc,UAAgC,QAA0C;CACpG,MAAM,SAAS,gBAAgB,QAAQ;CACvC,OAAO,SAAS,UAAU,OAAO,OAAO,MAAM,IAAI;AACtD;;;;;;;;;;;;;;;;;AAkBA,SAAgB,qBACZ,YACA,QACA,MAC4C;CAC5C,MAAM,UAAU,SAAS,SAAS,eAAe;CACjD,OAAO,WAAW,aAAY,aAAY,CAAC,QAAQ,UAAU,MAAM,CAAC;AACxE;;;;;;;;;;;;;AAcA,SAAgB,oBAAoB,YAA2C;CAC3E,OAAO,WAAW,aAAY,aAAY,QAAQ,UAAU,cAAc,CAAC,CAAC,CAAC;AACjF;AAEA,SAAS,WACL,YACA,YAC4C;CAC5C,MAAM,WAAqB,CAAC;CAC5B,MAAM,0BAAU,IAAI,IAAY;CAChC,MAAM,aAAa,WAAW,cAAc,CAAC;CAG7C,IAAI;CAEJ,MAAM,YAAY,SAAiB;EAC/B,IAAI,QAAQ,cAAc,CAAC,SAAS,SAAS,IAAI,GAAG,SAAS,KAAK,IAAI;EACtE,QAAQ,IAAI,IAAI;CACpB;CAEA,KAAK,MAAM,CAAC,MAAM,aAAa,OAAO,QAAQ,UAAU,GAAG;EACvD,IAAI,CAAC,WAAW,QAAoB,GAAG;EACvC,SAAS,IAAI;EACb,MAAM,aAAc,SAAsB;EAC1C,IAAI,YAAY,QAAQ,IAAI,UAAU;EACtC,IAAK,SAAsB,SAAS,YAAY;GAC5C,cAAc,2BAA2B,UAAU;GACnD,MAAM,WAAW,aAAa,WAAW,IAAI;GAC7C,IAAI,UAAU,SAAS,aAAa;IAChC,SAAS,kBAAkB,YAAY,SAAS,QAAQ,CAAC;IACzD,QAAQ,IAAI,SAAS,QAAQ;GACjC;EACJ;CACJ;CACA,OAAO;EAAE;EAAU;CAAQ;AAC/B;;;;;;;;;;;;AChKA,IAAa,sBAAsB;;AAEnC,IAAa,0BAA0B;;;;;;;;;;;;;AAcvC,SAAgB,iBAAiB,KAA4C;CACzE,IAAI,QAAQ,KAAA,KAAa,QAAQ,QAAQ,QAAQ,IAAI,OAAO,KAAA;CAC5D,MAAM,QAAQ,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY;CAC7C,IAAI,UAAU,WAAW,OAAO;CAChC,IAAI,UAAU,QAAQ,OAAO;CAC7B,MAAM,SAAS,WACX,cAAc,oBAAoB,GAAG,OAAO,GAAG,EAAE,yHAEjD,uBACJ;AACJ;;;;;;;;;;;;AAaA,SAAgB,gBAAgB,KAAuB;CACnD,IAAI,QAAQ,KAAA,KAAa,QAAQ,QAAQ,QAAQ,IAAI,OAAO;CAC5D,MAAM,QAAQ,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY;CAC7C,IAAI,UAAU,UAAU,UAAU,KAAK,OAAO;CAC9C,IAAI,UAAU,WAAW,UAAU,KAAK,OAAO;CAC/C,MAAM,SAAS,WACX,cAAc,wBAAwB,GAAG,OAAO,GAAG,EAAE,kCACrD,oBACJ;AACJ"}
|
|
@@ -5,7 +5,7 @@ __rebaseCreateRequire(import.meta.url);
|
|
|
5
5
|
import "./src-D5Y3N-S4.js";
|
|
6
6
|
import { t as isSQLAdmin } from "./backend-DTAOsLQc.js";
|
|
7
7
|
import { r as logger } from "./logger-Bt32sHFw.js";
|
|
8
|
-
import { t as ApiError } from "./errors-
|
|
8
|
+
import { t as ApiError } from "./errors-_ytoGi8A.js";
|
|
9
9
|
import { Hono } from "hono";
|
|
10
10
|
//#region ../utils/dist/csv.es.js
|
|
11
11
|
/**
|
|
@@ -237,4 +237,4 @@ function createSqlExportRoutes(options) {
|
|
|
237
237
|
//#endregion
|
|
238
238
|
export { createSqlExportRoutes };
|
|
239
239
|
|
|
240
|
-
//# sourceMappingURL=sql-export-routes-
|
|
240
|
+
//# sourceMappingURL=sql-export-routes-BgTJ7K3S.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sql-export-routes-D86u7N9i.js","names":[],"sources":["../../utils/dist/csv.es.js","../../common/src/util/sql-redaction.ts","../src/api/sql-export-routes.ts"],"sourcesContent":["//#region src/csv.ts\n/**\n* One CSV field (RFC 4180): quoted when it holds a comma, a quote or a line\n* break, with each quote doubled. SQL NULL — `null` or `undefined` — is an\n* empty field, as `COPY … CSV` writes it; an object (a plan, a parsed `jsonb`)\n* is its JSON.\n*/\nfunction csvField(value) {\n\tif (value === null || value === void 0) return \"\";\n\tconst text = typeof value === \"object\" ? JSON.stringify(value) : String(value);\n\treturn /[\",\\r\\n]/.test(text) ? `\"${text.replace(/\"/g, \"\\\"\\\"\")}\"` : text;\n}\n/** One CSV record: each value through {@link csvField}, separated by commas, with no line break. */\nfunction csvRecord(values) {\n\treturn values.map(csvField).join(\",\");\n}\n//#endregion\nexport { csvField, csvRecord };\n\n//# sourceMappingURL=csv.es.js.map","/**\n * SQL a person ran, as the audit log may keep it: every string literal masked.\n *\n * The SQL console's audit line wrote the statement verbatim, so `ALTER ROLE app\n * PASSWORD 'hunter2'`, `CREATE USER MAPPING … OPTIONS (password 'hunter2')` and\n * `dblink_connect('host=db password=hunter2')` put the password in the\n * production logs, where the logger's redaction — by key name — never looks.\n *\n * A literal is a value the statement carries inline, which is exactly what the\n * line already declines to write when it is bound as a parameter: the\n * statement's shape is the audit signal, the values are whatever the operator\n * was touching. So every literal goes, not only the ones beside a word that\n * looks like `password` — a secret has no reliable neighbour. Identifiers,\n * keywords, numbers and comments stay.\n *\n * Read with Postgres's own lexical rules, not a regular expression over the\n * text: a quote inside a comment, a doubled quote, an `E'…\\'…'` escape or a\n * `$tag$` body must not shift where a literal is thought to end, or the mask\n * covers the statement and leaves the secret.\n */\nexport function redactSqlLiterals(sql: string): string {\n let out = \"\";\n let i = 0;\n while (i < sql.length) {\n const char = sql[i];\n const next = sql[i + 1];\n\n // -- line comment\n if (char === \"-\" && next === \"-\") {\n const end = sql.indexOf(\"\\n\", i);\n const stop = end === -1 ? sql.length : end;\n out += sql.slice(i, stop);\n i = stop;\n continue;\n }\n\n // /* block comment */ — nested, as Postgres reads them\n if (char === \"/\" && next === \"*\") {\n let depth = 1;\n let j = i + 2;\n while (j < sql.length && depth > 0) {\n if (sql[j] === \"/\" && sql[j + 1] === \"*\") { depth++; j += 2; } else if (sql[j] === \"*\" && sql[j + 1] === \"/\") { depth--; j += 2; } else j++;\n }\n out += sql.slice(i, j);\n i = j;\n continue;\n }\n\n // \"quoted identifier\"\n if (char === \"\\\"\") {\n const end = closingQuote(sql, i + 1, \"\\\"\", false);\n out += sql.slice(i, end);\n i = end;\n continue;\n }\n\n // 'string', E'string' with backslash escapes, and the B'', X'', N'',\n // U&'' prefixes — the prefix is kept, the content masked.\n if (char === \"'\") {\n const prefix = /(?:^|[^A-Za-z0-9_$])([EeBbXxNn]|[Uu]&)$/.exec(out)?.[1] ?? \"\";\n out += \"'***'\";\n i = closingQuote(sql, i + 1, \"'\", prefix.toUpperCase() === \"E\");\n continue;\n }\n\n // $tag$ … $tag$ — a dollar-quoted string. A function or DO body is SQL\n // in its own right, and its literals are masked as these are; a\n // password written as `PASSWORD $$hunter2$$` is masked whole.\n if (char === \"$\") {\n const tag = /^\\$([A-Za-z_][A-Za-z0-9_]*)?\\$/.exec(sql.slice(i));\n const precededByIdentifier = /[A-Za-z0-9_]$/.test(out);\n if (tag && !precededByIdentifier) {\n const delimiter = tag[0];\n const bodyStart = i + delimiter.length;\n const bodyEnd = sql.indexOf(delimiter, bodyStart);\n const stop = bodyEnd === -1 ? sql.length : bodyEnd;\n const body = sql.slice(bodyStart, stop);\n const isPassword = /\\bpassword\\s*$/i.test(out);\n out += delimiter + (isPassword ? \"***\" : redactSqlLiterals(body)) + (bodyEnd === -1 ? \"\" : delimiter);\n i = bodyEnd === -1 ? sql.length : bodyEnd + delimiter.length;\n continue;\n }\n }\n\n out += char;\n i++;\n }\n return out;\n}\n\n/**\n * The index just past the quote that closes a literal or identifier opened\n * before `from`. A doubled quote is one quote inside it; with `backslashEscapes`\n * (an `E''` string) so is a backslash-escaped one. Unterminated runs to the end.\n */\nfunction closingQuote(sql: string, from: number, quote: string, backslashEscapes: boolean): number {\n let j = from;\n while (j < sql.length) {\n if (backslashEscapes && sql[j] === \"\\\\\") { j += 2; continue; }\n if (sql[j] === quote) {\n if (sql[j + 1] === quote) { j += 2; continue; }\n return j + 1;\n }\n j++;\n }\n return sql.length;\n}\n","import { Hono } from \"hono\";\nimport { isSQLAdmin, type DatabaseAdmin, type SqlCursor } from \"@rebasepro/types\";\nimport { redactSqlLiterals } from \"@rebasepro/common\";\nimport { csvRecord } from \"@rebasepro/utils\";\nimport { ApiError } from \"./errors\";\nimport type { HonoEnv } from \"./types\";\nimport { logger } from \"../utils/logger\";\n\n/**\n * The SQL console's \"Download full result as CSV\".\n *\n * The console shows at most the first 10,000 rows of a result (see\n * `SQL_CONSOLE_RESULT_LIMITS`), and its own export writes the rows on screen.\n * This route is the way to the rest: it runs ONE query through\n * `SQLAdmin.openSqlCursor` — a server-side cursor in a read-only transaction,\n * as the role the console picked — and streams it as RFC 4180 CSV, one batch\n * at a time. A batch is fetched only when the response has taken the last\n * one, so the server holds one batch however long the result is.\n *\n * Mounted under `/api/admin/sql` behind the admin gate with `database:write` —\n * the scope the socket's `EXECUTE_SQL` asks of the same console, so whoever\n * can run the query can download it and nobody else.\n */\n\n/** RFC 4180's record separator. */\nconst CRLF = \"\\r\\n\";\n\nexport interface SqlExportRoutesOptions {\n /** The default data source's admin capabilities, read per request. */\n getAdmin: () => DatabaseAdmin | undefined;\n}\n\ninterface ExportRequest {\n sql: string;\n database?: string;\n role?: string;\n}\n\nfunction readExportRequest(body: unknown): ExportRequest {\n if (typeof body !== \"object\" || body === null || !(\"sql\" in body) || typeof body.sql !== \"string\" || body.sql.trim() === \"\") {\n throw ApiError.badRequest(\"Send the query to download as `sql`, a non-empty string.\", \"INVALID_INPUT\");\n }\n const optional = (key: \"database\" | \"role\"): string | undefined => {\n const value: unknown = key in body ? Reflect.get(body, key) : undefined;\n if (value === undefined || value === null || value === \"\") return undefined;\n if (typeof value !== \"string\") throw ApiError.badRequest(`\\`${key}\\` must be a string.`, \"INVALID_INPUT\");\n return value;\n };\n return { sql: body.sql, database: optional(\"database\"), role: optional(\"role\") };\n}\n\n/**\n * The cursor's rows as CSV, a header record first. Read one batch per pull:\n * the stream asks for the next only when the response has taken this one.\n *\n * A failure after the first byte cannot change the status any more, so the\n * stream is errored instead — the download fails rather than ending early\n * with a file that looks whole.\n */\nfunction csvStream(cursor: SqlCursor, onFailure: (error: unknown) => void): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n let headerSent = false;\n return new ReadableStream<Uint8Array>({\n async pull(controller) {\n try {\n if (!headerSent) {\n headerSent = true;\n controller.enqueue(encoder.encode(csvRecord(cursor.columns) + CRLF));\n return;\n }\n const rows = await cursor.read();\n if (!rows) {\n await cursor.close();\n controller.close();\n return;\n }\n controller.enqueue(encoder.encode(rows.map(row => csvRecord(row) + CRLF).join(\"\")));\n } catch (error: unknown) {\n onFailure(error);\n await cursor.close();\n controller.error(error);\n }\n },\n async cancel() {\n // The reader went away: end the read and give the session back.\n await cursor.close();\n }\n }, { highWaterMark: 1 });\n}\n\nexport function createSqlExportRoutes(options: SqlExportRoutesOptions): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n\n router.post(\"/csv\", async (c) => {\n const request = readExportRequest(await c.req.json());\n const admin = options.getAdmin();\n if (!isSQLAdmin(admin) || typeof admin.openSqlCursor !== \"function\") {\n throw new ApiError(501, \"SQL_EXPORT_UNSUPPORTED\",\n \"This data source cannot stream a query's result. Add a LIMIT and export the rows on screen.\");\n }\n\n const caller = c.get(\"user\");\n const user = typeof caller === \"object\" && caller !== null ? caller : undefined;\n const audit = {\n // Literals masked, as on the socket: a statement's shape is the\n // audit signal, its values are whatever the operator was reading.\n sql: redactSqlLiterals(request.sql).substring(0, 500),\n database: request.database,\n role: request.role,\n uid: user?.uid ?? \"unknown\",\n roles: user?.roles ?? [],\n ...(c.get(\"apiKey\") ? { apiKey: c.get(\"apiKey\")?.id } : {}),\n requestId: c.get(\"requestId\")\n };\n\n // Opened before the response starts: a query the database refuses — a\n // write, a second statement, a syntax error — is a 400 with its\n // reason, not an empty file.\n const cursor = await admin.openSqlCursor(request.sql, { database: request.database, role: request.role });\n logger.info(\"[SQL Audit] SQL console CSV download\", audit);\n\n const body = csvStream(cursor, (error) => logger.warn(\"[SQL export] The download failed part-way and was cut off\", {\n requestId: audit.requestId,\n error: error instanceof Error ? error.message : String(error)\n }));\n return new Response(body, {\n status: 200,\n headers: {\n \"Content-Type\": \"text/csv; charset=utf-8\",\n \"Content-Disposition\": \"attachment; filename=\\\"query_results.csv\\\"\",\n \"Cache-Control\": \"no-store\"\n }\n });\n });\n\n return router;\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAOA,SAAS,SAAS,OAAO;CACxB,IAAI,UAAU,QAAQ,UAAU,KAAK,GAAG,OAAO;CAC/C,MAAM,OAAO,OAAO,UAAU,WAAW,KAAK,UAAU,KAAK,IAAI,OAAO,KAAK;CAC7E,OAAO,WAAW,KAAK,IAAI,IAAI,IAAI,KAAK,QAAQ,MAAM,MAAM,EAAE,KAAK;AACpE;;AAEA,SAAS,UAAU,QAAQ;CAC1B,OAAO,OAAO,IAAI,QAAQ,CAAC,CAAC,KAAK,GAAG;AACrC;;;;;;;;;;;;;;;;;;;;;;;ACKA,SAAgB,kBAAkB,KAAqB;CACnD,IAAI,MAAM;CACV,IAAI,IAAI;CACR,OAAO,IAAI,IAAI,QAAQ;EACnB,MAAM,OAAO,IAAI;EACjB,MAAM,OAAO,IAAI,IAAI;EAGrB,IAAI,SAAS,OAAO,SAAS,KAAK;GAC9B,MAAM,MAAM,IAAI,QAAQ,MAAM,CAAC;GAC/B,MAAM,OAAO,QAAQ,KAAK,IAAI,SAAS;GACvC,OAAO,IAAI,MAAM,GAAG,IAAI;GACxB,IAAI;GACJ;EACJ;EAGA,IAAI,SAAS,OAAO,SAAS,KAAK;GAC9B,IAAI,QAAQ;GACZ,IAAI,IAAI,IAAI;GACZ,OAAO,IAAI,IAAI,UAAU,QAAQ,GAC7B,IAAI,IAAI,OAAO,OAAO,IAAI,IAAI,OAAO,KAAK;IAAE;IAAS,KAAK;GAAG,OAAO,IAAI,IAAI,OAAO,OAAO,IAAI,IAAI,OAAO,KAAK;IAAE;IAAS,KAAK;GAAG,OAAO;GAE5I,OAAO,IAAI,MAAM,GAAG,CAAC;GACrB,IAAI;GACJ;EACJ;EAGA,IAAI,SAAS,MAAM;GACf,MAAM,MAAM,aAAa,KAAK,IAAI,GAAG,MAAM,KAAK;GAChD,OAAO,IAAI,MAAM,GAAG,GAAG;GACvB,IAAI;GACJ;EACJ;EAIA,IAAI,SAAS,KAAK;GACd,MAAM,SAAS,0CAA0C,KAAK,GAAG,CAAC,GAAG,MAAM;GAC3E,OAAO;GACP,IAAI,aAAa,KAAK,IAAI,GAAG,KAAK,OAAO,YAAY,MAAM,GAAG;GAC9D;EACJ;EAKA,IAAI,SAAS,KAAK;GACd,MAAM,MAAM,iCAAiC,KAAK,IAAI,MAAM,CAAC,CAAC;GAC9D,MAAM,uBAAuB,gBAAgB,KAAK,GAAG;GACrD,IAAI,OAAO,CAAC,sBAAsB;IAC9B,MAAM,YAAY,IAAI;IACtB,MAAM,YAAY,IAAI,UAAU;IAChC,MAAM,UAAU,IAAI,QAAQ,WAAW,SAAS;IAChD,MAAM,OAAO,YAAY,KAAK,IAAI,SAAS;IAC3C,MAAM,OAAO,IAAI,MAAM,WAAW,IAAI;IACtC,MAAM,aAAa,kBAAkB,KAAK,GAAG;IAC7C,OAAO,aAAa,aAAa,QAAQ,kBAAkB,IAAI,MAAM,YAAY,KAAK,KAAK;IAC3F,IAAI,YAAY,KAAK,IAAI,SAAS,UAAU,UAAU;IACtD;GACJ;EACJ;EAEA,OAAO;EACP;CACJ;CACA,OAAO;AACX;;;;;;AAOA,SAAS,aAAa,KAAa,MAAc,OAAe,kBAAmC;CAC/F,IAAI,IAAI;CACR,OAAO,IAAI,IAAI,QAAQ;EACnB,IAAI,oBAAoB,IAAI,OAAO,MAAM;GAAE,KAAK;GAAG;EAAU;EAC7D,IAAI,IAAI,OAAO,OAAO;GAClB,IAAI,IAAI,IAAI,OAAO,OAAO;IAAE,KAAK;IAAG;GAAU;GAC9C,OAAO,IAAI;EACf;EACA;CACJ;CACA,OAAO,IAAI;AACf;;;;;;;;;;;;;;;;;;;ACjFA,IAAM,OAAO;AAab,SAAS,kBAAkB,MAA8B;CACrD,IAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,EAAE,SAAS,SAAS,OAAO,KAAK,QAAQ,YAAY,KAAK,IAAI,KAAK,MAAM,IACrH,MAAM,SAAS,WAAW,4DAA4D,eAAe;CAEzG,MAAM,YAAY,QAAiD;EAC/D,MAAM,QAAiB,OAAO,OAAO,QAAQ,IAAI,MAAM,GAAG,IAAI,KAAA;EAC9D,IAAI,UAAU,KAAA,KAAa,UAAU,QAAQ,UAAU,IAAI,OAAO,KAAA;EAClE,IAAI,OAAO,UAAU,UAAU,MAAM,SAAS,WAAW,KAAK,IAAI,uBAAuB,eAAe;EACxG,OAAO;CACX;CACA,OAAO;EAAE,KAAK,KAAK;EAAK,UAAU,SAAS,UAAU;EAAG,MAAM,SAAS,MAAM;CAAE;AACnF;;;;;;;;;AAUA,SAAS,UAAU,QAAmB,WAAiE;CACnG,MAAM,UAAU,IAAI,YAAY;CAChC,IAAI,aAAa;CACjB,OAAO,IAAI,eAA2B;EAClC,MAAM,KAAK,YAAY;GACnB,IAAI;IACA,IAAI,CAAC,YAAY;KACb,aAAa;KACb,WAAW,QAAQ,QAAQ,OAAO,UAAU,OAAO,OAAO,IAAI,IAAI,CAAC;KACnE;IACJ;IACA,MAAM,OAAO,MAAM,OAAO,KAAK;IAC/B,IAAI,CAAC,MAAM;KACP,MAAM,OAAO,MAAM;KACnB,WAAW,MAAM;KACjB;IACJ;IACA,WAAW,QAAQ,QAAQ,OAAO,KAAK,KAAI,QAAO,UAAU,GAAG,IAAI,IAAI,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;GACtF,SAAS,OAAgB;IACrB,UAAU,KAAK;IACf,MAAM,OAAO,MAAM;IACnB,WAAW,MAAM,KAAK;GAC1B;EACJ;EACA,MAAM,SAAS;GAEX,MAAM,OAAO,MAAM;EACvB;CACJ,GAAG,EAAE,eAAe,EAAE,CAAC;AAC3B;AAEA,SAAgB,sBAAsB,SAAgD;CAClF,MAAM,SAAS,IAAI,KAAc;CAEjC,OAAO,KAAK,QAAQ,OAAO,MAAM;EAC7B,MAAM,UAAU,kBAAkB,MAAM,EAAE,IAAI,KAAK,CAAC;EACpD,MAAM,QAAQ,QAAQ,SAAS;EAC/B,IAAI,CAAC,WAAW,KAAK,KAAK,OAAO,MAAM,kBAAkB,YACrD,MAAM,IAAI,SAAS,KAAK,0BACpB,6FAA6F;EAGrG,MAAM,SAAS,EAAE,IAAI,MAAM;EAC3B,MAAM,OAAO,OAAO,WAAW,YAAY,WAAW,OAAO,SAAS,KAAA;EACtE,MAAM,QAAQ;GAGV,KAAK,kBAAkB,QAAQ,GAAG,CAAC,CAAC,UAAU,GAAG,GAAG;GACpD,UAAU,QAAQ;GAClB,MAAM,QAAQ;GACd,KAAK,MAAM,OAAO;GAClB,OAAO,MAAM,SAAS,CAAC;GACvB,GAAI,EAAE,IAAI,QAAQ,IAAI,EAAE,QAAQ,EAAE,IAAI,QAAQ,CAAC,EAAE,GAAG,IAAI,CAAC;GACzD,WAAW,EAAE,IAAI,WAAW;EAChC;EAKA,MAAM,SAAS,MAAM,MAAM,cAAc,QAAQ,KAAK;GAAE,UAAU,QAAQ;GAAU,MAAM,QAAQ;EAAK,CAAC;EACxG,OAAO,KAAK,wCAAwC,KAAK;EAEzD,MAAM,OAAO,UAAU,SAAS,UAAU,OAAO,KAAK,6DAA6D;GAC/G,WAAW,MAAM;GACjB,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EAChE,CAAC,CAAC;EACF,OAAO,IAAI,SAAS,MAAM;GACtB,QAAQ;GACR,SAAS;IACL,gBAAgB;IAChB,uBAAuB;IACvB,iBAAiB;GACrB;EACJ,CAAC;CACL,CAAC;CAED,OAAO;AACX"}
|
|
1
|
+
{"version":3,"file":"sql-export-routes-BgTJ7K3S.js","names":[],"sources":["../../utils/dist/csv.es.js","../../common/src/util/sql-redaction.ts","../src/api/sql-export-routes.ts"],"sourcesContent":["//#region src/csv.ts\n/**\n* One CSV field (RFC 4180): quoted when it holds a comma, a quote or a line\n* break, with each quote doubled. SQL NULL — `null` or `undefined` — is an\n* empty field, as `COPY … CSV` writes it; an object (a plan, a parsed `jsonb`)\n* is its JSON.\n*/\nfunction csvField(value) {\n\tif (value === null || value === void 0) return \"\";\n\tconst text = typeof value === \"object\" ? JSON.stringify(value) : String(value);\n\treturn /[\",\\r\\n]/.test(text) ? `\"${text.replace(/\"/g, \"\\\"\\\"\")}\"` : text;\n}\n/** One CSV record: each value through {@link csvField}, separated by commas, with no line break. */\nfunction csvRecord(values) {\n\treturn values.map(csvField).join(\",\");\n}\n//#endregion\nexport { csvField, csvRecord };\n\n//# sourceMappingURL=csv.es.js.map","/**\n * SQL a person ran, as the audit log may keep it: every string literal masked.\n *\n * The SQL console's audit line wrote the statement verbatim, so `ALTER ROLE app\n * PASSWORD 'hunter2'`, `CREATE USER MAPPING … OPTIONS (password 'hunter2')` and\n * `dblink_connect('host=db password=hunter2')` put the password in the\n * production logs, where the logger's redaction — by key name — never looks.\n *\n * A literal is a value the statement carries inline, which is exactly what the\n * line already declines to write when it is bound as a parameter: the\n * statement's shape is the audit signal, the values are whatever the operator\n * was touching. So every literal goes, not only the ones beside a word that\n * looks like `password` — a secret has no reliable neighbour. Identifiers,\n * keywords, numbers and comments stay.\n *\n * Read with Postgres's own lexical rules, not a regular expression over the\n * text: a quote inside a comment, a doubled quote, an `E'…\\'…'` escape or a\n * `$tag$` body must not shift where a literal is thought to end, or the mask\n * covers the statement and leaves the secret.\n */\nexport function redactSqlLiterals(sql: string): string {\n let out = \"\";\n let i = 0;\n while (i < sql.length) {\n const char = sql[i];\n const next = sql[i + 1];\n\n // -- line comment\n if (char === \"-\" && next === \"-\") {\n const end = sql.indexOf(\"\\n\", i);\n const stop = end === -1 ? sql.length : end;\n out += sql.slice(i, stop);\n i = stop;\n continue;\n }\n\n // /* block comment */ — nested, as Postgres reads them\n if (char === \"/\" && next === \"*\") {\n let depth = 1;\n let j = i + 2;\n while (j < sql.length && depth > 0) {\n if (sql[j] === \"/\" && sql[j + 1] === \"*\") { depth++; j += 2; } else if (sql[j] === \"*\" && sql[j + 1] === \"/\") { depth--; j += 2; } else j++;\n }\n out += sql.slice(i, j);\n i = j;\n continue;\n }\n\n // \"quoted identifier\"\n if (char === \"\\\"\") {\n const end = closingQuote(sql, i + 1, \"\\\"\", false);\n out += sql.slice(i, end);\n i = end;\n continue;\n }\n\n // 'string', E'string' with backslash escapes, and the B'', X'', N'',\n // U&'' prefixes — the prefix is kept, the content masked.\n if (char === \"'\") {\n const prefix = /(?:^|[^A-Za-z0-9_$])([EeBbXxNn]|[Uu]&)$/.exec(out)?.[1] ?? \"\";\n out += \"'***'\";\n i = closingQuote(sql, i + 1, \"'\", prefix.toUpperCase() === \"E\");\n continue;\n }\n\n // $tag$ … $tag$ — a dollar-quoted string. A function or DO body is SQL\n // in its own right, and its literals are masked as these are; a\n // password written as `PASSWORD $$hunter2$$` is masked whole.\n if (char === \"$\") {\n const tag = /^\\$([A-Za-z_][A-Za-z0-9_]*)?\\$/.exec(sql.slice(i));\n const precededByIdentifier = /[A-Za-z0-9_]$/.test(out);\n if (tag && !precededByIdentifier) {\n const delimiter = tag[0];\n const bodyStart = i + delimiter.length;\n const bodyEnd = sql.indexOf(delimiter, bodyStart);\n const stop = bodyEnd === -1 ? sql.length : bodyEnd;\n const body = sql.slice(bodyStart, stop);\n const isPassword = /\\bpassword\\s*$/i.test(out);\n out += delimiter + (isPassword ? \"***\" : redactSqlLiterals(body)) + (bodyEnd === -1 ? \"\" : delimiter);\n i = bodyEnd === -1 ? sql.length : bodyEnd + delimiter.length;\n continue;\n }\n }\n\n out += char;\n i++;\n }\n return out;\n}\n\n/**\n * The index just past the quote that closes a literal or identifier opened\n * before `from`. A doubled quote is one quote inside it; with `backslashEscapes`\n * (an `E''` string) so is a backslash-escaped one. Unterminated runs to the end.\n */\nfunction closingQuote(sql: string, from: number, quote: string, backslashEscapes: boolean): number {\n let j = from;\n while (j < sql.length) {\n if (backslashEscapes && sql[j] === \"\\\\\") { j += 2; continue; }\n if (sql[j] === quote) {\n if (sql[j + 1] === quote) { j += 2; continue; }\n return j + 1;\n }\n j++;\n }\n return sql.length;\n}\n","import { Hono } from \"hono\";\nimport { isSQLAdmin, type DatabaseAdmin, type SqlCursor } from \"@rebasepro/types\";\nimport { redactSqlLiterals } from \"@rebasepro/common\";\nimport { csvRecord } from \"@rebasepro/utils\";\nimport { ApiError } from \"./errors\";\nimport type { HonoEnv } from \"./types\";\nimport { logger } from \"../utils/logger\";\n\n/**\n * The SQL console's \"Download full result as CSV\".\n *\n * The console shows at most the first 10,000 rows of a result (see\n * `SQL_CONSOLE_RESULT_LIMITS`), and its own export writes the rows on screen.\n * This route is the way to the rest: it runs ONE query through\n * `SQLAdmin.openSqlCursor` — a server-side cursor in a read-only transaction,\n * as the role the console picked — and streams it as RFC 4180 CSV, one batch\n * at a time. A batch is fetched only when the response has taken the last\n * one, so the server holds one batch however long the result is.\n *\n * Mounted under `/api/admin/sql` behind the admin gate with `database:write` —\n * the scope the socket's `EXECUTE_SQL` asks of the same console, so whoever\n * can run the query can download it and nobody else.\n */\n\n/** RFC 4180's record separator. */\nconst CRLF = \"\\r\\n\";\n\nexport interface SqlExportRoutesOptions {\n /** The default data source's admin capabilities, read per request. */\n getAdmin: () => DatabaseAdmin | undefined;\n}\n\ninterface ExportRequest {\n sql: string;\n database?: string;\n role?: string;\n}\n\nfunction readExportRequest(body: unknown): ExportRequest {\n if (typeof body !== \"object\" || body === null || !(\"sql\" in body) || typeof body.sql !== \"string\" || body.sql.trim() === \"\") {\n throw ApiError.badRequest(\"Send the query to download as `sql`, a non-empty string.\", \"INVALID_INPUT\");\n }\n const optional = (key: \"database\" | \"role\"): string | undefined => {\n const value: unknown = key in body ? Reflect.get(body, key) : undefined;\n if (value === undefined || value === null || value === \"\") return undefined;\n if (typeof value !== \"string\") throw ApiError.badRequest(`\\`${key}\\` must be a string.`, \"INVALID_INPUT\");\n return value;\n };\n return { sql: body.sql, database: optional(\"database\"), role: optional(\"role\") };\n}\n\n/**\n * The cursor's rows as CSV, a header record first. Read one batch per pull:\n * the stream asks for the next only when the response has taken this one.\n *\n * A failure after the first byte cannot change the status any more, so the\n * stream is errored instead — the download fails rather than ending early\n * with a file that looks whole.\n */\nfunction csvStream(cursor: SqlCursor, onFailure: (error: unknown) => void): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n let headerSent = false;\n return new ReadableStream<Uint8Array>({\n async pull(controller) {\n try {\n if (!headerSent) {\n headerSent = true;\n controller.enqueue(encoder.encode(csvRecord(cursor.columns) + CRLF));\n return;\n }\n const rows = await cursor.read();\n if (!rows) {\n await cursor.close();\n controller.close();\n return;\n }\n controller.enqueue(encoder.encode(rows.map(row => csvRecord(row) + CRLF).join(\"\")));\n } catch (error: unknown) {\n onFailure(error);\n await cursor.close();\n controller.error(error);\n }\n },\n async cancel() {\n // The reader went away: end the read and give the session back.\n await cursor.close();\n }\n }, { highWaterMark: 1 });\n}\n\nexport function createSqlExportRoutes(options: SqlExportRoutesOptions): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n\n router.post(\"/csv\", async (c) => {\n const request = readExportRequest(await c.req.json());\n const admin = options.getAdmin();\n if (!isSQLAdmin(admin) || typeof admin.openSqlCursor !== \"function\") {\n throw new ApiError(501, \"SQL_EXPORT_UNSUPPORTED\",\n \"This data source cannot stream a query's result. Add a LIMIT and export the rows on screen.\");\n }\n\n const caller = c.get(\"user\");\n const user = typeof caller === \"object\" && caller !== null ? caller : undefined;\n const audit = {\n // Literals masked, as on the socket: a statement's shape is the\n // audit signal, its values are whatever the operator was reading.\n sql: redactSqlLiterals(request.sql).substring(0, 500),\n database: request.database,\n role: request.role,\n uid: user?.uid ?? \"unknown\",\n roles: user?.roles ?? [],\n ...(c.get(\"apiKey\") ? { apiKey: c.get(\"apiKey\")?.id } : {}),\n requestId: c.get(\"requestId\")\n };\n\n // Opened before the response starts: a query the database refuses — a\n // write, a second statement, a syntax error — is a 400 with its\n // reason, not an empty file.\n const cursor = await admin.openSqlCursor(request.sql, { database: request.database, role: request.role });\n logger.info(\"[SQL Audit] SQL console CSV download\", audit);\n\n const body = csvStream(cursor, (error) => logger.warn(\"[SQL export] The download failed part-way and was cut off\", {\n requestId: audit.requestId,\n error: error instanceof Error ? error.message : String(error)\n }));\n return new Response(body, {\n status: 200,\n headers: {\n \"Content-Type\": \"text/csv; charset=utf-8\",\n \"Content-Disposition\": \"attachment; filename=\\\"query_results.csv\\\"\",\n \"Cache-Control\": \"no-store\"\n }\n });\n });\n\n return router;\n}\n"],"mappings":"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"}
|
|
@@ -2,7 +2,7 @@ import { createRequire as __rebaseCreateRequire } from "module";
|
|
|
2
2
|
import __rebaseProcess from "process";
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
|
-
import { t as ApiError } from "./errors-
|
|
5
|
+
import { t as ApiError } from "./errors-_ytoGi8A.js";
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
//#region src/storage/types.ts
|
|
8
8
|
/**
|
|
@@ -169,4 +169,4 @@ function validateUploadedFile(file, maxSize, allowedMimeTypes) {
|
|
|
169
169
|
//#endregion
|
|
170
170
|
export { readStreamFully as a, DOCUMENT_MIME_TYPES as c, fileWebStream as i, IMAGE_MIME_TYPES as l, bufferWebStream as n, webStreamFrom as o, bytesView as r, DEFAULT_MAX_FILE_SIZE as s, validateUploadedFile as t };
|
|
171
171
|
|
|
172
|
-
//# sourceMappingURL=upload-validation-
|
|
172
|
+
//# sourceMappingURL=upload-validation-I9cCUBIW.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upload-validation-pXcDy_EQ.js","names":[],"sources":["../src/storage/types.ts","../src/storage/streams.ts","../src/storage/upload-validation.ts"],"sourcesContent":["/**\n * Storage configuration and types for Rebase backend\n */\n\nimport { StorageSource, UploadFileProps, UploadFileResult, DownloadConfig, DownloadMetadata, StorageListResult, StorageReference } from \"@rebasepro/types\";\n\n/**\n * Local filesystem storage configuration\n */\nexport interface LocalStorageConfig {\n type: \"local\";\n /** Base directory for file storage (e.g., './uploads') */\n basePath: string;\n /** Maximum file size in bytes (default: 50MB) */\n maxFileSize?: number;\n /** Allowed MIME types (if not set, all types allowed) */\n allowedMimeTypes?: string[];\n /** Base URL for generating download URLs (default: auto-detected from request) */\n baseUrl?: string;\n /**\n * Set when this local backend stands in for an object store the source\n * declared and nothing bound — `bucket(\"media\", { engine: \"s3\" })` with no\n * `S3_BUCKET__MEDIA` in a development process. Names the engine it stands\n * in for, so boot can say so and `rebase status` can show it. Never set in\n * production, where an unbound object store stays unbound.\n */\n standsInFor?: string;\n /**\n * Set when nothing chose this backend: no `STORAGE_TYPE` and no declared\n * engine, so local disk is only the default a source falls back to.\n * Production drops a local backend either way. This only decides how that\n * is reported: an implicit default being dropped means the project uses no\n * storage, which is not a misconfiguration.\n */\n implicit?: boolean;\n}\n\n/**\n * S3-compatible storage configuration (works with AWS S3 and MinIO)\n */\nexport interface S3StorageConfig {\n type: \"s3\";\n /** S3 bucket name */\n bucket: string;\n /** AWS region (e.g., 'us-east-1') */\n region?: string;\n /** Custom endpoint URL (required for MinIO, Cloudflare R2, Hetzner Object Storage) */\n endpoint?: string;\n /** AWS access key ID */\n accessKeyId: string;\n /** AWS secret access key */\n secretAccessKey: string;\n /** Use path-style URLs (required for MinIO) */\n forcePathStyle?: boolean;\n /** Maximum file size in bytes (default: 50MB) */\n maxFileSize?: number;\n /** Allowed MIME types (if not set, all types allowed) */\n allowedMimeTypes?: string[];\n}\n\n/**\n * Google Cloud Storage configuration (works with GCS and Firebase Storage)\n */\nexport interface GCSStorageConfig {\n type: \"gcs\";\n /** GCS bucket name (e.g. \"my-project.appspot.com\" for Firebase Storage) */\n bucket: string;\n /** GCP project ID (optional, auto-detected from credentials) */\n projectId?: string;\n /** Path to service account JSON key file */\n keyFilename?: string;\n /** Service account credentials object (alternative to keyFilename) */\n credentials?: { client_email: string; private_key: string; project_id?: string };\n /** Maximum file size in bytes (default: 50MB) */\n maxFileSize?: number;\n /** Allowed MIME types */\n allowedMimeTypes?: string[];\n}\n\n/**\n * Storage configuration — local filesystem, S3-compatible, or Google Cloud Storage.\n *\n * **Built-in providers:**\n * - `local` — Zero-config filesystem storage. Great for dev and single-server deployments (Hetzner, bare metal).\n * - `s3` — Any S3-compatible provider. AWS S3, Cloudflare R2, MinIO, Hetzner Object Storage,\n * Backblaze B2, DigitalOcean Spaces, and even GCS (via its S3-compatible interoperability API).\n * - `gcs` — Native Google Cloud Storage / Firebase Storage via `@google-cloud/storage`.\n *\n * **Custom providers:**\n * For other cloud storage (Azure Blob, etc.), implement the `StorageController`\n * interface and pass the instance directly to the `storage` config.\n */\nexport type BackendStorageConfig = LocalStorageConfig | S3StorageConfig | GCSStorageConfig;\n\n/** What a storage request is trying to do to an object. */\nexport type {\n StorageOperation,\n StorageAuthorizeUser,\n StorageAuthorizeContext,\n StorageAuthorizeData,\n StorageAuthorize\n} from \"@rebasepro/types\";\n\n/**\n * How the server writes an object. See {@link StorageController.putObject}.\n */\nexport interface PutObjectOptions {\n /**\n * Hold this write to the controller's upload limits: `maxFileSize` and\n * `allowedMimeTypes`. Defaults to `true`.\n *\n * Those limits say what a user may upload. A write the server makes for\n * itself, such as a database backup, passes `false`. A dump is neither of\n * an allowed type nor, past a small database, under 50 MB. The upload\n * routes never pass it.\n */\n uploadLimits?: boolean;\n}\n\n/**\n * Storage controller interface for backend implementations\n */\nexport interface StorageController {\n /**\n * Upload an object.\n *\n * A write is held to the controller's upload limits — `maxFileSize` and\n * `allowedMimeTypes` — unless `options.uploadLimits` is `false`. Only the\n * server's own writes pass that; nothing a request sends reaches it.\n */\n putObject(props: UploadFileProps, options?: PutObjectOptions): Promise<UploadFileResult>;\n\n /**\n * Get a download URL (signed URL equivalent) for an object.\n *\n * For server-side code only: no route hands this URL to a client. A\n * client reads a private object through `/file/*` with the download\n * token `GET /metadata/*` mints. The built-in S3 and GCS controllers sign\n * it for an hour.\n */\n getSignedUrl(key: string, bucket?: string): Promise<DownloadConfig>;\n\n /**\n * Describe an object — size, type, custom metadata — without producing a\n * URL for it, or `null` when there is no such object.\n *\n * `GET /metadata/*` wants only this: the client builds its own `/file/*`\n * URL and spends the download token the route mints. Asking\n * `getSignedUrl` for it made every metadata read sign a URL nobody used,\n * and on GCS signing is not free — without a key file the client asks the\n * IAM Credentials API to sign, which needs `iam.serviceAccounts.signBlob`\n * on the runtime account itself. Cloud Run does not grant that, so every\n * private object answered 500 there.\n *\n * A controller that does not implement this keeps the old behaviour: the\n * route falls back to `getSignedUrl` and reads its `metadata`.\n */\n getMetadata?(key: string, bucket?: string): Promise<DownloadMetadata | null>;\n\n /**\n * Get object as a File\n */\n getObject(key: string, bucket?: string): Promise<File | null>;\n\n /**\n * Describe an object for serving it — its size, stored type and version —\n * without reading its bytes, or `null` when there is no such object.\n *\n * Optional, together with {@link getObjectStream}. A controller that has\n * both is served streamed: `GET /file/*` answers a `304`, a `416`, a `HEAD`\n * and a cached image rendition from this alone, and otherwise reads only\n * the bytes it sends — the requested range, a chunk at a time. One without\n * them is served through {@link getObject}, which holds the whole object in\n * memory for every request, a `Range` request for one megabyte included.\n */\n statObject?(key: string, bucket?: string): Promise<StoredObjectInfo | null>;\n\n /**\n * Read an object, or one byte range of it, as a stream, or `null` when\n * there is no such object. See {@link statObject}.\n *\n * The stream holds a file or a connection until it is read to the end or\n * cancelled, so a caller that decides not to read it must cancel it.\n */\n getObjectStream?(key: string, options?: { bucket?: string; range?: StoredObjectRange }): Promise<StoredObjectStream | null>;\n\n /**\n * Delete an object\n */\n deleteObject(key: string, bucket?: string): Promise<void>;\n\n /**\n * List objects in a prefix\n */\n listObjects(prefix: string, options?: {\n bucket?: string;\n maxResults?: number;\n pageToken?: string;\n }): Promise<StorageListResult>;\n\n /**\n * Get the storage provider identifier.\n *\n * Built-in values are `'local'` and `'s3'`. Custom implementations\n * should return their own identifier (e.g. `'gcs'`, `'azure'`).\n */\n getType(): string;\n\n /**\n * The buckets this controller serves, if it knows.\n *\n * The `bucket` a request may name is not a free-text field: on S3 and GCS it\n * goes straight to the provider, so an unvalidated one addresses *any*\n * bucket the deployment's credentials can reach, and locally it names a\n * directory the framework would create on demand. Either way, a caller who\n * mistypes it gets \"file not found\" — the same answer as a missing object —\n * with nothing to say the bucket was the problem.\n *\n * So a controller that knows its buckets says so, and the routes refuse\n * anything else with `UNKNOWN_STORAGE_SOURCE` naming what is served. A\n * controller that does not implement this keeps the old behaviour and is\n * handed whatever the caller wrote — a custom implementation may address\n * buckets this framework has no way to enumerate.\n *\n * `\"default\"` is always among them: it is the logical name every route uses\n * when the caller names none, and each provider maps it to its own.\n */\n knownBuckets?(): string[];\n\n /**\n * The largest file this controller accepts, in bytes.\n *\n * Every door that takes a file reads it, so the number a client is told is\n * the number it is held to: the upload route checks it before anything is\n * stored, the resumable route checks it at creation — before the first\n * chunk rather than after the last — and advertises it as `Tus-Max-Size`,\n * and the server's upload body limit is the largest of them. A controller\n * that does not implement this is held to the deployment-wide limit alone.\n */\n maxFileSize?(): number;\n}\n\n/**\n * What serving an object needs to know before deciding whether to read it.\n * See {@link StorageController.statObject}.\n */\nexport interface StoredObjectInfo {\n /** The whole object's size in bytes. */\n size: number;\n /** The stored content type — the uploader's claim, not a sniffed one. */\n contentType: string;\n /**\n * The object's own modification time, in milliseconds since the epoch.\n * The routes build `ETag`, `Last-Modified` and the transform cache key from\n * it, so it must be the object's version, not the time of the read.\n */\n lastModified: number;\n}\n\n/** An inclusive byte range, as `Range: bytes=start-end` names one. */\nexport interface StoredObjectRange {\n start: number;\n end: number;\n}\n\n/** An object's bytes as a stream. See {@link StorageController.getObjectStream}. */\nexport interface StoredObjectStream extends StoredObjectInfo {\n /** The bytes: the range when {@link range} is set, the whole object otherwise. */\n stream: ReadableStream<Uint8Array>;\n /**\n * The range {@link stream} holds — the one asked for — or absent when it\n * holds the whole object. A controller may answer a range with the whole\n * object, as HTTP lets a server do; the caller then serves it whole.\n */\n range?: StoredObjectRange;\n}\n\n/**\n * Default maximum file size (50MB)\n */\nexport const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024;\n\n/**\n * Common image MIME types\n */\nexport const IMAGE_MIME_TYPES = [\n \"image/jpeg\",\n \"image/png\",\n \"image/gif\",\n \"image/webp\",\n \"image/svg+xml\",\n \"image/bmp\",\n \"image/tiff\"\n];\n\n/**\n * Common document MIME types\n */\nexport const DOCUMENT_MIME_TYPES = [\n \"application/pdf\",\n \"application/msword\",\n \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n \"application/vnd.ms-excel\",\n \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n \"application/vnd.ms-powerpoint\",\n \"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\n \"text/plain\",\n \"text/csv\"\n];\n","/**\n * Moving object bytes without holding them.\n *\n * A stored object can be as large as the deployment's `maxFileSize` allows — a\n * video, a database dump — and the paths that used to read one whole (a\n * `File`, an `arrayBuffer()`, a `Buffer.concat`) held it three or four times\n * over per request. These helpers are what the streamed paths share instead.\n */\nimport fs from \"node:fs\";\n\n/** Anything that hands over bytes a chunk at a time: a Node readable, an SDK body. */\ntype ByteSource = AsyncIterable<Uint8Array>;\n\n/**\n * A byte source as a web stream that reads a chunk only when the consumer asks\n * for one, and releases the source when the consumer stops early.\n *\n * Pull-based with a zero high-water mark, so nothing is read ahead of the\n * reader: a slow client holds one chunk, not a queue of them. A source given\n * as a function is opened on the first read, which keeps a stream nobody reads\n * — a `HEAD`, a 304 decided late — from holding a file descriptor or a\n * connection open. Cancelling (a client that disconnects mid-download)\n * returns the iterator, which destroys a Node stream and so closes its file or\n * socket.\n */\nexport function webStreamFrom(source: ByteSource | (() => ByteSource)): ReadableStream<Uint8Array> {\n let iterator: AsyncIterator<Uint8Array> | undefined;\n const open = (): AsyncIterator<Uint8Array> => {\n iterator ??= (typeof source === \"function\" ? source() : source)[Symbol.asyncIterator]();\n return iterator;\n };\n return new ReadableStream<Uint8Array>({\n async pull(controller) {\n try {\n const next = await open().next();\n if (next.done) controller.close();\n else controller.enqueue(next.value);\n } catch (err) {\n controller.error(err);\n }\n },\n async cancel() {\n // A source never opened has nothing to release — and opening it\n // now only to close it would be the cost this avoids.\n if (!iterator && typeof source === \"function\") return;\n try {\n await open().return?.();\n } catch {\n // Already failed or closed; either way it is released.\n }\n }\n }, { highWaterMark: 0 });\n}\n\n/**\n * A file on disk, or an inclusive byte range of it, as a web stream. The file\n * is opened on the first read; see {@link webStreamFrom}.\n */\nexport function fileWebStream(filePath: string, range?: { start: number; end: number }): ReadableStream<Uint8Array> {\n return webStreamFrom(() => fs.createReadStream(filePath, range ? { start: range.start, end: range.end } : {}));\n}\n\n/** A buffer already in memory as a web stream of views onto it, never copies. */\nexport function bufferWebStream(bytes: Uint8Array, chunkSize = 64 * 1024): ReadableStream<Uint8Array> {\n let offset = 0;\n return new ReadableStream<Uint8Array>({\n pull(controller) {\n if (offset >= bytes.byteLength) {\n controller.close();\n return;\n }\n const end = Math.min(offset + chunkSize, bytes.byteLength);\n controller.enqueue(bytes.subarray(offset, end));\n offset = end;\n }\n }, { highWaterMark: 0 });\n}\n\n/**\n * Read a stream into one buffer.\n *\n * For the one consumer that needs the whole object at once — an image decode.\n * With the size known the bytes are copied once, into a buffer of that size,\n * rather than collected and then concatenated (which holds them twice); a\n * stream that turns out longer or shorter than announced still comes back\n * whole and exact.\n */\nexport async function readStreamFully(stream: ReadableStream<Uint8Array>, expectedSize?: number): Promise<Buffer> {\n const reader = stream.getReader();\n const target = expectedSize !== undefined && expectedSize > 0 ? Buffer.allocUnsafe(expectedSize) : undefined;\n let offset = 0;\n let overflow: Uint8Array[] | undefined;\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n if (target && !overflow && offset + value.byteLength <= target.byteLength) {\n target.set(value, offset);\n } else {\n overflow ??= target ? [target.subarray(0, offset)] : [];\n overflow.push(value);\n }\n offset += value.byteLength;\n }\n } finally {\n reader.releaseLock();\n }\n if (overflow) return Buffer.concat(overflow, offset);\n if (!target) return Buffer.alloc(0);\n return offset === target.byteLength ? target : target.subarray(0, offset);\n}\n\n/**\n * The bytes of a buffer as a `Uint8Array` the response APIs accept, without\n * copying them. `new Uint8Array(buffer)` copies; a view does not. Only a buffer\n * over shared memory — which nothing here produces — is copied.\n */\nexport function bytesView(bytes: Uint8Array): Uint8Array<ArrayBuffer> {\n const backing = bytes.buffer;\n return backing instanceof ArrayBuffer\n ? new Uint8Array(backing, bytes.byteOffset, bytes.byteLength)\n : new Uint8Array(bytes);\n}\n","/**\n * What every storage controller checks about a file before it stores it: the\n * source's own size cap and, when configured, its allowed content types.\n *\n * One function for the three controllers, which carried a copy each. Both\n * refusals are `ApiError`s, so they reach the caller as what they are. A plain\n * `Error` was a 500 \"Internal Server Error\" on `/upload` and a 502 on TUS —\n * after the client had sent every byte.\n */\n\nimport { ApiError } from \"../api/errors\";\n\nexport function validateUploadedFile(\n file: { size: number; type: string },\n maxSize: number,\n allowedMimeTypes: string[] | undefined\n): void {\n if (file.size > maxSize) {\n throw new ApiError(\n 413,\n \"PAYLOAD_TOO_LARGE\",\n `File size ${file.size} exceeds this storage source's maximum of ${maxSize} bytes.`,\n { size: file.size, maxFileSize: maxSize }\n );\n }\n\n if (allowedMimeTypes && allowedMimeTypes.length > 0 && !allowedMimeTypes.includes(file.type)) {\n // The code a property's `acceptedFiles` refusal answers with: to the\n // caller, \"this source does not take this type\" is the same mistake.\n throw ApiError.badRequest(\n `File type ${file.type || \"(none)\"} is not allowed on this storage source. ` +\n `Allowed types: ${allowedMimeTypes.join(\", \")}.`,\n \"STORAGE_FILE_TYPE_REFUSED\",\n { allowedMimeTypes, contentType: file.type }\n );\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"upload-validation-I9cCUBIW.js","names":[],"sources":["../src/storage/types.ts","../src/storage/streams.ts","../src/storage/upload-validation.ts"],"sourcesContent":["/**\n * Storage configuration and types for Rebase backend\n */\n\nimport { StorageSource, UploadFileProps, UploadFileResult, DownloadConfig, DownloadMetadata, StorageListResult, StorageReference } from \"@rebasepro/types\";\n\n/**\n * Local filesystem storage configuration\n */\nexport interface LocalStorageConfig {\n type: \"local\";\n /** Base directory for file storage (e.g., './uploads') */\n basePath: string;\n /** Maximum file size in bytes (default: 50MB) */\n maxFileSize?: number;\n /** Allowed MIME types (if not set, all types allowed) */\n allowedMimeTypes?: string[];\n /** Base URL for generating download URLs (default: auto-detected from request) */\n baseUrl?: string;\n /**\n * Set when this local backend stands in for an object store the source\n * declared and nothing bound — `bucket(\"media\", { engine: \"s3\" })` with no\n * `S3_BUCKET__MEDIA` in a development process. Names the engine it stands\n * in for, so boot can say so and `rebase status` can show it. Never set in\n * production, where an unbound object store stays unbound.\n */\n standsInFor?: string;\n /**\n * Set when nothing chose this backend: no `STORAGE_TYPE` and no declared\n * engine, so local disk is only the default a source falls back to.\n * Production drops a local backend either way. This only decides how that\n * is reported: an implicit default being dropped means the project uses no\n * storage, which is not a misconfiguration.\n */\n implicit?: boolean;\n}\n\n/**\n * S3-compatible storage configuration (works with AWS S3 and MinIO)\n */\nexport interface S3StorageConfig {\n type: \"s3\";\n /** S3 bucket name */\n bucket: string;\n /** AWS region (e.g., 'us-east-1') */\n region?: string;\n /** Custom endpoint URL (required for MinIO, Cloudflare R2, Hetzner Object Storage) */\n endpoint?: string;\n /** AWS access key ID */\n accessKeyId: string;\n /** AWS secret access key */\n secretAccessKey: string;\n /** Use path-style URLs (required for MinIO) */\n forcePathStyle?: boolean;\n /** Maximum file size in bytes (default: 50MB) */\n maxFileSize?: number;\n /** Allowed MIME types (if not set, all types allowed) */\n allowedMimeTypes?: string[];\n}\n\n/**\n * Google Cloud Storage configuration (works with GCS and Firebase Storage)\n */\nexport interface GCSStorageConfig {\n type: \"gcs\";\n /** GCS bucket name (e.g. \"my-project.appspot.com\" for Firebase Storage) */\n bucket: string;\n /** GCP project ID (optional, auto-detected from credentials) */\n projectId?: string;\n /** Path to service account JSON key file */\n keyFilename?: string;\n /** Service account credentials object (alternative to keyFilename) */\n credentials?: { client_email: string; private_key: string; project_id?: string };\n /** Maximum file size in bytes (default: 50MB) */\n maxFileSize?: number;\n /** Allowed MIME types */\n allowedMimeTypes?: string[];\n}\n\n/**\n * Storage configuration — local filesystem, S3-compatible, or Google Cloud Storage.\n *\n * **Built-in providers:**\n * - `local` — Zero-config filesystem storage. Great for dev and single-server deployments (Hetzner, bare metal).\n * - `s3` — Any S3-compatible provider. AWS S3, Cloudflare R2, MinIO, Hetzner Object Storage,\n * Backblaze B2, DigitalOcean Spaces, and even GCS (via its S3-compatible interoperability API).\n * - `gcs` — Native Google Cloud Storage / Firebase Storage via `@google-cloud/storage`.\n *\n * **Custom providers:**\n * For other cloud storage (Azure Blob, etc.), implement the `StorageController`\n * interface and pass the instance directly to the `storage` config.\n */\nexport type BackendStorageConfig = LocalStorageConfig | S3StorageConfig | GCSStorageConfig;\n\n/** What a storage request is trying to do to an object. */\nexport type {\n StorageOperation,\n StorageAuthorizeUser,\n StorageAuthorizeContext,\n StorageAuthorizeData,\n StorageAuthorize\n} from \"@rebasepro/types\";\n\n/**\n * How the server writes an object. See {@link StorageController.putObject}.\n */\nexport interface PutObjectOptions {\n /**\n * Hold this write to the controller's upload limits: `maxFileSize` and\n * `allowedMimeTypes`. Defaults to `true`.\n *\n * Those limits say what a user may upload. A write the server makes for\n * itself, such as a database backup, passes `false`. A dump is neither of\n * an allowed type nor, past a small database, under 50 MB. The upload\n * routes never pass it.\n */\n uploadLimits?: boolean;\n}\n\n/**\n * Storage controller interface for backend implementations\n */\nexport interface StorageController {\n /**\n * Upload an object.\n *\n * A write is held to the controller's upload limits — `maxFileSize` and\n * `allowedMimeTypes` — unless `options.uploadLimits` is `false`. Only the\n * server's own writes pass that; nothing a request sends reaches it.\n */\n putObject(props: UploadFileProps, options?: PutObjectOptions): Promise<UploadFileResult>;\n\n /**\n * Get a download URL (signed URL equivalent) for an object.\n *\n * For server-side code only: no route hands this URL to a client. A\n * client reads a private object through `/file/*` with the download\n * token `GET /metadata/*` mints. The built-in S3 and GCS controllers sign\n * it for an hour.\n */\n getSignedUrl(key: string, bucket?: string): Promise<DownloadConfig>;\n\n /**\n * Describe an object — size, type, custom metadata — without producing a\n * URL for it, or `null` when there is no such object.\n *\n * `GET /metadata/*` wants only this: the client builds its own `/file/*`\n * URL and spends the download token the route mints. Asking\n * `getSignedUrl` for it made every metadata read sign a URL nobody used,\n * and on GCS signing is not free — without a key file the client asks the\n * IAM Credentials API to sign, which needs `iam.serviceAccounts.signBlob`\n * on the runtime account itself. Cloud Run does not grant that, so every\n * private object answered 500 there.\n *\n * A controller that does not implement this keeps the old behaviour: the\n * route falls back to `getSignedUrl` and reads its `metadata`.\n */\n getMetadata?(key: string, bucket?: string): Promise<DownloadMetadata | null>;\n\n /**\n * Get object as a File\n */\n getObject(key: string, bucket?: string): Promise<File | null>;\n\n /**\n * Describe an object for serving it — its size, stored type and version —\n * without reading its bytes, or `null` when there is no such object.\n *\n * Optional, together with {@link getObjectStream}. A controller that has\n * both is served streamed: `GET /file/*` answers a `304`, a `416`, a `HEAD`\n * and a cached image rendition from this alone, and otherwise reads only\n * the bytes it sends — the requested range, a chunk at a time. One without\n * them is served through {@link getObject}, which holds the whole object in\n * memory for every request, a `Range` request for one megabyte included.\n */\n statObject?(key: string, bucket?: string): Promise<StoredObjectInfo | null>;\n\n /**\n * Read an object, or one byte range of it, as a stream, or `null` when\n * there is no such object. See {@link statObject}.\n *\n * The stream holds a file or a connection until it is read to the end or\n * cancelled, so a caller that decides not to read it must cancel it.\n */\n getObjectStream?(key: string, options?: { bucket?: string; range?: StoredObjectRange }): Promise<StoredObjectStream | null>;\n\n /**\n * Delete an object\n */\n deleteObject(key: string, bucket?: string): Promise<void>;\n\n /**\n * List objects in a prefix\n */\n listObjects(prefix: string, options?: {\n bucket?: string;\n maxResults?: number;\n pageToken?: string;\n }): Promise<StorageListResult>;\n\n /**\n * Get the storage provider identifier.\n *\n * Built-in values are `'local'` and `'s3'`. Custom implementations\n * should return their own identifier (e.g. `'gcs'`, `'azure'`).\n */\n getType(): string;\n\n /**\n * The buckets this controller serves, if it knows.\n *\n * The `bucket` a request may name is not a free-text field: on S3 and GCS it\n * goes straight to the provider, so an unvalidated one addresses *any*\n * bucket the deployment's credentials can reach, and locally it names a\n * directory the framework would create on demand. Either way, a caller who\n * mistypes it gets \"file not found\" — the same answer as a missing object —\n * with nothing to say the bucket was the problem.\n *\n * So a controller that knows its buckets says so, and the routes refuse\n * anything else with `UNKNOWN_STORAGE_SOURCE` naming what is served. A\n * controller that does not implement this keeps the old behaviour and is\n * handed whatever the caller wrote — a custom implementation may address\n * buckets this framework has no way to enumerate.\n *\n * `\"default\"` is always among them: it is the logical name every route uses\n * when the caller names none, and each provider maps it to its own.\n */\n knownBuckets?(): string[];\n\n /**\n * The largest file this controller accepts, in bytes.\n *\n * Every door that takes a file reads it, so the number a client is told is\n * the number it is held to: the upload route checks it before anything is\n * stored, the resumable route checks it at creation — before the first\n * chunk rather than after the last — and advertises it as `Tus-Max-Size`,\n * and the server's upload body limit is the largest of them. A controller\n * that does not implement this is held to the deployment-wide limit alone.\n */\n maxFileSize?(): number;\n}\n\n/**\n * What serving an object needs to know before deciding whether to read it.\n * See {@link StorageController.statObject}.\n */\nexport interface StoredObjectInfo {\n /** The whole object's size in bytes. */\n size: number;\n /** The stored content type — the uploader's claim, not a sniffed one. */\n contentType: string;\n /**\n * The object's own modification time, in milliseconds since the epoch.\n * The routes build `ETag`, `Last-Modified` and the transform cache key from\n * it, so it must be the object's version, not the time of the read.\n */\n lastModified: number;\n}\n\n/** An inclusive byte range, as `Range: bytes=start-end` names one. */\nexport interface StoredObjectRange {\n start: number;\n end: number;\n}\n\n/** An object's bytes as a stream. See {@link StorageController.getObjectStream}. */\nexport interface StoredObjectStream extends StoredObjectInfo {\n /** The bytes: the range when {@link range} is set, the whole object otherwise. */\n stream: ReadableStream<Uint8Array>;\n /**\n * The range {@link stream} holds — the one asked for — or absent when it\n * holds the whole object. A controller may answer a range with the whole\n * object, as HTTP lets a server do; the caller then serves it whole.\n */\n range?: StoredObjectRange;\n}\n\n/**\n * Default maximum file size (50MB)\n */\nexport const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024;\n\n/**\n * Common image MIME types\n */\nexport const IMAGE_MIME_TYPES = [\n \"image/jpeg\",\n \"image/png\",\n \"image/gif\",\n \"image/webp\",\n \"image/svg+xml\",\n \"image/bmp\",\n \"image/tiff\"\n];\n\n/**\n * Common document MIME types\n */\nexport const DOCUMENT_MIME_TYPES = [\n \"application/pdf\",\n \"application/msword\",\n \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n \"application/vnd.ms-excel\",\n \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n \"application/vnd.ms-powerpoint\",\n \"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\n \"text/plain\",\n \"text/csv\"\n];\n","/**\n * Moving object bytes without holding them.\n *\n * A stored object can be as large as the deployment's `maxFileSize` allows — a\n * video, a database dump — and the paths that used to read one whole (a\n * `File`, an `arrayBuffer()`, a `Buffer.concat`) held it three or four times\n * over per request. These helpers are what the streamed paths share instead.\n */\nimport fs from \"node:fs\";\n\n/** Anything that hands over bytes a chunk at a time: a Node readable, an SDK body. */\ntype ByteSource = AsyncIterable<Uint8Array>;\n\n/**\n * A byte source as a web stream that reads a chunk only when the consumer asks\n * for one, and releases the source when the consumer stops early.\n *\n * Pull-based with a zero high-water mark, so nothing is read ahead of the\n * reader: a slow client holds one chunk, not a queue of them. A source given\n * as a function is opened on the first read, which keeps a stream nobody reads\n * — a `HEAD`, a 304 decided late — from holding a file descriptor or a\n * connection open. Cancelling (a client that disconnects mid-download)\n * returns the iterator, which destroys a Node stream and so closes its file or\n * socket.\n */\nexport function webStreamFrom(source: ByteSource | (() => ByteSource)): ReadableStream<Uint8Array> {\n let iterator: AsyncIterator<Uint8Array> | undefined;\n const open = (): AsyncIterator<Uint8Array> => {\n iterator ??= (typeof source === \"function\" ? source() : source)[Symbol.asyncIterator]();\n return iterator;\n };\n return new ReadableStream<Uint8Array>({\n async pull(controller) {\n try {\n const next = await open().next();\n if (next.done) controller.close();\n else controller.enqueue(next.value);\n } catch (err) {\n controller.error(err);\n }\n },\n async cancel() {\n // A source never opened has nothing to release — and opening it\n // now only to close it would be the cost this avoids.\n if (!iterator && typeof source === \"function\") return;\n try {\n await open().return?.();\n } catch {\n // Already failed or closed; either way it is released.\n }\n }\n }, { highWaterMark: 0 });\n}\n\n/**\n * A file on disk, or an inclusive byte range of it, as a web stream. The file\n * is opened on the first read; see {@link webStreamFrom}.\n */\nexport function fileWebStream(filePath: string, range?: { start: number; end: number }): ReadableStream<Uint8Array> {\n return webStreamFrom(() => fs.createReadStream(filePath, range ? { start: range.start, end: range.end } : {}));\n}\n\n/** A buffer already in memory as a web stream of views onto it, never copies. */\nexport function bufferWebStream(bytes: Uint8Array, chunkSize = 64 * 1024): ReadableStream<Uint8Array> {\n let offset = 0;\n return new ReadableStream<Uint8Array>({\n pull(controller) {\n if (offset >= bytes.byteLength) {\n controller.close();\n return;\n }\n const end = Math.min(offset + chunkSize, bytes.byteLength);\n controller.enqueue(bytes.subarray(offset, end));\n offset = end;\n }\n }, { highWaterMark: 0 });\n}\n\n/**\n * Read a stream into one buffer.\n *\n * For the one consumer that needs the whole object at once — an image decode.\n * With the size known the bytes are copied once, into a buffer of that size,\n * rather than collected and then concatenated (which holds them twice); a\n * stream that turns out longer or shorter than announced still comes back\n * whole and exact.\n */\nexport async function readStreamFully(stream: ReadableStream<Uint8Array>, expectedSize?: number): Promise<Buffer> {\n const reader = stream.getReader();\n const target = expectedSize !== undefined && expectedSize > 0 ? Buffer.allocUnsafe(expectedSize) : undefined;\n let offset = 0;\n let overflow: Uint8Array[] | undefined;\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n if (target && !overflow && offset + value.byteLength <= target.byteLength) {\n target.set(value, offset);\n } else {\n overflow ??= target ? [target.subarray(0, offset)] : [];\n overflow.push(value);\n }\n offset += value.byteLength;\n }\n } finally {\n reader.releaseLock();\n }\n if (overflow) return Buffer.concat(overflow, offset);\n if (!target) return Buffer.alloc(0);\n return offset === target.byteLength ? target : target.subarray(0, offset);\n}\n\n/**\n * The bytes of a buffer as a `Uint8Array` the response APIs accept, without\n * copying them. `new Uint8Array(buffer)` copies; a view does not. Only a buffer\n * over shared memory — which nothing here produces — is copied.\n */\nexport function bytesView(bytes: Uint8Array): Uint8Array<ArrayBuffer> {\n const backing = bytes.buffer;\n return backing instanceof ArrayBuffer\n ? new Uint8Array(backing, bytes.byteOffset, bytes.byteLength)\n : new Uint8Array(bytes);\n}\n","/**\n * What every storage controller checks about a file before it stores it: the\n * source's own size cap and, when configured, its allowed content types.\n *\n * One function for the three controllers, which carried a copy each. Both\n * refusals are `ApiError`s, so they reach the caller as what they are. A plain\n * `Error` was a 500 \"Internal Server Error\" on `/upload` and a 502 on TUS —\n * after the client had sent every byte.\n */\n\nimport { ApiError } from \"../api/errors\";\n\nexport function validateUploadedFile(\n file: { size: number; type: string },\n maxSize: number,\n allowedMimeTypes: string[] | undefined\n): void {\n if (file.size > maxSize) {\n throw new ApiError(\n 413,\n \"PAYLOAD_TOO_LARGE\",\n `File size ${file.size} exceeds this storage source's maximum of ${maxSize} bytes.`,\n { size: file.size, maxFileSize: maxSize }\n );\n }\n\n if (allowedMimeTypes && allowedMimeTypes.length > 0 && !allowedMimeTypes.includes(file.type)) {\n // The code a property's `acceptedFiles` refusal answers with: to the\n // caller, \"this source does not take this type\" is the same mistake.\n throw ApiError.badRequest(\n `File type ${file.type || \"(none)\"} is not allowed on this storage source. ` +\n `Allowed types: ${allowedMimeTypes.join(\", \")}.`,\n \"STORAGE_FILE_TYPE_REFUSED\",\n { allowedMimeTypes, contentType: file.type }\n );\n }\n}\n"],"mappings":"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"}
|