@rebasepro/common 0.27.0 → 0.28.1
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/index.es.js +4 -3
- package/dist/util/entities.es.js +1 -4
- package/dist/util/entities.es.js.map +1 -1
- package/dist/util/identity.d.ts +8 -0
- package/dist/util/identity.es.js +36 -6
- package/dist/util/identity.es.js.map +1 -1
- package/dist/util/index.d.ts +1 -0
- package/dist/util/policy/evaluatePolicy.es.js +1 -1
- package/dist/util/policy/evaluatePolicy.es.js.map +1 -1
- package/dist/util/policy/policyToPostgres.es.js +16 -3
- package/dist/util/policy/policyToPostgres.es.js.map +1 -1
- package/dist/util/policy/sqlToPolicy.es.js +14 -2
- package/dist/util/policy/sqlToPolicy.es.js.map +1 -1
- package/dist/util/resolutions.d.ts +9 -1
- package/dist/util/resolutions.es.js +10 -6
- package/dist/util/resolutions.es.js.map +1 -1
- package/dist/util/resolve-relation.es.js +1 -0
- package/dist/util/resolve-relation.es.js.map +1 -1
- package/dist/util/table-ddl-warning.d.ts +31 -0
- package/dist/util/table-ddl-warning.es.js +151 -0
- package/dist/util/table-ddl-warning.es.js.map +1 -0
- package/package.json +3 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolve-relation.es.js","names":[],"sources":["../../src/util/resolve-relation.ts"],"sourcesContent":["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: isSingleHopVia(relation.cardinality, relation.joinPath),\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/**\n * Whether a `via` chain has a single link for a write to set — the chains\n * Rebase writes through a create or update:\n *\n * - **to-one, one step** (`{ table: target, on: { from, to } }`): the target\n * row holds the key, and the write points it at this row, as a `hasOne`\n * write does;\n * - **to-many, two steps through one table between the ends**: that table\n * holds the links, and the write sets them as junction rows, as a\n * `manyToMany` write does.\n *\n * Both shipped writable (0.24.0 fixed the one-to-one writer). A longer chain\n * has no single link to maintain: a to-many one found no junction connecting\n * both ends and failed as a 500, and a to-one one re-pointed the target at\n * whatever column the first step read. So those are read-only, and every door\n * reads this answer through `writable` rather than re-deriving it.\n */\nfunction isSingleHopVia(cardinality: \"one\" | \"many\", joinPath: readonly { table: string }[] | undefined): boolean {\n // A chain declared without its steps resolves — resolution cannot fail;\n // the relation validator names the missing `joinPath` — and is not written.\n if (!Array.isArray(joinPath)) return false;\n if (cardinality === \"one\") return joinPath.length === 1;\n return joinPath.length === 2 && joinPath[0].table !== joinPath[1].table;\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"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"resolve-relation.es.js","names":[],"sources":["../../src/util/resolve-relation.ts"],"sourcesContent":["import {\n CollectionConfig,\n JoinStep,\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: isSingleHopVia(relation.cardinality, relation.joinPath),\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/**\n * Whether a `via` chain has a single link for a write to set — the chains\n * Rebase writes through a create or update:\n *\n * - **to-one, one step** (`{ table: target, on: { from, to } }`): the target\n * row holds the key, and the write points it at this row, as a `hasOne`\n * write does;\n * - **to-many, two steps through one table between the ends**: that table\n * holds the links, and the write sets them as junction rows, as a\n * `manyToMany` write does.\n *\n * Both shipped writable (0.24.0 fixed the one-to-one writer). A longer chain\n * has no single link to maintain: a to-many one found no junction connecting\n * both ends and failed as a 500, and a to-one one re-pointed the target at\n * whatever column the first step read. So those are read-only, and every door\n * reads this answer through `writable` rather than re-deriving it.\n */\nfunction isSingleHopVia(cardinality: \"one\" | \"many\", joinPath: readonly Pick<JoinStep, \"table\" | \"on\">[] | undefined): boolean {\n // A chain declared without its steps resolves — resolution cannot fail;\n // the relation validator names the missing `joinPath` — and is not written.\n if (!Array.isArray(joinPath)) return false;\n // A step that joins on any of several columns has no one column to write a\n // new link into: which of them it goes in is the application's rule. A\n // list of one is the bare condition written as a list, and is written the\n // same; an empty list joins nothing (the validator names it).\n if (joinPath.some(step => Array.isArray(step.on) && step.on.length !== 1)) return false;\n if (cardinality === \"one\") return joinPath.length === 1;\n return joinPath.length === 2 && joinPath[0].table !== joinPath[1].table;\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"],"mappings":"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"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guard that keeps a project's tables in its collections.
|
|
3
|
+
*
|
|
4
|
+
* `rebase.sql()` runs on the owner connection, so nothing in the database stops
|
|
5
|
+
* it creating a table — and an AI agent that does not know Rebase reaches for
|
|
6
|
+
* exactly that: `CREATE TABLE IF NOT EXISTS` inside a function, then raw reads
|
|
7
|
+
* and writes against it. Such a table has no collection, so no validation, no
|
|
8
|
+
* callbacks, no security rules, no admin view and no SDK type, and every later
|
|
9
|
+
* agent copies the pattern it finds. The project's instruction files say all of
|
|
10
|
+
* this, but they are files an agent may never open: one started at the root of
|
|
11
|
+
* a repository whose Rebase project sits in a subfolder never loads them. What
|
|
12
|
+
* every agent does read is what its own code prints when it runs, so the rule
|
|
13
|
+
* is said there.
|
|
14
|
+
*
|
|
15
|
+
* A statement that creates, alters or drops a table, or indexes one, is logged
|
|
16
|
+
* once per table with the way to do it instead, and then runs: raw SQL is never
|
|
17
|
+
* blocked. Temporary tables and Rebase's own schemas are left alone.
|
|
18
|
+
*/
|
|
19
|
+
/** One statement that changes the shape of an application table. */
|
|
20
|
+
export interface TableDdl {
|
|
21
|
+
statement: "create table" | "alter table" | "drop table" | "create index";
|
|
22
|
+
/** As Postgres resolves it: unquoted names lower-cased, `public` when unqualified. */
|
|
23
|
+
schema: string;
|
|
24
|
+
table: string;
|
|
25
|
+
}
|
|
26
|
+
/** The application-table DDL in a SQL string, one entry per statement that has it. */
|
|
27
|
+
export declare function findTableDdl(sqlText: string): TableDdl[];
|
|
28
|
+
/** What an agent (or a person) reads when the guard stops them, written to be acted on as it stands. */
|
|
29
|
+
export declare function tableDdlMessage(ddl: TableDdl): string;
|
|
30
|
+
/** Log application-table DDL once per table, with the way to do it instead. Never throws: the statement runs. */
|
|
31
|
+
export declare function warnOnTableDdl(sqlText: string, warn?: (message: string) => void): void;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { REBASE_INTERNAL_SCHEMAS, isRebaseInternalTable } from "../table-classification.es.js";
|
|
2
|
+
//#region src/util/table-ddl-warning.ts
|
|
3
|
+
/**
|
|
4
|
+
* The guard that keeps a project's tables in its collections.
|
|
5
|
+
*
|
|
6
|
+
* `rebase.sql()` runs on the owner connection, so nothing in the database stops
|
|
7
|
+
* it creating a table — and an AI agent that does not know Rebase reaches for
|
|
8
|
+
* exactly that: `CREATE TABLE IF NOT EXISTS` inside a function, then raw reads
|
|
9
|
+
* and writes against it. Such a table has no collection, so no validation, no
|
|
10
|
+
* callbacks, no security rules, no admin view and no SDK type, and every later
|
|
11
|
+
* agent copies the pattern it finds. The project's instruction files say all of
|
|
12
|
+
* this, but they are files an agent may never open: one started at the root of
|
|
13
|
+
* a repository whose Rebase project sits in a subfolder never loads them. What
|
|
14
|
+
* every agent does read is what its own code prints when it runs, so the rule
|
|
15
|
+
* is said there.
|
|
16
|
+
*
|
|
17
|
+
* A statement that creates, alters or drops a table, or indexes one, is logged
|
|
18
|
+
* once per table with the way to do it instead, and then runs: raw SQL is never
|
|
19
|
+
* blocked. Temporary tables and Rebase's own schemas are left alone.
|
|
20
|
+
*/
|
|
21
|
+
/** A character that continues an identifier: `$` is one, so `a$b$` is a name and not a dollar quote. */
|
|
22
|
+
var IDENTIFIER_CHAR = /[\w$]/;
|
|
23
|
+
var DOLLAR_QUOTE = /\$([A-Za-z_]\w*)?\$/y;
|
|
24
|
+
/**
|
|
25
|
+
* Comments and literals out, quoted identifiers kept: a `CREATE TABLE` inside a
|
|
26
|
+
* string is data, and a table named `"Order"` must keep its case.
|
|
27
|
+
*/
|
|
28
|
+
function stripCommentsAndLiterals(sqlText) {
|
|
29
|
+
let out = "";
|
|
30
|
+
let i = 0;
|
|
31
|
+
while (i < sqlText.length) {
|
|
32
|
+
const char = sqlText[i];
|
|
33
|
+
if (char === "-" && sqlText.startsWith("--", i)) {
|
|
34
|
+
const end = sqlText.indexOf("\n", i);
|
|
35
|
+
i = end === -1 ? sqlText.length : end;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (char === "/" && sqlText.startsWith("/*", i)) {
|
|
39
|
+
const end = sqlText.indexOf("*/", i + 2);
|
|
40
|
+
i = end === -1 ? sqlText.length : end + 2;
|
|
41
|
+
out += " ";
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (char === "'") {
|
|
45
|
+
i++;
|
|
46
|
+
while (i < sqlText.length) if (sqlText[i] === "'" && sqlText[i + 1] === "'") i += 2;
|
|
47
|
+
else if (sqlText[i] === "'") {
|
|
48
|
+
i++;
|
|
49
|
+
break;
|
|
50
|
+
} else i++;
|
|
51
|
+
out += "''";
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (char === "$" && !(i > 0 && IDENTIFIER_CHAR.test(sqlText[i - 1]))) {
|
|
55
|
+
DOLLAR_QUOTE.lastIndex = i;
|
|
56
|
+
const dollar = DOLLAR_QUOTE.exec(sqlText);
|
|
57
|
+
if (dollar) {
|
|
58
|
+
const end = sqlText.indexOf(dollar[0], i + dollar[0].length);
|
|
59
|
+
i = end === -1 ? sqlText.length : end + dollar[0].length;
|
|
60
|
+
out += "''";
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (char === "\"") {
|
|
65
|
+
const end = sqlText.indexOf("\"", i + 1);
|
|
66
|
+
const stop = end === -1 ? sqlText.length : end + 1;
|
|
67
|
+
out += sqlText.slice(i, stop);
|
|
68
|
+
i = stop;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out += char;
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
var PART = String.raw`(?:"[^"]+"|[A-Za-z_][\w$]*)`;
|
|
77
|
+
var NAME = String.raw`(${PART}(?:\s*\.\s*${PART})?)`;
|
|
78
|
+
var PATTERNS = [
|
|
79
|
+
["create table", new RegExp(String.raw`^create\s+(?:(?:global|local)\s+)?(?:(temp|temporary|unlogged)\s+)?table\s+(?:if\s+not\s+exists\s+)?${NAME}`, "i")],
|
|
80
|
+
["alter table", new RegExp(String.raw`^alter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?()${NAME}`, "i")],
|
|
81
|
+
["drop table", new RegExp(String.raw`^drop\s+table\s+(?:if\s+exists\s+)?()${NAME}`, "i")],
|
|
82
|
+
["create index", new RegExp(String.raw`^create\s+(?:unique\s+)?index\b[\s\S]*?\bon\s+(?:only\s+)?()${NAME}`, "i")]
|
|
83
|
+
];
|
|
84
|
+
function unquote(part) {
|
|
85
|
+
const trimmed = part.trim();
|
|
86
|
+
return trimmed.startsWith("\"") ? trimmed.slice(1, -1) : trimmed.toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
/** The application-table DDL in a SQL string, one entry per statement that has it. */
|
|
89
|
+
function findTableDdl(sqlText) {
|
|
90
|
+
if (!/\b(?:create|alter|drop)\b/i.test(sqlText)) return [];
|
|
91
|
+
const found = [];
|
|
92
|
+
for (const raw of stripCommentsAndLiterals(sqlText).split(";")) {
|
|
93
|
+
const statement = raw.trim();
|
|
94
|
+
for (const [kind, pattern] of PATTERNS) {
|
|
95
|
+
const match = pattern.exec(statement);
|
|
96
|
+
if (!match) continue;
|
|
97
|
+
if (match[1] && /^temp/i.test(match[1])) break;
|
|
98
|
+
const parts = match[2].split(/\s*\.\s*(?=(?:[^"]*"[^"]*")*[^"]*$)/).map(unquote);
|
|
99
|
+
const [schema, table] = parts.length === 2 ? parts : ["public", parts[0]];
|
|
100
|
+
if (schema === "pg_temp" || REBASE_INTERNAL_SCHEMAS.includes(schema) || isRebaseInternalTable(table, schema)) break;
|
|
101
|
+
found.push({
|
|
102
|
+
statement: kind,
|
|
103
|
+
schema,
|
|
104
|
+
table
|
|
105
|
+
});
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return found;
|
|
110
|
+
}
|
|
111
|
+
/** What an agent (or a person) reads when the guard stops them, written to be acted on as it stands. */
|
|
112
|
+
function tableDdlMessage(ddl) {
|
|
113
|
+
const qualified = ddl.schema === "public" ? ddl.table : `${ddl.schema}.${ddl.table}`;
|
|
114
|
+
const file = ddl.table.replace(/_/g, "-");
|
|
115
|
+
const collection = `its collection in config/collections/ (the one with \`table: "${ddl.table}"\`)`;
|
|
116
|
+
const migrations = "`pnpm db:push` in development, `pnpm db:generate` then `pnpm db:migrate` for production";
|
|
117
|
+
const introspect = "A table with no collection yet: `pnpm schema:introspect` writes one.";
|
|
118
|
+
const ran = "The statement ran. See ai-instructions.md.";
|
|
119
|
+
if (ddl.statement === "alter table") return [
|
|
120
|
+
`rebase.sql() ran \`ALTER TABLE ${qualified}\`: in a Rebase project every application table is a collection.`,
|
|
121
|
+
`Change ${collection} and let the migration do it: ${migrations}.`,
|
|
122
|
+
introspect,
|
|
123
|
+
ran
|
|
124
|
+
].join("\n");
|
|
125
|
+
if (ddl.statement === "drop table") return [
|
|
126
|
+
`rebase.sql() ran \`DROP TABLE ${qualified}\`: in a Rebase project every application table is a collection.`,
|
|
127
|
+
`Remove ${collection} instead, and leave dropping the table to a person, who can see what it holds first.`,
|
|
128
|
+
ran
|
|
129
|
+
].join("\n");
|
|
130
|
+
return [
|
|
131
|
+
`rebase.sql() ran ${ddl.statement === "create index" ? `\`CREATE INDEX\` on ${qualified}: an index belongs in the collection's \`indexes\`, which boot creates.` : `\`CREATE TABLE ${qualified}\`: in a Rebase project every application table is a collection.`}`,
|
|
132
|
+
`Declare it in config/collections/${file}.ts with defineCollection({ slug, table: "${ddl.table}", properties, securityRules, indexes }). \`pnpm dev\` (or \`pnpm schema:generate\`) creates the table and its columns and indexes, and gives it validation, callbacks, security rules, an admin view and SDK types.`,
|
|
133
|
+
"Read and write it with `rebase.dataAsAdmin.<slug>`, or `getDriver(c)` to act as the caller — never raw SQL against an application table.",
|
|
134
|
+
`A table that already exists: \`pnpm schema:introspect\` writes its collection. A change boot leaves alone (a rename, a dropped column): ${migrations}.`,
|
|
135
|
+
ran
|
|
136
|
+
].join("\n");
|
|
137
|
+
}
|
|
138
|
+
var warned = /* @__PURE__ */ new Set();
|
|
139
|
+
/** Log application-table DDL once per table, with the way to do it instead. Never throws: the statement runs. */
|
|
140
|
+
function warnOnTableDdl(sqlText, warn = (message) => console.warn(message)) {
|
|
141
|
+
for (const one of findTableDdl(sqlText)) {
|
|
142
|
+
const key = `${one.statement} ${one.schema}.${one.table}`;
|
|
143
|
+
if (warned.has(key)) continue;
|
|
144
|
+
warned.add(key);
|
|
145
|
+
warn(tableDdlMessage(one));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
export { findTableDdl, tableDdlMessage, warnOnTableDdl };
|
|
150
|
+
|
|
151
|
+
//# sourceMappingURL=table-ddl-warning.es.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"table-ddl-warning.es.js","names":[],"sources":["../../src/util/table-ddl-warning.ts"],"sourcesContent":["/**\n * The guard that keeps a project's tables in its collections.\n *\n * `rebase.sql()` runs on the owner connection, so nothing in the database stops\n * it creating a table — and an AI agent that does not know Rebase reaches for\n * exactly that: `CREATE TABLE IF NOT EXISTS` inside a function, then raw reads\n * and writes against it. Such a table has no collection, so no validation, no\n * callbacks, no security rules, no admin view and no SDK type, and every later\n * agent copies the pattern it finds. The project's instruction files say all of\n * this, but they are files an agent may never open: one started at the root of\n * a repository whose Rebase project sits in a subfolder never loads them. What\n * every agent does read is what its own code prints when it runs, so the rule\n * is said there.\n *\n * A statement that creates, alters or drops a table, or indexes one, is logged\n * once per table with the way to do it instead, and then runs: raw SQL is never\n * blocked. Temporary tables and Rebase's own schemas are left alone.\n */\n\nimport { REBASE_INTERNAL_SCHEMAS, isRebaseInternalTable } from \"../table-classification\";\n\n/** One statement that changes the shape of an application table. */\nexport interface TableDdl {\n statement: \"create table\" | \"alter table\" | \"drop table\" | \"create index\";\n /** As Postgres resolves it: unquoted names lower-cased, `public` when unqualified. */\n schema: string;\n table: string;\n}\n\n/** A character that continues an identifier: `$` is one, so `a$b$` is a name and not a dollar quote. */\nconst IDENTIFIER_CHAR = /[\\w$]/;\nconst DOLLAR_QUOTE = /\\$([A-Za-z_]\\w*)?\\$/y;\n\n/**\n * Comments and literals out, quoted identifiers kept: a `CREATE TABLE` inside a\n * string is data, and a table named `\"Order\"` must keep its case.\n */\nfunction stripCommentsAndLiterals(sqlText: string): string {\n let out = \"\";\n let i = 0;\n while (i < sqlText.length) {\n const char = sqlText[i];\n if (char === \"-\" && sqlText.startsWith(\"--\", i)) {\n const end = sqlText.indexOf(\"\\n\", i);\n i = end === -1 ? sqlText.length : end;\n continue;\n }\n if (char === \"/\" && sqlText.startsWith(\"/*\", i)) {\n const end = sqlText.indexOf(\"*/\", i + 2);\n i = end === -1 ? sqlText.length : end + 2;\n out += \" \";\n continue;\n }\n if (char === \"'\") {\n i++;\n while (i < sqlText.length) {\n if (sqlText[i] === \"'\" && sqlText[i + 1] === \"'\") i += 2;\n else if (sqlText[i] === \"'\") { i++; break; }\n else i++;\n }\n out += \"''\";\n continue;\n }\n if (char === \"$\" && !(i > 0 && IDENTIFIER_CHAR.test(sqlText[i - 1]))) {\n DOLLAR_QUOTE.lastIndex = i;\n const dollar = DOLLAR_QUOTE.exec(sqlText);\n if (dollar) {\n const end = sqlText.indexOf(dollar[0], i + dollar[0].length);\n i = end === -1 ? sqlText.length : end + dollar[0].length;\n out += \"''\";\n continue;\n }\n }\n if (char === \"\\\"\") {\n const end = sqlText.indexOf(\"\\\"\", i + 1);\n const stop = end === -1 ? sqlText.length : end + 1;\n out += sqlText.slice(i, stop);\n i = stop;\n continue;\n }\n out += char;\n i++;\n }\n return out;\n}\n\nconst PART = String.raw`(?:\"[^\"]+\"|[A-Za-z_][\\w$]*)`;\nconst NAME = String.raw`(${PART}(?:\\s*\\.\\s*${PART})?)`;\n\nconst PATTERNS: readonly [TableDdl[\"statement\"], RegExp][] = [\n [\"create table\", new RegExp(String.raw`^create\\s+(?:(?:global|local)\\s+)?(?:(temp|temporary|unlogged)\\s+)?table\\s+(?:if\\s+not\\s+exists\\s+)?${NAME}`, \"i\")],\n [\"alter table\", new RegExp(String.raw`^alter\\s+table\\s+(?:if\\s+exists\\s+)?(?:only\\s+)?()${NAME}`, \"i\")],\n [\"drop table\", new RegExp(String.raw`^drop\\s+table\\s+(?:if\\s+exists\\s+)?()${NAME}`, \"i\")],\n [\"create index\", new RegExp(String.raw`^create\\s+(?:unique\\s+)?index\\b[\\s\\S]*?\\bon\\s+(?:only\\s+)?()${NAME}`, \"i\")]\n];\n\nfunction unquote(part: string): string {\n const trimmed = part.trim();\n return trimmed.startsWith(\"\\\"\") ? trimmed.slice(1, -1) : trimmed.toLowerCase();\n}\n\n/** The application-table DDL in a SQL string, one entry per statement that has it. */\nexport function findTableDdl(sqlText: string): TableDdl[] {\n // Every statement this finds starts with one of these words (`CREATE INDEX`\n // with `create`), so a query without them — a large INSERT, most of all —\n // is not scanned.\n if (!/\\b(?:create|alter|drop)\\b/i.test(sqlText)) return [];\n const found: TableDdl[] = [];\n for (const raw of stripCommentsAndLiterals(sqlText).split(\";\")) {\n const statement = raw.trim();\n for (const [kind, pattern] of PATTERNS) {\n const match = pattern.exec(statement);\n if (!match) continue;\n if (match[1] && /^temp/i.test(match[1])) break;\n const parts = match[2].split(/\\s*\\.\\s*(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/).map(unquote);\n const [schema, table] = parts.length === 2 ? parts : [\"public\", parts[0]];\n if (schema === \"pg_temp\" || REBASE_INTERNAL_SCHEMAS.includes(schema) || isRebaseInternalTable(table, schema)) break;\n found.push({ statement: kind, schema, table });\n break;\n }\n }\n return found;\n}\n\n/** What an agent (or a person) reads when the guard stops them, written to be acted on as it stands. */\nexport function tableDdlMessage(ddl: TableDdl): string {\n const qualified = ddl.schema === \"public\" ? ddl.table : `${ddl.schema}.${ddl.table}`;\n const file = ddl.table.replace(/_/g, \"-\");\n const collection = `its collection in config/collections/ (the one with \\`table: \"${ddl.table}\"\\`)`;\n const migrations = \"`pnpm db:push` in development, `pnpm db:generate` then `pnpm db:migrate` for production\";\n const introspect = \"A table with no collection yet: `pnpm schema:introspect` writes one.\";\n const ran = \"The statement ran. See ai-instructions.md.\";\n if (ddl.statement === \"alter table\") {\n return [\n `rebase.sql() ran \\`ALTER TABLE ${qualified}\\`: in a Rebase project every application table is a collection.`,\n `Change ${collection} and let the migration do it: ${migrations}.`,\n introspect,\n ran\n ].join(\"\\n\");\n }\n if (ddl.statement === \"drop table\") {\n return [\n `rebase.sql() ran \\`DROP TABLE ${qualified}\\`: in a Rebase project every application table is a collection.`,\n `Remove ${collection} instead, and leave dropping the table to a person, who can see what it holds first.`,\n ran\n ].join(\"\\n\");\n }\n const what = ddl.statement === \"create index\"\n ? `\\`CREATE INDEX\\` on ${qualified}: an index belongs in the collection's \\`indexes\\`, which boot creates.`\n : `\\`CREATE TABLE ${qualified}\\`: in a Rebase project every application table is a collection.`;\n return [\n `rebase.sql() ran ${what}`,\n `Declare it in config/collections/${file}.ts with defineCollection({ slug, table: \"${ddl.table}\", properties, securityRules, indexes }).` +\n \" `pnpm dev` (or `pnpm schema:generate`) creates the table and its columns and indexes, and gives it validation, callbacks, security rules, an admin view and SDK types.\",\n \"Read and write it with `rebase.dataAsAdmin.<slug>`, or `getDriver(c)` to act as the caller — never raw SQL against an application table.\",\n `A table that already exists: \\`pnpm schema:introspect\\` writes its collection. A change boot leaves alone (a rename, a dropped column): ${migrations}.`,\n ran\n ].join(\"\\n\");\n}\n\nconst warned = new Set<string>();\n\n/** Log application-table DDL once per table, with the way to do it instead. Never throws: the statement runs. */\nexport function warnOnTableDdl(sqlText: string, warn: (message: string) => void = message => console.warn(message)): void {\n for (const one of findTableDdl(sqlText)) {\n const key = `${one.statement} ${one.schema}.${one.table}`;\n if (warned.has(key)) continue;\n warned.add(key);\n warn(tableDdlMessage(one));\n }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/common",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.1",
|
|
4
4
|
"description": "Rebase shared core — collection registry, data driver adapter and fluent query builder. No React dependency.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rebase",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"fast-equals": "6.0.2",
|
|
48
48
|
"json-logic-js": "^2.0.5",
|
|
49
|
-
"@rebasepro/types": "0.
|
|
50
|
-
"@rebasepro/utils": "0.
|
|
49
|
+
"@rebasepro/types": "0.28.1",
|
|
50
|
+
"@rebasepro/utils": "0.28.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@jest/globals": "^30.4.1",
|