@rebasepro/server-postgres 0.13.1-canary.gf57a27e → 0.14.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.
Files changed (103) hide show
  1. package/dist/PostgresBootstrapper.d.ts +26 -0
  2. package/dist/auth/services.d.ts +21 -0
  3. package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
  4. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  5. package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
  6. package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  7. package/dist/cli-output.d.ts +34 -0
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  12. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  13. package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
  14. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  15. package/dist/index.es.js +1310 -1060
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
  18. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
  19. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  20. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  21. package/dist/schema/auth-schema.d.ts +102 -0
  22. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  23. package/dist/schema/doctor.d.ts +41 -25
  24. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  25. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  26. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  27. package/dist/schema/introspect-db-inference.d.ts +8 -1
  28. package/dist/schema/introspect-db-logic.d.ts +49 -0
  29. package/dist/schema/introspect-db-project.d.ts +21 -0
  30. package/dist/schema/search-column.d.ts +49 -0
  31. package/dist/security/policy-drift.d.ts +34 -0
  32. package/dist/security/rls-enforcement.d.ts +8 -3
  33. package/dist/services/FetchService.d.ts +9 -0
  34. package/dist/services/PersistService.d.ts +21 -17
  35. package/dist/services/RelationService.d.ts +9 -57
  36. package/dist/services/RelationWriteService.d.ts +82 -0
  37. package/dist/services/collection-helpers.d.ts +42 -0
  38. package/dist/services/dataService.d.ts +2 -0
  39. package/dist/services/junction-writes.d.ts +82 -0
  40. package/dist/services/realtimeService.d.ts +137 -2
  41. package/dist/services/write-denial.d.ts +36 -0
  42. package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
  43. package/dist/src-DCdn3Val.js.map +1 -0
  44. package/dist/utils/drizzle-conditions.d.ts +54 -1
  45. package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
  46. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  47. package/package.json +6 -6
  48. package/src/PostgresBackendDriver.ts +7 -3
  49. package/src/PostgresBootstrapper.ts +95 -9
  50. package/src/auth/ensure-tables.ts +27 -5
  51. package/src/auth/services.ts +82 -5
  52. package/src/backup/backup-cli.ts +59 -57
  53. package/src/cli-errors.ts +6 -6
  54. package/src/cli-helpers.ts +4 -4
  55. package/src/cli-output.ts +43 -0
  56. package/src/cli.ts +155 -147
  57. package/src/collections/buildRegistry.ts +3 -1
  58. package/src/data-transformer.ts +111 -25
  59. package/src/history/ensure-history-table.ts +2 -2
  60. package/src/schema/auth-schema.ts +17 -1
  61. package/src/schema/doctor-cli.ts +14 -65
  62. package/src/schema/doctor-policy-checks.ts +105 -0
  63. package/src/schema/doctor.ts +149 -72
  64. package/src/schema/ensure-collection-policies.ts +99 -6
  65. package/src/schema/ensure-collection-tables.ts +214 -17
  66. package/src/schema/generate-drizzle-schema-logic.ts +121 -65
  67. package/src/schema/generate-drizzle-schema.ts +11 -10
  68. package/src/schema/generate-postgres-ddl-logic.ts +28 -1
  69. package/src/schema/generate-postgres-ddl.ts +14 -13
  70. package/src/schema/generated-schema-staleness.ts +7 -5
  71. package/src/schema/introspect-db-inference.ts +9 -2
  72. package/src/schema/introspect-db-logic.ts +251 -75
  73. package/src/schema/introspect-db-project.ts +78 -0
  74. package/src/schema/introspect-db.ts +42 -25
  75. package/src/schema/introspect-runtime.ts +14 -2
  76. package/src/schema/search-column.ts +85 -0
  77. package/src/security/policy-drift.test.ts +104 -3
  78. package/src/security/policy-drift.ts +129 -7
  79. package/src/security/rls-enforcement.ts +9 -4
  80. package/src/services/FetchService.ts +105 -7
  81. package/src/services/PersistService.ts +68 -42
  82. package/src/services/RelationService.ts +35 -695
  83. package/src/services/RelationWriteService.ts +653 -0
  84. package/src/services/cdc/trigger-cdc.ts +5 -1
  85. package/src/services/channel-history.ts +9 -3
  86. package/src/services/channel-presence.ts +10 -3
  87. package/src/services/collection-helpers.ts +89 -4
  88. package/src/services/dataService.ts +2 -0
  89. package/src/services/junction-writes.ts +295 -0
  90. package/src/services/pg-notify-listener.ts +1 -1
  91. package/src/services/realtimeService.ts +337 -82
  92. package/src/services/write-denial.ts +55 -0
  93. package/src/utils/drizzle-conditions.ts +211 -34
  94. package/src/utils/pg-error-utils.ts +8 -3
  95. package/src/websocket.ts +113 -16
  96. package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
  97. package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
  98. package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
  99. package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
  100. package/dist/policy-CPkCqVTz.js +0 -105
  101. package/dist/policy-CPkCqVTz.js.map +0 -1
  102. package/dist/src-C_wvdMnl.js.map +0 -1
  103. package/dist/websocket-D0TBU3ia.js.map +0 -1
@@ -15,7 +15,14 @@ export function inferPropertyFromData(
15
15
  pgDataType: string,
16
16
  currentPropType: string,
17
17
  sampleValues: unknown[],
18
- isPk: boolean
18
+ isPk: boolean,
19
+ /**
20
+ * False when generating for a project without `@rebasepro/admin-types`, where
21
+ * `BaseProperty` declares no `admin` field and the block below would not
22
+ * compile. The type-level inferences (`propType`, `url`, `storage`) are
23
+ * unaffected — only the form-widget hints are dropped.
24
+ */
25
+ emitAdmin = true
19
26
  ): InferenceResult {
20
27
  const result: InferenceResult = {};
21
28
  const extraLines: string[] = [];
@@ -239,7 +246,7 @@ export function inferPropertyFromData(
239
246
  }
240
247
  }
241
248
 
242
- if (adminOptions.length > 0) {
249
+ if (emitAdmin && adminOptions.length > 0) {
243
250
  extraLines.push(` admin: {\n${adminOptions.map((o) => ` ${o}`).join(",\n")}\n }`);
244
251
  }
245
252
 
@@ -6,6 +6,7 @@
6
6
  * no process.exit. It is imported by introspect-db.ts (the CLI entry-point)
7
7
  * and consumed directly by tests.
8
8
  */
9
+ import { firstFreeKey, toWireKey } from "@rebasepro/utils";
9
10
  import { inferPropertyFromData } from "./introspect-db-inference";
10
11
  import { humanize } from "./introspect-db-naming";
11
12
  import { mapPgType } from "./introspect-db-types";
@@ -218,7 +219,14 @@ export function singularize(word: string): string {
218
219
  * e.g. "company_token" -> "companyTokenCollection"
219
220
  */
220
221
  export function toCollectionVarName(tableName: string): string {
221
- return tableName.replace(/_([a-z])/g, (_g, letter: string) => letter.toUpperCase()) + "Collection";
222
+ const camel = tableName.replace(/_([a-z])/g, (_g, letter: string) => letter.toUpperCase()) + "Collection";
223
+ // Only reshapes names that are not identifiers already, so every table that
224
+ // generated a working file keeps the exact variable name it had. A table
225
+ // called `2024 archive` used to emit `const 2024 archiveCollection`, which
226
+ // is three syntax errors rather than a declaration.
227
+ if (JS_IDENTIFIER.test(camel)) return camel;
228
+ const sanitized = camel.replace(/[^A-Za-z0-9_$]/g, "_");
229
+ return /^[0-9]/.test(sanitized) ? `_${sanitized}` : sanitized;
222
230
  }
223
231
 
224
232
  export function getIconForTable(tableName: string): string {
@@ -572,10 +580,61 @@ export interface GeneratedFile {
572
580
  * have no database to read constraints or row counts from, and they must keep
573
581
  * producing a valid collection.
574
582
  */
583
+ /**
584
+ * Which `defineCollection` — if any — the project being generated into can import.
585
+ *
586
+ * A bare `const x: PostgresCollectionConfig = { … }` annotation widens `properties`
587
+ * to `Record<string, …>`, and every key-shaped field in the admin block —
588
+ * `titleProperty`, `sort`, `propertiesOrder`, `listProperties`, `fixedFilter` — is
589
+ * derived from those keys. Annotated, they accept any string: introspection was
590
+ * emitting a `propertiesOrder` array that nothing checked, so renaming a column and
591
+ * re-introspecting left a stale key that compiled silently. `defineCollection` is
592
+ * the identity function whose `const P` type parameter keeps the keys literal, which
593
+ * is what turns that checking on.
594
+ *
595
+ * There are two of them and they are not interchangeable:
596
+ *
597
+ * - `admin-types` — `@rebasepro/admin-types`. Its index side-effect-imports
598
+ * `augment.ts`, so importing it is also what *declares* the `admin` block. Only a
599
+ * project that depends on the package can resolve it.
600
+ * - `common` — `@rebasepro/common`. Same key inference, no admin surface, no React
601
+ * anywhere in its graph (`scripts/headless-guard` lists it as core). This is the
602
+ * headless flavour.
603
+ * - `annotation` — neither package is declared, so neither import would resolve and
604
+ * the old annotation is the only honest thing to emit. Projects scaffolded before
605
+ * `@rebasepro/common` joined the headless config package land here.
606
+ *
607
+ * The last two emit **no admin block, on the collection or on any property**. That is
608
+ * not a downgrade: `@rebasepro/types` declares no `admin` field at all, so the block
609
+ * introspection used to emit was a type error in every headless project it was
610
+ * written into. See `packages/admin-types/src/augment.ts`.
611
+ */
612
+ export type CollectionBuilder = "admin-types" | "common" | "annotation";
613
+
614
+ /**
615
+ * The package specifiers the generated files name, spelled once.
616
+ *
617
+ * Written as constants rather than inline in the import templates below because
618
+ * `scripts/headless-guard/check-types.mjs` scans core sources for `from
619
+ * "@rebasepro/admin-types"` and cannot tell a real import from one this module
620
+ * *writes*. It is right to be that blunt — the guard's whole value is that it
621
+ * cannot be reasoned around — so the string simply never appears in that shape
622
+ * here. Inlining them back into the templates re-breaks `check:types-headless`.
623
+ */
624
+ export const ADMIN_TYPES_PACKAGE = "@rebasepro/admin-types";
625
+ export const COMMON_PACKAGE = "@rebasepro/common";
626
+ export const TYPES_PACKAGE = "@rebasepro/types";
627
+
575
628
  export interface GenerationContext {
576
629
  metadata?: SchemaMetadata;
577
630
  classifications?: Map<string, TableClassification>;
578
631
  checkFacts?: CheckFactsByTable;
632
+ /**
633
+ * Defaults to `admin-types`, which is what the generator has always emitted.
634
+ * The CLI never relies on the default — `introspect-db.ts` detects the flavour
635
+ * from the target project and passes it. See `detectCollectionBuilder`.
636
+ */
637
+ builder?: CollectionBuilder;
579
638
  }
580
639
 
581
640
  /** Adds entries to a property's `validation` block, creating it if absent. */
@@ -601,8 +660,17 @@ function hasGeneratedKey(extra: string, key: string): boolean {
601
660
  return new RegExp(`(^|[\\s{,])${key}\\s*:`).test(extra);
602
661
  }
603
662
 
604
- /** Adds entries to a property's `admin` block, creating it if absent. */
605
- function withAdminOptions(extra: string, entries: string[]): string {
663
+ /**
664
+ * Adds entries to a property's `admin` block, creating it if absent.
665
+ *
666
+ * `emitAdmin` is false for the headless flavours, where `BaseProperty` has no
667
+ * `admin` field to put them in — see {@link CollectionBuilder}. The options are
668
+ * dropped rather than relocated: every one of them (`readOnly`, `multiline`,
669
+ * `hideFromCollection`, `urlPreview`) describes a form widget, and there is no
670
+ * form.
671
+ */
672
+ function withAdminOptions(extra: string, entries: string[], emitAdmin = true): string {
673
+ if (!emitAdmin) return extra;
606
674
  if (entries.length === 0) return extra;
607
675
  const block = entries.map((e) => ` ${e}`).join(",\n");
608
676
  if (extra.includes("admin: {")) {
@@ -616,22 +684,35 @@ function quote(value: string): string {
616
684
  return JSON.stringify(value);
617
685
  }
618
686
 
687
+ const JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
688
+
619
689
  /**
620
- * The first candidate key not already used, or a numbered fallback.
690
+ * An object key for the generated file: verbatim when the name is a JavaScript
691
+ * identifier, quoted otherwise.
692
+ *
693
+ * Postgres constrains an identifier only by quoting, so `order`, `full name`
694
+ * and `2fa_enabled` are all ordinary column names — and all three produced a
695
+ * file that did not parse when written as a bare key.
621
696
  *
622
- * The numbered tail exists so this is total: a table can hold two foreign keys
623
- * whose columns strip to the same name, and a function that returns a key it
624
- * cannot guarantee is free has just moved the duplicate one line down.
697
+ * Still needed now that keys are camel-cased rather than copied from the
698
+ * column: `toWireKey` splits on separators and joins, which fixes `full name`
699
+ * but cannot fix a name that is not an identifier for some other reason
700
+ * `2fa_enabled` becomes `2faEnabled`, still leading with a digit, and `order`
701
+ * was never a separator problem at all.
625
702
  */
626
- function firstFreeKey(candidates: string[], taken: ReadonlyMap<string, unknown>): string {
627
- for (const candidate of candidates) {
628
- if (!taken.has(candidate)) return candidate;
629
- }
630
- const base = candidates[candidates.length - 1];
631
- for (let suffix = 2; ; suffix++) {
632
- const candidate = `${base}_${suffix}`;
633
- if (!taken.has(candidate)) return candidate;
634
- }
703
+ function propKey(name: string): string {
704
+ return JS_IDENTIFIER.test(name) ? name : quote(name);
705
+ }
706
+
707
+ /**
708
+ * Text safe to put after `//`.
709
+ *
710
+ * A table comment or a classification reason is free text out of the database.
711
+ * A newline in one ended the comment and let the rest of the value continue as
712
+ * code.
713
+ */
714
+ function commentText(value: string): string {
715
+ return value.replace(/\s*[\r\n]+\s*/g, " ");
635
716
  }
636
717
 
637
718
  /** A property-key array, one key per line, indented for the admin block. */
@@ -674,7 +755,16 @@ export function generateCollectionFile(
674
755
  .map((u) => u.column_names[0])
675
756
  );
676
757
 
677
- const imports = new Set<string>(['import { PostgresCollectionConfig } from "@rebasepro/types";']);
758
+ const builder: CollectionBuilder = context.builder ?? "admin-types";
759
+ /** Whether the target project has an `admin` field to write into at all. */
760
+ const emitAdmin = builder === "admin-types";
761
+
762
+ const BUILDER_IMPORT: Record<CollectionBuilder, string> = {
763
+ "admin-types": `import { defineCollection } from ${quote(ADMIN_TYPES_PACKAGE)};`,
764
+ common: `import { defineCollection } from ${quote(COMMON_PACKAGE)};`,
765
+ annotation: `import { PostgresCollectionConfig } from ${quote(TYPES_PACKAGE)};`
766
+ };
767
+ const imports = new Set<string>([BUILDER_IMPORT[builder]]);
678
768
 
679
769
  /**
680
770
  * Imports the collection a relation points at — unless it is this one.
@@ -688,14 +778,48 @@ export function generateCollectionFile(
688
778
  */
689
779
  const importCollection = (otherTable: string): string => {
690
780
  const varName = toCollectionVarName(otherTable);
691
- if (otherTable !== tableName) imports.add(`import ${varName} from "./${otherTable}";`);
781
+ if (otherTable !== tableName) imports.add(`import ${varName} from ${quote(`./${otherTable}`)};`);
692
782
  return varName;
693
783
  };
694
784
 
785
+ /**
786
+ * A relation's `target` thunk, with its return type spelled out.
787
+ *
788
+ * The annotation is what makes `defineCollection` survive a relational schema.
789
+ * Without an explicit type on the const, the collection's type is *inferred*,
790
+ * and a relation cycle — `posts` belongs to `authors`, `authors` has many
791
+ * `posts`; or `employees.reports_to -> employees`, which northwind, chinook and
792
+ * musicbrainz all have — makes that inference circular: `TS7022: implicitly has
793
+ * type 'any' because it is referenced directly or indirectly in its own
794
+ * initializer`, plus `TS7023` on the thunk and `TS2303` on the import alias.
795
+ * Naming the return type lets the checker type the thunk without resolving the
796
+ * collection it points at, which breaks the cycle. Nothing else is given up:
797
+ * the inference that matters runs over `properties`, not over `relations`.
798
+ *
799
+ * The annotated flavour has no cycle to break — its const is already typed — so
800
+ * it keeps the plainer thunk it has always emitted.
801
+ */
802
+ const relationTarget = (targetVarName: string): string => {
803
+ if (builder === "annotation") return `() => ${targetVarName}`;
804
+ imports.add(`import type { AnyCollectionConfig } from ${quote(TYPES_PACKAGE)};`);
805
+ return `(): AnyCollectionConfig => ${targetVarName}`;
806
+ };
807
+
695
808
  let propsOutput = "";
696
809
  let relationsOutput = "";
697
810
  const orderEntries: PropertyOrderEntry[] = [];
698
811
  const propertyBlocks = new Map<string, string>();
812
+ /**
813
+ * Column → the property key it was generated under.
814
+ *
815
+ * Needed because the structural helpers below (`deriveTitleProperty`,
816
+ * `deriveKanbanProperty`, `deriveSort`) answer in *columns* — they read
817
+ * `pg_attribute` — while `display.title`, `kanban.columnProperty` and
818
+ * `sort` name **properties**. The two used to be the same string, so
819
+ * nothing carried the translation; now `full_name` is generated as
820
+ * `fullName` and a title pointing at `full_name` points at nothing.
821
+ */
822
+ const keyByColumn = new Map<string, string>();
699
823
  /** Properties the list view will not render, so `listProperties` skips them. */
700
824
  const hiddenFromCollection = new Set<string>();
701
825
  let columnIndex = 0;
@@ -711,6 +835,23 @@ export function generateCollectionFile(
711
835
 
712
836
  const currentIndex = columnIndex++;
713
837
 
838
+ // The key this column is generated under — its *wire* name, which is
839
+ // not its column name. `columnName` below carries the column, so the
840
+ // two never have to agree and the API stops carrying `user_id` next to
841
+ // `displayName`.
842
+ //
843
+ // Camel-casing makes collisions possible where none existed: `user_id`
844
+ // and `userId` are two columns and one key, which is a duplicate key in
845
+ // an object literal — a TypeScript error that stops the whole generated
846
+ // collection compiling. Resolved the same way the foreign-key loop
847
+ // below resolves its own: first free candidate, then a numbered tail,
848
+ // so no column is ever dropped. The raw column name is the second
849
+ // candidate, so the loser of a collision still gets a name that means
850
+ // something. Deterministic for a given database: the columns arrive in
851
+ // ordinal order, so the same schema always yields the same keys.
852
+ const propertyKey = firstFreeKey([toWireKey(col.column_name), col.column_name], propertyBlocks);
853
+ keyByColumn.set(col.column_name, propertyKey);
854
+
714
855
  // Check if this column uses a PostgreSQL enum type
715
856
  const colEnumValues = enumMap.get(col.udt_name);
716
857
  const isEnumColumn = col.data_type === "USER-DEFINED" && colEnumValues !== undefined;
@@ -727,7 +868,7 @@ export function generateCollectionFile(
727
868
 
728
869
  if (!isEnumColumn && sampleData && sampleData.length > 0) {
729
870
  const values = sampleData.map(r => r[col.column_name]);
730
- const inferred = inferPropertyFromData(col.column_name, col.data_type, propType, values, meta.pks.includes(col.column_name));
871
+ const inferred = inferPropertyFromData(col.column_name, col.data_type, propType, values, meta.pks.includes(col.column_name), emitAdmin);
731
872
  if (inferred.propType) finalPropType = inferred.propType;
732
873
  if (inferred.extra) inferenceExtra = inferred.extra;
733
874
  }
@@ -737,7 +878,7 @@ export function generateCollectionFile(
737
878
  // Enum values — generate real enum from the PG enum
738
879
  if (isEnumColumn && colEnumValues) {
739
880
  const enumEntries = colEnumValues
740
- .map((v) => `{ id: "${v}", label: "${humanize(v)}" }`)
881
+ .map((v) => `{ id: ${quote(v)}, label: ${quote(humanize(v))} }`)
741
882
  .join(", ");
742
883
  extra += `\n enum: [${enumEntries}],`;
743
884
  } else if (columnChecks?.enumValues && !inferenceExtra.includes("enum:") && propType === "string") {
@@ -754,13 +895,16 @@ export function generateCollectionFile(
754
895
  // Date auto-value heuristics
755
896
  if (finalPropType === "date") {
756
897
  if (colNameLower === "created_at" || colNameLower === "createdat") {
757
- extra += "\n autoValue: \"on_create\",\n admin: {\n readOnly: true,\n hideFromCollection: true\n },";
758
- hiddenFromCollection.add(col.column_name);
898
+ extra += "\n autoValue: \"on_create\",";
899
+ extra = withAdminOptions(extra, ["readOnly: true", "hideFromCollection: true"], emitAdmin);
900
+ hiddenFromCollection.add(propertyKey);
759
901
  } else if (colNameLower === "updated_at" || colNameLower === "updatedat") {
760
- extra += "\n autoValue: \"on_update\",\n admin: {\n readOnly: true,\n hideFromCollection: true\n },";
761
- hiddenFromCollection.add(col.column_name);
902
+ extra += "\n autoValue: \"on_update\",";
903
+ extra = withAdminOptions(extra, ["readOnly: true", "hideFromCollection: true"], emitAdmin);
904
+ hiddenFromCollection.add(propertyKey);
762
905
  } else if (col.column_default && (col.column_default.includes("now()") || col.column_default.includes("CURRENT_TIMESTAMP"))) {
763
- extra += "\n autoValue: \"on_create\",\n admin: {\n readOnly: true\n },";
906
+ extra += "\n autoValue: \"on_create\",";
907
+ extra = withAdminOptions(extra, ["readOnly: true"], emitAdmin);
764
908
  }
765
909
  }
766
910
 
@@ -776,9 +920,9 @@ export function generateCollectionFile(
776
920
  else if (innerType === "boolean") colType = "boolean[]";
777
921
  }
778
922
  if (colType) {
779
- extra += `\n columnType: "${colType}",`;
923
+ extra += `\n columnType: ${quote(colType)},`;
780
924
  }
781
- extra += `\n of: { name: "${humanize(col.column_name)} Item", type: "${innerType}" },`;
925
+ extra += `\n of: { name: ${quote(`${humanize(col.column_name)} Item`)}, type: ${quote(innerType)} },`;
782
926
  } else if (finalPropType === "map" && !inferenceExtra.includes("keyValue: true") && !inferenceExtra.includes("properties: {")) {
783
927
  extra += "\n keyValue: true,";
784
928
  }
@@ -789,20 +933,20 @@ export function generateCollectionFile(
789
933
  const isMedia = colNameLower.includes("image") || colNameLower.includes("avatar") || colNameLower.includes("photo") || colNameLower.includes("logo") || colNameLower.includes("cover");
790
934
 
791
935
  if (isMedia) {
792
- extra += `\n storage: {\n storagePath: "${tableName}/${col.column_name}"\n },`;
936
+ extra += `\n storage: {\n storagePath: ${quote(`${tableName}/${col.column_name}`)}\n },`;
793
937
  } else if (isUrl) {
794
938
  extra += "\n url: true,";
795
939
  } else if (colNameLower === "description" || colNameLower === "summary" || colNameLower === "excerpt") {
796
- extra += "\n admin: {\n multiline: true\n },";
940
+ extra = withAdminOptions(extra, ["multiline: true"], emitAdmin);
797
941
  } else if (colNameLower === "content" || colNameLower === "body") {
798
942
  // Inside `admin`, because that is where both options live. At the
799
943
  // top of the property — where these were — the generated file
800
944
  // does not compile: `StringProperty` declares neither. Six of
801
945
  // OpenStreetMap's tables have a `body` column, which is how this
802
946
  // surfaced.
803
- extra += "\n admin: {\n multiline: true,\n markdown: true\n },";
947
+ extra = withAdminOptions(extra, ["multiline: true", "markdown: true"], emitAdmin);
804
948
  } else if (col.data_type === "text") {
805
- extra += "\n admin: {\n multiline: true\n },";
949
+ extra = withAdminOptions(extra, ["multiline: true"], emitAdmin);
806
950
  }
807
951
  }
808
952
 
@@ -857,9 +1001,9 @@ export function generateCollectionFile(
857
1001
  const options = ["readOnly: true"];
858
1002
  if (isDerivedIndexColumn(col) && !hasGeneratedKey(extra, "hideFromCollection")) {
859
1003
  options.push("hideFromCollection: true");
860
- hiddenFromCollection.add(col.column_name);
1004
+ hiddenFromCollection.add(propertyKey);
861
1005
  }
862
- extra = withAdminOptions(extra, options);
1006
+ extra = withAdminOptions(extra, options, emitAdmin);
863
1007
  }
864
1008
 
865
1009
  // `COMMENT ON COLUMN` — documentation the author already wrote, which
@@ -872,7 +1016,7 @@ export function generateCollectionFile(
872
1016
  // Identify IDs (unless already inferred as UUID/CUID by inferenceEngine)
873
1017
  if (meta.pks.includes(col.column_name)) {
874
1018
  if (isCompositePk) {
875
- extra += `\n // Part of composite primary key (${meta.pks.join(", ")})`;
1019
+ extra += `\n // Part of composite primary key (${commentText(meta.pks.join(", "))})`;
876
1020
  } else if (finalPropType === "number" && !inferenceExtra.includes("isId:")) {
877
1021
  extra += "\n isId: \"increment\",";
878
1022
  } else if (col.data_type.toLowerCase() === "uuid" && !inferenceExtra.includes("isId:")) {
@@ -901,7 +1045,7 @@ export function generateCollectionFile(
901
1045
  const humanName = humanize(col.column_name);
902
1046
 
903
1047
  orderEntries.push({
904
- key: col.column_name,
1048
+ key: propertyKey,
905
1049
  ctx: {
906
1050
  propType: finalPropType,
907
1051
  isPk: meta.pks.includes(col.column_name),
@@ -912,11 +1056,11 @@ export function generateCollectionFile(
912
1056
  }
913
1057
  });
914
1058
 
915
- propertyBlocks.set(col.column_name, `
916
- ${col.column_name}: {
917
- name: "${humanName}",
918
- columnName: "${col.column_name}",
919
- type: "${finalPropType}",${extra}
1059
+ propertyBlocks.set(propertyKey, `
1060
+ ${propKey(propertyKey)}: {
1061
+ name: ${quote(humanName)},
1062
+ columnName: ${quote(col.column_name)},
1063
+ type: ${quote(finalPropType)},${extra}
920
1064
  },`);
921
1065
  }
922
1066
 
@@ -936,11 +1080,17 @@ export function generateCollectionFile(
936
1080
  // every foreign key after the table it points at — `area_tag (area,
937
1081
  // tag)` — so 67 of its 339 collections came out with a property
938
1082
  // declared twice.
1083
+ //
1084
+ // Camel-cased for the same reason the columns above are: this is a
1085
+ // property key, it sits in the same object literal, and a
1086
+ // `blog_author` beside a `publishedAt` is the two-conventions
1087
+ // defect reproduced inside a single collection.
1088
+ const stripped = toWireKey(fk.column_name.replace(/_id$/, ""));
939
1089
  const relName = firstFreeKey(
940
1090
  [
941
- fk.column_name.replace(/_id$/, ""),
942
- targetTableName,
943
- `${fk.column_name.replace(/_id$/, "")}_relation`
1091
+ stripped,
1092
+ toWireKey(targetTableName),
1093
+ `${stripped}Relation`
944
1094
  ],
945
1095
  propertyBlocks
946
1096
  );
@@ -962,14 +1112,14 @@ export function generateCollectionFile(
962
1112
  const relHumanName = humanize(relName);
963
1113
 
964
1114
  propertyBlocks.set(relName, `
965
- ${relName}: {
966
- name: "${relHumanName}",
1115
+ ${propKey(relName)}: {
1116
+ name: ${quote(relHumanName)},
967
1117
  type: "relation",
968
- // mapped from foreign key: ${fk.column_name} -> ${targetTableName}(${fk.foreign_column_name})
1118
+ // mapped from foreign key: ${commentText(fk.column_name)} -> ${commentText(targetTableName)}(${commentText(fk.foreign_column_name)})
969
1119
  relation: {
970
1120
  kind: "belongsTo",
971
- target: () => ${targetCollectionCamel},
972
- localKey: "${fk.column_name}"
1121
+ target: ${relationTarget(targetCollectionCamel)},
1122
+ localKey: ${quote(fk.column_name)}
973
1123
  }
974
1124
  },`);
975
1125
  }
@@ -986,10 +1136,10 @@ export function generateCollectionFile(
986
1136
  relationsOutput += `
987
1137
  {
988
1138
  kind: "hasMany",
989
- relationName: "${sourceTableName}",
990
- target: () => ${targetCollectionCamel},
991
- // the ${sourceTableName}.${fk.column_name} FK points back here
992
- foreignKeyOnTarget: "${fk.column_name}"
1139
+ relationName: ${quote(sourceTableName)},
1140
+ target: ${relationTarget(targetCollectionCamel)},
1141
+ // the ${commentText(sourceTableName)}.${commentText(fk.column_name)} FK points back here
1142
+ foreignKeyOnTarget: ${quote(fk.column_name)}
993
1143
  },`;
994
1144
  }
995
1145
 
@@ -1018,12 +1168,12 @@ export function generateCollectionFile(
1018
1168
  relationsOutput += `
1019
1169
  {
1020
1170
  kind: "manyToMany",
1021
- relationName: "${relPropName}",
1022
- target: () => ${tableName}Collection,
1171
+ relationName: ${quote(relPropName)},
1172
+ target: ${relationTarget(toCollectionVarName(tableName))},
1023
1173
  through: {
1024
- table: "${jt}",
1025
- sourceColumn: "${thisFk.column_name}",
1026
- targetColumn: "${otherFk.column_name}"
1174
+ table: ${quote(jt)},
1175
+ sourceColumn: ${quote(thisFk.column_name)},
1176
+ targetColumn: ${quote(otherFk.column_name)}
1027
1177
  }
1028
1178
  },`;
1029
1179
  continue;
@@ -1045,14 +1195,14 @@ export function generateCollectionFile(
1045
1195
  const thisFk = joinFks.find((fk) => fk.foreign_table_name === tableName);
1046
1196
 
1047
1197
  const throughCode = thisFk
1048
- ? `\n through: {\n table: "${jt}",\n sourceColumn: "${thisFk.column_name}",\n targetColumn: "${otherFk.column_name}"\n }`
1198
+ ? `\n through: {\n table: ${quote(jt)},\n sourceColumn: ${quote(thisFk.column_name)},\n targetColumn: ${quote(otherFk.column_name)}\n }`
1049
1199
  : "";
1050
1200
 
1051
1201
  relationsOutput += `
1052
1202
  {
1053
1203
  kind: "manyToMany",
1054
- relationName: "${targetTableName}",
1055
- target: () => ${targetCollectionCamel},${throughCode}
1204
+ relationName: ${quote(targetTableName)},
1205
+ target: ${relationTarget(targetCollectionCamel)},${throughCode}
1056
1206
  },`;
1057
1207
  }
1058
1208
  }
@@ -1071,7 +1221,7 @@ export function generateCollectionFile(
1071
1221
  // config, where they have not belonged since the admin block was split out:
1072
1222
  // `PostgresCollectionConfig` does not declare them, so every generated file
1073
1223
  // was a type error, and the panel — which reads the block — never saw them.
1074
- const adminEntries: string[] = [`icon: "${icon}"`];
1224
+ const adminEntries: string[] = [`icon: ${quote(icon)}`];
1075
1225
 
1076
1226
  if (classification) {
1077
1227
  const derivedFacts = context.metadata
@@ -1088,14 +1238,17 @@ export function generateCollectionFile(
1088
1238
  }
1089
1239
 
1090
1240
  if (derivedFacts) {
1241
+ // Each of these comes back as a column and is emitted as a property.
1242
+ const asProperty = (column: string): string => keyByColumn.get(column) ?? toWireKey(column);
1243
+
1091
1244
  const titleProperty = deriveTitleProperty(derivedFacts);
1092
- if (titleProperty) adminEntries.push(`display: { title: ${quote(titleProperty)} }`);
1245
+ if (titleProperty) adminEntries.push(`display: { title: ${quote(asProperty(titleProperty))} }`);
1093
1246
 
1094
1247
  const kanbanProperty = deriveKanbanProperty(derivedFacts);
1095
- if (kanbanProperty) adminEntries.push(`kanban: {\n columnProperty: ${quote(kanbanProperty)}\n }`);
1248
+ if (kanbanProperty) adminEntries.push(`kanban: {\n columnProperty: ${quote(asProperty(kanbanProperty))}\n }`);
1096
1249
 
1097
1250
  const sort = deriveSort(derivedFacts);
1098
- if (sort) adminEntries.push(`sort: [${quote(sort[0])}, "desc"]`);
1251
+ if (sort) adminEntries.push(`sort: [${quote(asProperty(sort[0]))}, "desc"]`);
1099
1252
  }
1100
1253
 
1101
1254
  const listProperties = deriveListProperties(sortedPropertiesOrder, hiddenFromCollection);
@@ -1106,7 +1259,14 @@ export function generateCollectionFile(
1106
1259
 
1107
1260
  adminEntries.push(`propertiesOrder: ${formatKeyList(sortedPropertiesOrder)}`);
1108
1261
 
1109
- const adminBlock = `\n admin: {\n ${adminEntries.join(",\n ")}\n }`;
1262
+ // Every entry above names a property key, and with `defineCollection` those
1263
+ // keys are now checked against `properties` — a stale `propertiesOrder` entry
1264
+ // left behind by a renamed column is a compile error rather than a silent
1265
+ // no-op. Which is also why the block cannot be emitted where the field is not
1266
+ // declared: see {@link CollectionBuilder}.
1267
+ const adminBlock = emitAdmin
1268
+ ? `\n admin: {\n ${adminEntries.join(",\n ")}\n }`
1269
+ : "";
1110
1270
 
1111
1271
  const descriptionBlock = tableComment
1112
1272
  ? `\n description: ${quote(tableComment)},`
@@ -1116,20 +1276,36 @@ export function generateCollectionFile(
1116
1276
  // decision the reader may disagree with. Naming the evidence tells them
1117
1277
  // which line to delete when they do.
1118
1278
  const classificationNote = classification && classification.role !== "entity"
1119
- ? `\n// Introspected as a ${classification.role}: ${classification.reason}.\n`
1279
+ ? `\n// Introspected as a ${commentText(classification.role)}: ${commentText(classification.reason)}.\n`
1120
1280
  : "";
1121
1281
 
1122
1282
  const collectionVarName = toCollectionVarName(tableName);
1123
- const fileContent = `${Array.from(imports).join("\n")}
1283
+ // `const x = defineCollection({ … })` is also the shape the ts-morph schema
1284
+ // editor in `@rebasepro/server` expects — `COLLECTION_FACTORIES` — so an
1285
+ // introspected collection is now editable from the panel the way a scaffolded
1286
+ // one is.
1287
+ const [open, close] = builder === "annotation"
1288
+ ? [`const ${collectionVarName}: PostgresCollectionConfig = {`, "};"]
1289
+ : [`const ${collectionVarName} = defineCollection({`, "});"];
1290
+ // Package imports first, then siblings. `AnyCollectionConfig` is added the
1291
+ // moment the first relation needs it — which is after the sibling collections
1292
+ // it points at have already been added — and only when a relation needs it, so
1293
+ // a project with `noUnusedLocals` never sees an import it does not use.
1294
+ const importLines = Array.from(imports);
1295
+ const orderedImports = [
1296
+ ...importLines.filter((line) => !line.includes('from "./')),
1297
+ ...importLines.filter((line) => line.includes('from "./'))
1298
+ ];
1299
+ const fileContent = `${orderedImports.join("\n")}
1124
1300
  ${classificationNote}
1125
- const ${collectionVarName}: PostgresCollectionConfig = {
1126
- name: "${collectionName}",
1127
- singularName: "${singular}",
1128
- slug: "${tableName}",
1129
- table: "${tableName}",${descriptionBlock}
1301
+ ${open}
1302
+ name: ${quote(collectionName)},
1303
+ singularName: ${quote(singular)},
1304
+ slug: ${quote(tableName)},
1305
+ table: ${quote(tableName)},${descriptionBlock}
1130
1306
  properties: {${propsOutput}
1131
1307
  },${relationsBlock}${adminBlock}
1132
- };
1308
+ ${close}
1133
1309
 
1134
1310
  export default ${collectionVarName};
1135
1311
  `;
@@ -1146,7 +1322,7 @@ export function generateIndexContent(fileNames: string[]): string {
1146
1322
  let arrayElements = "";
1147
1323
  for (const f of sorted) {
1148
1324
  const varName = toCollectionVarName(f);
1149
- imports += `import ${varName} from "./${f}";\n`;
1325
+ imports += `import ${varName} from ${quote(`./${f}`)};\n`;
1150
1326
  arrayElements += ` ${varName},\n`;
1151
1327
  }
1152
1328
  return `${imports}\nexport const collections = [\n${arrayElements}];\n`;
@@ -1168,7 +1344,7 @@ export function mergeIndexContent(existingContent: string, newFileNames: string[
1168
1344
  for (const f of sorted) {
1169
1345
  if (!existingImports.has(f)) {
1170
1346
  const varName = toCollectionVarName(f);
1171
- newImports += `import ${varName} from "./${f}";\n`;
1347
+ newImports += `import ${varName} from ${quote(`./${f}`)};\n`;
1172
1348
  newElements += ` ${varName},\n`;
1173
1349
  }
1174
1350
  }