@rebasepro/server-postgres 0.10.1-canary.ed8caed → 0.10.1-canary.gdaf6ba4
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/auth/schema-version.d.ts +106 -0
- package/dist/collections/validate-relations.d.ts +53 -0
- package/dist/data-transformer.d.ts +3 -3
- package/dist/{ensure-collection-tables-BVvtkkRm.js → ensure-collection-tables-DGMYK0fr.js} +5 -5
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
- package/dist/index.es.js +610 -313
- package/dist/index.es.js.map +1 -1
- package/dist/services/RelationService.d.ts +9 -8
- package/dist/services/cdc/junction-tables.d.ts +38 -0
- package/dist/services/nested-path.d.ts +3 -3
- package/dist/services/realtimeService.d.ts +19 -0
- package/dist/services/row-pipeline.d.ts +2 -2
- package/dist/{src-lcfUP4xg.js → src-3VmUJ8Xn.js} +135 -247
- package/dist/src-3VmUJ8Xn.js.map +1 -0
- package/dist/{src-CBgtrPhJ.js → src-D5xBTl32.js} +12 -2
- package/dist/src-D5xBTl32.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +35 -23
- package/package.json +6 -6
- package/src/PostgresBootstrapper.ts +15 -2
- package/src/auth/ensure-tables.ts +23 -0
- package/src/auth/schema-version.ts +260 -0
- package/src/cli-errors.ts +1 -1
- package/src/cli-helpers.ts +4 -3
- package/src/collections/PostgresCollectionRegistry.ts +9 -4
- package/src/collections/buildRegistry.ts +7 -0
- package/src/collections/validate-relations.ts +280 -0
- package/src/data-transformer.ts +28 -38
- package/src/schema/doctor.ts +8 -8
- package/src/schema/generate-drizzle-schema-logic.ts +49 -101
- package/src/schema/generate-postgres-ddl-logic.ts +9 -9
- package/src/schema/introspect-db-logic.ts +19 -23
- package/src/services/FetchService.ts +12 -12
- package/src/services/PersistService.ts +23 -27
- package/src/services/RelationService.ts +65 -83
- package/src/services/cdc/junction-tables.ts +91 -0
- package/src/services/nested-path.ts +24 -9
- package/src/services/realtimeService.ts +60 -0
- package/src/services/row-pipeline.ts +5 -6
- package/src/utils/drizzle-conditions.ts +177 -382
- package/dist/ensure-collection-tables-BVvtkkRm.js.map +0 -1
- package/dist/src-CBgtrPhJ.js.map +0 -1
- package/dist/src-lcfUP4xg.js.map +0 -1
package/dist/index.es.js
CHANGED
|
@@ -2,8 +2,8 @@ import { createRequire as __createRequire } from "module";
|
|
|
2
2
|
import process from "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
4
|
import { i as __toESM, n as __exportAll, r as __require, t as __commonJSMin } from "./chunk-DSJWtz9O.js";
|
|
5
|
-
import { a as isSchemaAdmin,
|
|
6
|
-
import { C as createRelationRefWithData, D as mergeDeep, E as getPolicyNamesForRule, O as camelCase, S as createRelationRef, T as updateDateAutoValues, _ as getTableVarName, a as getJunctionCollectionConfig, b as getDeclaredPrimaryKeys, c as getEffectiveSecurityRules, d as securityRuleToConditions, f as findAnonymousGrants, g as getTableName$1, h as getEnumVarName, i as CollectionRegistry, k as toSnakeCase, l as buildPropertyCallbacks, m as getColumnName, n as detectJunctionTables, o as getJunctionSecurityRules, p as findRelation, r as buildSdkData, s as resolveJunctionSpecs, t as classifyTable, u as policyToPostgres, v as resolveCollectionRelations, w as normalizeToEntityRelation, x as parseIdValues, y as buildCompositeId } from "./src-
|
|
5
|
+
import { a as isSchemaAdmin, c as hasForeignKeyOnTarget, d as isPostgresCollectionConfig, g as Vector, i as isSQLAdmin, l as isManyToMany, o as ANONYMOUS_USER_ID, r as isChannelBusInstance } from "./src-D5xBTl32.js";
|
|
6
|
+
import { C as createRelationRefWithData, D as mergeDeep, E as getPolicyNamesForRule, O as camelCase, S as createRelationRef, T as updateDateAutoValues, _ as getTableVarName, a as getJunctionCollectionConfig, b as getDeclaredPrimaryKeys, c as getEffectiveSecurityRules, d as securityRuleToConditions, f as findAnonymousGrants, g as getTableName$1, h as getEnumVarName, i as CollectionRegistry, k as toSnakeCase, l as buildPropertyCallbacks, m as getColumnName, n as detectJunctionTables, o as getJunctionSecurityRules, p as findRelation, r as buildSdkData, s as resolveJunctionSpecs, t as classifyTable, u as policyToPostgres, v as resolveCollectionRelations, w as normalizeToEntityRelation, x as parseIdValues, y as buildCompositeId } from "./src-3VmUJ8Xn.js";
|
|
7
7
|
import { Client, Pool } from "pg";
|
|
8
8
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
9
9
|
import { ApiError, createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
|
|
@@ -413,28 +413,32 @@ var DrizzleConditionBuilder = class {
|
|
|
413
413
|
* it and the final step correlating to the outer row.
|
|
414
414
|
*/
|
|
415
415
|
static buildRelationScopeCondition(relation, parent, parentId, targetTable, targetIdColumn, registry) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
416
|
+
switch (relation.kind) {
|
|
417
|
+
case "via": {
|
|
418
|
+
const { table, idColumn } = parent();
|
|
419
|
+
return this.buildJoinPathScopeCondition(relation.joinPath, table, idColumn, parentId, targetTable, registry);
|
|
420
|
+
}
|
|
421
|
+
case "manyToMany": {
|
|
422
|
+
const { table: junctionName, sourceColumn, targetColumn } = relation.through;
|
|
423
|
+
const junctionTable = registry.getTable(junctionName);
|
|
424
|
+
if (!junctionTable) throw new Error(`Junction table not found: ${junctionName}`);
|
|
425
|
+
const sourceCol = junctionTable[sourceColumn];
|
|
426
|
+
const targetCol = junctionTable[targetColumn];
|
|
427
|
+
if (!sourceCol || !targetCol) throw new Error(`Junction columns '${sourceColumn}'/'${targetColumn}' not found in '${junctionName}'`);
|
|
428
|
+
return sql`EXISTS (SELECT 1 FROM ${junctionTable} WHERE ${targetCol} = ${targetIdColumn} AND ${sourceCol} = ${parentId})`;
|
|
429
|
+
}
|
|
430
|
+
case "hasOne":
|
|
431
|
+
case "hasMany": {
|
|
432
|
+
const fkColumn = targetTable[relation.foreignKeyOnTarget];
|
|
433
|
+
if (!fkColumn) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'.`);
|
|
434
|
+
return eq(fkColumn, parentId);
|
|
435
|
+
}
|
|
436
|
+
case "belongsTo": {
|
|
437
|
+
const { table, idColumn } = parent();
|
|
438
|
+
return sql`${targetIdColumn} = (SELECT ${sql.identifier(relation.localKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
|
|
439
|
+
}
|
|
440
|
+
default: throw new Error(`Unknown relation kind: ${JSON.stringify(relation)}`);
|
|
436
441
|
}
|
|
437
|
-
throw new Error(`Relation '${relation.relationName}' declares no \`foreignKeyOnTarget\`, \`through\`, \`joinPath\` or \`localKey\`, so there is no way to tell which target rows belong to a parent.`);
|
|
438
442
|
}
|
|
439
443
|
/**
|
|
440
444
|
* `EXISTS` for an explicit `joinPath`.
|
|
@@ -445,7 +449,7 @@ var DrizzleConditionBuilder = class {
|
|
|
445
449
|
* alias. Each intermediate table is aliased positionally, which keeps a path
|
|
446
450
|
* that revisits a table (a self-referencing many-to-many) unambiguous.
|
|
447
451
|
*/
|
|
448
|
-
static buildJoinPathScopeCondition(joinPath, parentTable, parentIdColumn, parentId,
|
|
452
|
+
static buildJoinPathScopeCondition(joinPath, parentTable, parentIdColumn, parentId, targetTable, registry) {
|
|
449
453
|
const sourceAlias = "__rel_src";
|
|
450
454
|
const aliasFor = (index) => `__rel_j${index}`;
|
|
451
455
|
const fromRef = (stepIndex, column) => sql`${sql.identifier(stepIndex === 0 ? sourceAlias : aliasFor(stepIndex - 1))}.${sql.identifier(getColumnName(column))}`;
|
|
@@ -466,8 +470,12 @@ var DrizzleConditionBuilder = class {
|
|
|
466
470
|
const on = pairs(step).map(({ from, to }) => sql`${fromRef(index, from)} = ${sql.identifier(aliasFor(index))}.${sql.identifier(getColumnName(to))}`);
|
|
467
471
|
return sql`JOIN ${table} AS ${sql.identifier(aliasFor(index))} ON ${sql.join(on, sql` AND `)}`;
|
|
468
472
|
});
|
|
469
|
-
const
|
|
470
|
-
|
|
473
|
+
const targetColumn = (name) => {
|
|
474
|
+
const column = targetTable[getColumnName(name)];
|
|
475
|
+
if (!column) throw new Error(`Join step column '${name}' not found in the target table of this joinPath`);
|
|
476
|
+
return column;
|
|
477
|
+
};
|
|
478
|
+
const correlation = sql.join(pairs(last).map(({ from, to }) => sql`${fromRef(inner.length, from)} = ${targetColumn(to)}`), sql` AND `);
|
|
471
479
|
const joinsSql = joins.length > 0 ? sql` ${sql.join(joins, sql` `)}` : sql``;
|
|
472
480
|
return sql`EXISTS (SELECT 1 FROM ${parentTable} AS ${sql.identifier(sourceAlias)}${joinsSql} WHERE ${sql.identifier(sourceAlias)}.${sql.identifier(parentIdColumn.name)} = ${parentId} AND ${correlation})`;
|
|
473
481
|
}
|
|
@@ -568,58 +576,43 @@ var DrizzleConditionBuilder = class {
|
|
|
568
576
|
/**
|
|
569
577
|
* Build relation-based conditions for different relation types
|
|
570
578
|
*/
|
|
579
|
+
/**
|
|
580
|
+
* Joins and where-conditions that reach a relation's target rows.
|
|
581
|
+
*
|
|
582
|
+
* One case per kind. This used to be a chain of six `else if`s over
|
|
583
|
+
* `cardinality`/`direction`/`through`, ending in
|
|
584
|
+
* `findCorrespondingJunctionTable` — a search through the *target's* own
|
|
585
|
+
* relations to work out whether an "inverse many" was a one-to-many or the
|
|
586
|
+
* far side of a junction. That search is gone: the kind says which it is.
|
|
587
|
+
*
|
|
588
|
+
* The owning/inverse split for junctions is gone too. Both variants built
|
|
589
|
+
* the identical condition — `through` is always written from the declaring
|
|
590
|
+
* side's point of view — so the second was a distinction without a
|
|
591
|
+
* difference and one of the places the two could drift apart.
|
|
592
|
+
*/
|
|
571
593
|
static buildRelationConditions(relation, parentId, targetTable, parentTable, parentIdColumn, targetIdColumn, registry) {
|
|
572
|
-
console.debug("🔍 [buildRelationConditions] Building conditions for relation:", {
|
|
573
|
-
relationName: relation.relationName,
|
|
574
|
-
cardinality: relation.cardinality,
|
|
575
|
-
direction: relation.direction,
|
|
576
|
-
hasThrough: !!relation.through,
|
|
577
|
-
hasForeignKeyOnTarget: !!relation.foreignKeyOnTarget,
|
|
578
|
-
inverseRelationName: relation.inverseRelationName,
|
|
579
|
-
parentId
|
|
580
|
-
});
|
|
581
594
|
const joinConditions = [];
|
|
582
595
|
const whereConditions = [];
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
whereConditions.push(junctionResult.condition);
|
|
593
|
-
} else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") {
|
|
594
|
-
console.debug("🔍 [buildRelationConditions] Using inverse many-to-many with explicit through");
|
|
595
|
-
const junctionResult = this.buildInverseJunctionTableConditions(relation.through, targetIdColumn, parentId, registry);
|
|
596
|
-
joinConditions.push(junctionResult.join);
|
|
597
|
-
whereConditions.push(junctionResult.condition);
|
|
598
|
-
} else if (relation.cardinality === "many" && relation.direction === "inverse" && !relation.through) {
|
|
599
|
-
console.debug("🔍 [buildRelationConditions] Handling inverse many relationship without explicit through");
|
|
600
|
-
const junctionInfo = this.findCorrespondingJunctionTable(relation, registry);
|
|
601
|
-
if (junctionInfo) {
|
|
602
|
-
console.debug("🔍 [buildRelationConditions] Found junction info for inverse many-to-many, building junction conditions");
|
|
603
|
-
const junctionResult = this.buildInverseJunctionTableConditions(junctionInfo, targetIdColumn, parentId, registry);
|
|
596
|
+
switch (relation.kind) {
|
|
597
|
+
case "via": {
|
|
598
|
+
const { joins, finalCondition } = this.buildJoinPathConditions(relation.joinPath, targetTable, parentTable, parentIdColumn, parentId, registry);
|
|
599
|
+
joinConditions.push(...joins);
|
|
600
|
+
whereConditions.push(finalCondition);
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
case "manyToMany": {
|
|
604
|
+
const junctionResult = this.buildJunctionTableConditions(relation.through, targetIdColumn, parentId, registry);
|
|
604
605
|
joinConditions.push(junctionResult.join);
|
|
605
606
|
whereConditions.push(junctionResult.condition);
|
|
606
|
-
|
|
607
|
-
console.debug("🔍 [buildRelationConditions] No junction table found, treating as inverse one-to-many with foreign key on target");
|
|
608
|
-
const simpleCondition = this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId);
|
|
609
|
-
whereConditions.push(simpleCondition);
|
|
610
|
-
} else {
|
|
611
|
-
logger.error("🔍 [buildRelationConditions] Failed to find junction table info and no foreign key specified");
|
|
612
|
-
throw new Error(`Cannot resolve inverse many relation '${relation.relationName}'. Either specify 'through' property, ensure corresponding owning relation exists with junction table configuration, or specify 'foreignKeyOnTarget' for one-to-many relationships.`);
|
|
607
|
+
break;
|
|
613
608
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
609
|
+
case "hasOne":
|
|
610
|
+
case "hasMany":
|
|
611
|
+
case "belongsTo":
|
|
612
|
+
whereConditions.push(this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId));
|
|
613
|
+
break;
|
|
614
|
+
default: throw new Error(`Unknown relation kind: ${JSON.stringify(relation)}`);
|
|
618
615
|
}
|
|
619
|
-
console.debug("🔍 [buildRelationConditions] Final result:", {
|
|
620
|
-
joinConditionsCount: joinConditions.length,
|
|
621
|
-
whereConditionsCount: whereConditions.length
|
|
622
|
-
});
|
|
623
616
|
return {
|
|
624
617
|
joinConditions,
|
|
625
618
|
whereConditions
|
|
@@ -759,48 +752,24 @@ var DrizzleConditionBuilder = class {
|
|
|
759
752
|
};
|
|
760
753
|
}
|
|
761
754
|
/**
|
|
762
|
-
*
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
if (!junctionSourceCol) throw new Error(`Source column '${through.sourceColumn}' not found in junction table '${through.table}'`);
|
|
770
|
-
if (!junctionTargetCol) throw new Error(`Target column '${through.targetColumn}' not found in junction table '${through.table}'`);
|
|
771
|
-
const condition = Array.isArray(parentId) ? inArray(junctionSourceCol, parentId) : eq(junctionSourceCol, parentId);
|
|
772
|
-
return {
|
|
773
|
-
join: {
|
|
774
|
-
table: junctionTable,
|
|
775
|
-
condition: eq(targetIdColumn, junctionTargetCol)
|
|
776
|
-
},
|
|
777
|
-
condition
|
|
778
|
-
};
|
|
779
|
-
}
|
|
780
|
-
/**
|
|
781
|
-
* Build conditions for simple relations (owning/inverse without join paths)
|
|
755
|
+
* The condition for a relation whose link is a single column.
|
|
756
|
+
*
|
|
757
|
+
* Two cases. It had five: two of them existed only to throw ("should not be
|
|
758
|
+
* called directly", "lacks proper configuration"), and one guessed a column
|
|
759
|
+
* name by appending `_id` to `inverseRelationName` when no foreign key had
|
|
760
|
+
* been resolved. All three were reachable only because the old type let a
|
|
761
|
+
* relation arrive here under-specified. It cannot now.
|
|
782
762
|
*/
|
|
783
763
|
static buildSimpleRelationCondition(relation, targetTable, parentTable, parentId) {
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
const foreignKeyCol = targetTable[relation.foreignKeyOnTarget];
|
|
794
|
-
if (!foreignKeyCol) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in target table. This might be a many-to-many relationship that requires a junction table. Consider using 'through' property or ensure the corresponding owning relation exists with junction table configuration.`);
|
|
795
|
-
return Array.isArray(parentId) ? inArray(foreignKeyCol, parentId) : eq(foreignKeyCol, parentId);
|
|
796
|
-
} else if (relation.direction === "inverse" && relation.cardinality === "many" && relation.inverseRelationName) throw new Error(`Inverse many-to-many relation '${relation.relationName}' requires a junction table. Either specify 'through' property or ensure the corresponding owning relation exists with junction table configuration.`);
|
|
797
|
-
else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) {
|
|
798
|
-
const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
|
|
799
|
-
const foreignKeyCol = targetTable[inferredForeignKeyName];
|
|
800
|
-
if (!foreignKeyCol) throw new Error(`Auto-inferred foreign key column '${inferredForeignKeyName}' not found in target table for inverse relation '${relation.relationName}'. Please specify 'foreignKeyOnTarget' explicitly.`);
|
|
801
|
-
console.debug(`🔍 [DrizzleConditionBuilder] Auto-inferred foreign key '${inferredForeignKeyName}' for inverse relation '${relation.relationName}'`);
|
|
802
|
-
return Array.isArray(parentId) ? inArray(foreignKeyCol, parentId) : eq(foreignKeyCol, parentId);
|
|
803
|
-
} else throw new Error(`Relation '${relation.relationName}' lacks proper configuration. For many-to-many relations, use 'through' property. For simple relations, use 'localKey' or 'foreignKeyOnTarget'.`);
|
|
764
|
+
const match = (column) => Array.isArray(parentId) ? inArray(column, parentId) : eq(column, parentId);
|
|
765
|
+
if (relation.kind === "belongsTo") {
|
|
766
|
+
const targetIdCol = Object.values(targetTable).find((col) => col.primary) ?? Object.values(targetTable).find((col) => col.name === "id");
|
|
767
|
+
if (!targetIdCol) throw new Error(`No primary key or "id" column in the target table of relation '${relation.relationName}'.`);
|
|
768
|
+
return match(targetIdCol);
|
|
769
|
+
}
|
|
770
|
+
const foreignKeyCol = targetTable[relation.foreignKeyOnTarget];
|
|
771
|
+
if (!foreignKeyCol) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'. A link through a junction is \`kind: "manyToMany"\`.`);
|
|
772
|
+
return match(foreignKeyCol);
|
|
804
773
|
}
|
|
805
774
|
/**
|
|
806
775
|
* Combine multiple conditions with AND operator
|
|
@@ -855,16 +824,23 @@ var DrizzleConditionBuilder = class {
|
|
|
855
824
|
return query;
|
|
856
825
|
}
|
|
857
826
|
/**
|
|
858
|
-
*
|
|
827
|
+
* A count over a relation's target rows.
|
|
828
|
+
*
|
|
829
|
+
* The junction case counts `distinct` because the caller's query joins the
|
|
830
|
+
* junction; the owning/inverse pair that used to sit here built the same
|
|
831
|
+
* query twice.
|
|
859
832
|
*/
|
|
860
833
|
static buildRelationCountQuery(baseCountQuery, relation, parentId, targetTable, parentTable, parentIdColumn, targetIdColumn, registry, additionalFilters) {
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
834
|
+
switch (relation.kind) {
|
|
835
|
+
case "via": return this.buildJoinPathCountQuery(baseCountQuery, relation.joinPath, targetTable, parentTable, parentIdColumn, parentId, registry, additionalFilters);
|
|
836
|
+
case "manyToMany": return this.buildJunctionCountQuery(baseCountQuery, relation.through, targetIdColumn, parentId, registry, additionalFilters);
|
|
837
|
+
case "belongsTo":
|
|
838
|
+
case "hasOne":
|
|
839
|
+
case "hasMany": {
|
|
840
|
+
const allConditions = [this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId), ...additionalFilters ?? []];
|
|
841
|
+
return baseCountQuery.where(and(...allConditions));
|
|
842
|
+
}
|
|
843
|
+
default: throw new Error(`Unknown relation kind: ${JSON.stringify(relation)}`);
|
|
868
844
|
}
|
|
869
845
|
}
|
|
870
846
|
/**
|
|
@@ -905,20 +881,6 @@ var DrizzleConditionBuilder = class {
|
|
|
905
881
|
return baseCountQuery.innerJoin(junctionTable, eq(targetIdColumn, junctionTargetCol)).where(and(...baseConditions));
|
|
906
882
|
}
|
|
907
883
|
/**
|
|
908
|
-
* Build inverse junction table conditions for count queries
|
|
909
|
-
*/
|
|
910
|
-
static buildInverseJunctionCountQuery(baseCountQuery, through, targetIdColumn, parentId, registry, additionalFilters) {
|
|
911
|
-
const junctionTable = registry.getTable(through.table);
|
|
912
|
-
if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
|
|
913
|
-
const junctionSourceCol = junctionTable[through.sourceColumn];
|
|
914
|
-
const junctionTargetCol = junctionTable[through.targetColumn];
|
|
915
|
-
if (!junctionSourceCol) throw new Error(`Source column '${through.sourceColumn}' not found in junction table '${through.table}'`);
|
|
916
|
-
if (!junctionTargetCol) throw new Error(`Target column '${through.targetColumn}' not found in junction table '${through.table}'`);
|
|
917
|
-
const baseConditions = [eq(junctionSourceCol, parentId)];
|
|
918
|
-
if (additionalFilters && additionalFilters.length > 0) baseConditions.push(...additionalFilters);
|
|
919
|
-
return baseCountQuery.innerJoin(junctionTable, eq(targetIdColumn, junctionTargetCol)).where(and(...baseConditions));
|
|
920
|
-
}
|
|
921
|
-
/**
|
|
922
884
|
* Helper method to extract table names from columns
|
|
923
885
|
*/
|
|
924
886
|
static getTableNamesFromColumns(columns) {
|
|
@@ -933,49 +895,6 @@ var DrizzleConditionBuilder = class {
|
|
|
933
895
|
return [getColumnName(columns)];
|
|
934
896
|
}
|
|
935
897
|
/**
|
|
936
|
-
* Find the corresponding junction table for an inverse many-to-many relation
|
|
937
|
-
*/
|
|
938
|
-
static findCorrespondingJunctionTable(relation, registry) {
|
|
939
|
-
try {
|
|
940
|
-
console.debug(`🔍 [findCorrespondingJunctionTable] Looking for junction table for inverse relation '${relation.relationName}' with inverseRelationName '${relation.inverseRelationName}'`);
|
|
941
|
-
if (!relation.inverseRelationName) {
|
|
942
|
-
console.debug("🔍 [findCorrespondingJunctionTable] No inverseRelationName specified");
|
|
943
|
-
return null;
|
|
944
|
-
}
|
|
945
|
-
const targetCollection = relation.target();
|
|
946
|
-
console.debug(`🔍 [findCorrespondingJunctionTable] Target collection: ${targetCollection.slug}`);
|
|
947
|
-
const targetCollectionRelations = resolveCollectionRelations(targetCollection);
|
|
948
|
-
console.debug("🔍 [findCorrespondingJunctionTable] Target collection relations:", Object.keys(targetCollectionRelations));
|
|
949
|
-
const correspondingRelation = targetCollectionRelations[relation.inverseRelationName];
|
|
950
|
-
if (!correspondingRelation) {
|
|
951
|
-
console.debug(`🔍 [findCorrespondingJunctionTable] No relation found with key '${relation.inverseRelationName}' on target collection`);
|
|
952
|
-
return null;
|
|
953
|
-
}
|
|
954
|
-
console.debug("🔍 [findCorrespondingJunctionTable] Found relation:", {
|
|
955
|
-
relationName: correspondingRelation.relationName,
|
|
956
|
-
cardinality: correspondingRelation.cardinality,
|
|
957
|
-
direction: correspondingRelation.direction,
|
|
958
|
-
hasThrough: !!correspondingRelation.through
|
|
959
|
-
});
|
|
960
|
-
if (correspondingRelation.cardinality !== "many" || correspondingRelation.direction !== "owning" || !correspondingRelation.through) {
|
|
961
|
-
console.debug("🔍 [findCorrespondingJunctionTable] Relation is not an owning many-to-many with junction table");
|
|
962
|
-
return null;
|
|
963
|
-
}
|
|
964
|
-
console.debug("🔍 [findCorrespondingJunctionTable] Found matching owning relation with junction table!");
|
|
965
|
-
const through = correspondingRelation.through;
|
|
966
|
-
const result = {
|
|
967
|
-
table: through.table,
|
|
968
|
-
sourceColumn: through.targetColumn,
|
|
969
|
-
targetColumn: through.sourceColumn
|
|
970
|
-
};
|
|
971
|
-
console.debug("🔍 [findCorrespondingJunctionTable] Returning junction info:", result);
|
|
972
|
-
return result;
|
|
973
|
-
} catch (error) {
|
|
974
|
-
logger.error(`🔍 [findCorrespondingJunctionTable] Error finding corresponding junction table for relation '${relation.relationName}'`, { error });
|
|
975
|
-
return null;
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
/**
|
|
979
898
|
* Build vector similarity search expressions for pgvector.
|
|
980
899
|
*
|
|
981
900
|
* Returns:
|
|
@@ -1052,7 +971,7 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1052
971
|
const joinPathRelationUpdates = [];
|
|
1053
972
|
const foreignKeys = /* @__PURE__ */ new Set();
|
|
1054
973
|
Object.values(resolvedRelations).forEach((relation) => {
|
|
1055
|
-
if (relation.
|
|
974
|
+
if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
|
|
1056
975
|
});
|
|
1057
976
|
for (const [key, value] of Object.entries(row)) {
|
|
1058
977
|
const property = properties[key];
|
|
@@ -1064,11 +983,11 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1064
983
|
if (property.type === "relation" && collection) {
|
|
1065
984
|
const relation = findRelation(resolvedRelations, key);
|
|
1066
985
|
if (relation) {
|
|
1067
|
-
if (relation.
|
|
986
|
+
if (relation.kind === "belongsTo") {
|
|
1068
987
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1069
988
|
if (serializedValue !== void 0) result[relation.localKey] = serializedValue;
|
|
1070
989
|
continue;
|
|
1071
|
-
} else if (relation
|
|
990
|
+
} else if (hasForeignKeyOnTarget(relation)) {
|
|
1072
991
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1073
992
|
inverseRelationUpdates.push({
|
|
1074
993
|
relationKey: key,
|
|
@@ -1076,7 +995,7 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1076
995
|
newValue: serializedValue
|
|
1077
996
|
});
|
|
1078
997
|
continue;
|
|
1079
|
-
} else if (relation.
|
|
998
|
+
} else if (relation.kind === "via") {
|
|
1080
999
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1081
1000
|
if (relation.cardinality === "one") joinPathRelationUpdates.push({
|
|
1082
1001
|
relationKey: key,
|
|
@@ -1089,14 +1008,6 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1089
1008
|
newValue: serializedValue
|
|
1090
1009
|
});
|
|
1091
1010
|
continue;
|
|
1092
|
-
} else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
|
|
1093
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1094
|
-
joinPathRelationUpdates.push({
|
|
1095
|
-
relationKey: key,
|
|
1096
|
-
relation,
|
|
1097
|
-
newTargetId: serializedValue
|
|
1098
|
-
});
|
|
1099
|
-
continue;
|
|
1100
1011
|
}
|
|
1101
1012
|
}
|
|
1102
1013
|
}
|
|
@@ -1182,7 +1093,7 @@ async function parseDataFromServer(data, collection, db, registry) {
|
|
|
1182
1093
|
for (const [propKey, property] of Object.entries(properties)) if (property.type === "relation" && !(propKey in result)) {
|
|
1183
1094
|
const relation = findRelation(resolvedRelations, propKey);
|
|
1184
1095
|
if (relation) {
|
|
1185
|
-
if (relation.
|
|
1096
|
+
if (relation.kind === "belongsTo" && relation.localKey in data) {
|
|
1186
1097
|
const fkValue = data[relation.localKey];
|
|
1187
1098
|
if (fkValue !== null && fkValue !== void 0) try {
|
|
1188
1099
|
const targetCollection = relation.target();
|
|
@@ -1190,7 +1101,7 @@ async function parseDataFromServer(data, collection, db, registry) {
|
|
|
1190
1101
|
} catch (e) {
|
|
1191
1102
|
logger.warn(`Could not resolve target collection for relation property: ${propKey}`, { error: e });
|
|
1192
1103
|
}
|
|
1193
|
-
} else if (relation
|
|
1104
|
+
} else if (hasForeignKeyOnTarget(relation) && db && registry) try {
|
|
1194
1105
|
const targetCollection = relation.target();
|
|
1195
1106
|
const targetTable = registry.getTable(getTableName$1(targetCollection));
|
|
1196
1107
|
const currentId = buildCompositeId(data, getPrimaryKeys(collection, registry));
|
|
@@ -1211,7 +1122,7 @@ async function parseDataFromServer(data, collection, db, registry) {
|
|
|
1211
1122
|
} catch (e) {
|
|
1212
1123
|
logger.warn(`Could not resolve inverse relation property: ${propKey}`, { error: e });
|
|
1213
1124
|
}
|
|
1214
|
-
else if (relation.
|
|
1125
|
+
else if (relation.kind === "via" && db && registry) try {
|
|
1215
1126
|
const targetCollection = relation.target();
|
|
1216
1127
|
const pks = getPrimaryKeys(collection, registry);
|
|
1217
1128
|
const currentId = buildCompositeId(data, pks);
|
|
@@ -1328,11 +1239,10 @@ function parsePropertyFromServer(value, property, collection, propertyKey) {
|
|
|
1328
1239
|
}
|
|
1329
1240
|
case "relation":
|
|
1330
1241
|
if (typeof value === "string" || typeof value === "number") {
|
|
1331
|
-
let relationDef = property.
|
|
1242
|
+
let relationDef = property.resolvedRelation;
|
|
1332
1243
|
if (!relationDef && propertyKey) relationDef = findRelation(resolveCollectionRelations(collection), propertyKey);
|
|
1333
|
-
if (!relationDef) relationDef = collection.relations?.find((rel) => rel.relationName === property.relationName);
|
|
1334
1244
|
if (!relationDef) {
|
|
1335
|
-
logger.warn(`
|
|
1245
|
+
logger.warn(`ResolvedRelation not defined in property for key: ${propertyKey || "unknown"}`);
|
|
1336
1246
|
return value;
|
|
1337
1247
|
}
|
|
1338
1248
|
try {
|
|
@@ -1432,7 +1342,7 @@ function normalizeScalarValues(data, properties, collection, resolvedRelations,
|
|
|
1432
1342
|
const result = {};
|
|
1433
1343
|
const internalFKColumns = /* @__PURE__ */ new Set();
|
|
1434
1344
|
Object.values(resolvedRelations).forEach((relation) => {
|
|
1435
|
-
if (relation.
|
|
1345
|
+
if (relation.kind === "belongsTo" && !properties[relation.localKey]) internalFKColumns.add(relation.localKey);
|
|
1436
1346
|
});
|
|
1437
1347
|
for (const [key, value] of Object.entries(data)) {
|
|
1438
1348
|
if (internalFKColumns.has(key)) {
|
|
@@ -1570,7 +1480,7 @@ var RelationService = class {
|
|
|
1570
1480
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
1571
1481
|
if (!parentTable) throw new Error("Parent table not found");
|
|
1572
1482
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
1573
|
-
if (relation.
|
|
1483
|
+
if (relation.kind === "via") {
|
|
1574
1484
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
1575
1485
|
let currentTable = parentTable;
|
|
1576
1486
|
for (const join of relation.joinPath) {
|
|
@@ -1685,8 +1595,8 @@ var RelationService = class {
|
|
|
1685
1595
|
* `tags` table and delete by primary key.
|
|
1686
1596
|
*/
|
|
1687
1597
|
async unlinkRelatedEntity(tx, hop, targetId) {
|
|
1598
|
+
if (!isManyToMany(hop.relation)) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
|
|
1688
1599
|
const through = hop.relation.through;
|
|
1689
|
-
if (!through) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
|
|
1690
1600
|
const junctionTable = this.registry.getTable(through.table);
|
|
1691
1601
|
if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
|
|
1692
1602
|
const sourceJunctionColumn = junctionTable[through.sourceColumn];
|
|
@@ -1716,7 +1626,7 @@ var RelationService = class {
|
|
|
1716
1626
|
if (!parentTable) throw new Error("Parent table not found");
|
|
1717
1627
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
1718
1628
|
const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
1719
|
-
if (relation.
|
|
1629
|
+
if (relation.kind === "via") {
|
|
1720
1630
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
1721
1631
|
let currentTable = parentTable;
|
|
1722
1632
|
for (const join of relation.joinPath) {
|
|
@@ -1745,7 +1655,7 @@ var RelationService = class {
|
|
|
1745
1655
|
}
|
|
1746
1656
|
return resultMap;
|
|
1747
1657
|
}
|
|
1748
|
-
if (relation.
|
|
1658
|
+
if (relation.kind === "belongsTo") {
|
|
1749
1659
|
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
|
|
1750
1660
|
const localKeyCol = parentTable[relation.localKey];
|
|
1751
1661
|
if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
|
|
@@ -1779,7 +1689,7 @@ var RelationService = class {
|
|
|
1779
1689
|
}
|
|
1780
1690
|
return resultMap;
|
|
1781
1691
|
}
|
|
1782
|
-
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget
|
|
1692
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName);
|
|
1783
1693
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
1784
1694
|
query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
1785
1695
|
const results = await query;
|
|
@@ -1788,8 +1698,7 @@ var RelationService = class {
|
|
|
1788
1698
|
for (const row of results) {
|
|
1789
1699
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
1790
1700
|
let parentId;
|
|
1791
|
-
if (relation
|
|
1792
|
-
else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
|
|
1701
|
+
if (hasForeignKeyOnTarget(relation)) parentId = targetRow[relation.foreignKeyOnTarget];
|
|
1793
1702
|
if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
1794
1703
|
}
|
|
1795
1704
|
return resultMap;
|
|
@@ -1812,7 +1721,7 @@ var RelationService = class {
|
|
|
1812
1721
|
if (!parentTable) throw new Error("Parent table not found");
|
|
1813
1722
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
1814
1723
|
const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
1815
|
-
if (relation.
|
|
1724
|
+
if (relation.kind === "via") {
|
|
1816
1725
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
1817
1726
|
let currentTable = parentTable;
|
|
1818
1727
|
for (const join of relation.joinPath) {
|
|
@@ -1842,7 +1751,7 @@ var RelationService = class {
|
|
|
1842
1751
|
}
|
|
1843
1752
|
return resultMap;
|
|
1844
1753
|
}
|
|
1845
|
-
if (relation.
|
|
1754
|
+
if (relation.kind === "manyToMany") {
|
|
1846
1755
|
this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
|
|
1847
1756
|
const junctionTable = this.registry.getTable(relation.through.table);
|
|
1848
1757
|
if (!junctionTable) {
|
|
@@ -1868,7 +1777,7 @@ var RelationService = class {
|
|
|
1868
1777
|
}
|
|
1869
1778
|
return resultMap;
|
|
1870
1779
|
}
|
|
1871
|
-
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget
|
|
1780
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName);
|
|
1872
1781
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
1873
1782
|
query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
1874
1783
|
const results = await query;
|
|
@@ -1877,9 +1786,7 @@ var RelationService = class {
|
|
|
1877
1786
|
for (const row of results) {
|
|
1878
1787
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
1879
1788
|
let parentId;
|
|
1880
|
-
if (relation
|
|
1881
|
-
else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
|
|
1882
|
-
else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
|
|
1789
|
+
if (hasForeignKeyOnTarget(relation)) parentId = targetRow[relation.foreignKeyOnTarget];
|
|
1883
1790
|
if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
|
|
1884
1791
|
const key = String(parentId);
|
|
1885
1792
|
const arr = resultMap.get(key) || [];
|
|
@@ -1899,7 +1806,7 @@ var RelationService = class {
|
|
|
1899
1806
|
if (!relation || relation.cardinality !== "many") continue;
|
|
1900
1807
|
const targetEntityIds = value && Array.isArray(value) ? value.map((rel) => rel.id) : [];
|
|
1901
1808
|
const targetCollection = relation.target();
|
|
1902
|
-
if (relation.
|
|
1809
|
+
if (relation.kind === "via") {
|
|
1903
1810
|
const parentTableName = getTableName$1(collection);
|
|
1904
1811
|
const targetTableName = getTableName$1(targetCollection);
|
|
1905
1812
|
let junctionTable = void 0;
|
|
@@ -1944,7 +1851,7 @@ var RelationService = class {
|
|
|
1944
1851
|
}));
|
|
1945
1852
|
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
1946
1853
|
}
|
|
1947
|
-
} else if (relation.
|
|
1854
|
+
} else if (relation.kind === "manyToMany") {
|
|
1948
1855
|
const junctionTable = this.registry.getTable(relation.through.table);
|
|
1949
1856
|
if (!junctionTable) {
|
|
1950
1857
|
logger.warn(`Junction table '${relation.through.table}' not found for relation '${key}' in collection '${collection.slug}'`);
|
|
@@ -1969,8 +1876,7 @@ var RelationService = class {
|
|
|
1969
1876
|
}));
|
|
1970
1877
|
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
1971
1878
|
}
|
|
1972
|
-
} else if (relation.
|
|
1973
|
-
else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
1879
|
+
} else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
1974
1880
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
1975
1881
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1976
1882
|
const targetIdInfo = targetPks[0];
|
|
@@ -2004,28 +1910,20 @@ var RelationService = class {
|
|
|
2004
1910
|
const targetIdInfo = targetPks[0];
|
|
2005
1911
|
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
2006
1912
|
const sourceIdInfo = sourcePks[0];
|
|
2007
|
-
if (relation.
|
|
1913
|
+
if (relation.kind === "via") {
|
|
2008
1914
|
await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
|
|
2009
1915
|
continue;
|
|
2010
1916
|
}
|
|
2011
|
-
if (relation
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
targetColumn: targetRelation.through.sourceColumn
|
|
2019
|
-
};
|
|
2020
|
-
break;
|
|
2021
|
-
}
|
|
2022
|
-
if (junctionInfo) {
|
|
2023
|
-
await this.updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, junctionInfo);
|
|
2024
|
-
continue;
|
|
2025
|
-
}
|
|
1917
|
+
if (isManyToMany(relation)) {
|
|
1918
|
+
await this.updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, {
|
|
1919
|
+
table: relation.through.table,
|
|
1920
|
+
sourceColumn: relation.through.sourceColumn,
|
|
1921
|
+
targetColumn: relation.through.targetColumn
|
|
1922
|
+
});
|
|
1923
|
+
continue;
|
|
2026
1924
|
}
|
|
2027
|
-
if (!relation
|
|
2028
|
-
logger.warn(`
|
|
1925
|
+
if (!hasForeignKeyOnTarget(relation)) {
|
|
1926
|
+
logger.warn(`Relation '${relation.relationName}' has no column on the target to write. Skipping.`);
|
|
2029
1927
|
continue;
|
|
2030
1928
|
}
|
|
2031
1929
|
const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget];
|
|
@@ -2051,10 +1949,6 @@ var RelationService = class {
|
|
|
2051
1949
|
*/
|
|
2052
1950
|
async updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue) {
|
|
2053
1951
|
try {
|
|
2054
|
-
if (!relation.joinPath || relation.joinPath.length === 0) {
|
|
2055
|
-
logger.warn(`Inverse relation '${relation.relationName}' missing joinPath`);
|
|
2056
|
-
return;
|
|
2057
|
-
}
|
|
2058
1952
|
const sourceTableName = getTableName$1(sourceCollection);
|
|
2059
1953
|
const targetTableName = getTableName$1(targetCollection);
|
|
2060
1954
|
const intermediateTables = relation.joinPath.map((step) => step.table).filter((table) => table !== sourceTableName && table !== targetTableName);
|
|
@@ -2254,9 +2148,8 @@ var RelationService = class {
|
|
|
2254
2148
|
* to unwrap that same level back out.
|
|
2255
2149
|
*/
|
|
2256
2150
|
function isJunctionRelation(relation) {
|
|
2257
|
-
if (relation
|
|
2258
|
-
|
|
2259
|
-
return false;
|
|
2151
|
+
if (isManyToMany(relation)) return true;
|
|
2152
|
+
return relation.kind === "via" && relation.joinPath.length > 1;
|
|
2260
2153
|
}
|
|
2261
2154
|
/**
|
|
2262
2155
|
* Reach the target row inside a junction row.
|
|
@@ -2446,7 +2339,7 @@ function resolveNestedPath(path, registry) {
|
|
|
2446
2339
|
* such a path addresses the *link*, not the row.
|
|
2447
2340
|
*/
|
|
2448
2341
|
function isJunctionBackedRelation(relation) {
|
|
2449
|
-
return
|
|
2342
|
+
return relation.shared;
|
|
2450
2343
|
}
|
|
2451
2344
|
/**
|
|
2452
2345
|
* Reject a nested write whose final segment is a to-one relation.
|
|
@@ -2459,7 +2352,10 @@ function isJunctionBackedRelation(relation) {
|
|
|
2459
2352
|
* one.
|
|
2460
2353
|
*/
|
|
2461
2354
|
function assertWritableThrough(hop, path) {
|
|
2462
|
-
|
|
2355
|
+
const { relation } = hop;
|
|
2356
|
+
if (relation.writable && relation.cardinality === "many") return;
|
|
2357
|
+
if (!relation.writable) throw ApiError.badRequest(`"${path}" ends in '${hop.relationKey}', a \`via\` relation. Rebase will not infer how to write through an arbitrary join chain — write the row at "${hop.targetCollection.slug}" directly.`, "RELATION_NOT_WRITABLE");
|
|
2358
|
+
throw ApiError.badRequest(`"${path}" ends in the to-one relation '${hop.relationKey}', which cannot be written through: the foreign key for a to-one relation lives on '${hop.parentCollection.slug}', not on '${hop.targetCollection.slug}'. Write the target row at "${hop.targetCollection.slug}" and set '${hop.relationKey}' on the parent instead.`, "RELATION_NOT_WRITABLE");
|
|
2463
2359
|
}
|
|
2464
2360
|
//#endregion
|
|
2465
2361
|
//#region src/services/FetchService.ts
|
|
@@ -2498,7 +2394,7 @@ var FetchService = class {
|
|
|
2498
2394
|
let orderByField = table[orderBy];
|
|
2499
2395
|
if (!orderByField && collection) {
|
|
2500
2396
|
const property = collection.properties[orderBy];
|
|
2501
|
-
if (property && property.type === "relation" && "relation" in property && property.
|
|
2397
|
+
if (property && property.type === "relation" && "relation" in property && property.resolvedRelation?.kind === "belongsTo") orderByField = table[`${orderBy}_id`];
|
|
2502
2398
|
}
|
|
2503
2399
|
return orderByField;
|
|
2504
2400
|
}
|
|
@@ -2519,7 +2415,7 @@ var FetchService = class {
|
|
|
2519
2415
|
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
2520
2416
|
if (!shouldInclude(key)) continue;
|
|
2521
2417
|
const drizzleRelName = relation.relationName || key;
|
|
2522
|
-
if (relation.
|
|
2418
|
+
if (relation.kind === "via") continue;
|
|
2523
2419
|
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
2524
2420
|
const targetFkName = this.getJunctionTargetRelationName(relation, collection);
|
|
2525
2421
|
if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
|
|
@@ -2533,7 +2429,7 @@ var FetchService = class {
|
|
|
2533
2429
|
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
2534
2430
|
*/
|
|
2535
2431
|
getJunctionTargetRelationName(relation, _collection) {
|
|
2536
|
-
if (relation
|
|
2432
|
+
if (isManyToMany(relation)) return relation.through.targetColumn.replace(/_id$/, "_id");
|
|
2537
2433
|
return null;
|
|
2538
2434
|
}
|
|
2539
2435
|
/**
|
|
@@ -2543,7 +2439,7 @@ var FetchService = class {
|
|
|
2543
2439
|
*/
|
|
2544
2440
|
async resolveJoinPathRelations(row, collection, collectionPath, parsedId, _databaseId) {
|
|
2545
2441
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
2546
|
-
const promises = Object.entries(resolvedRelations).filter(([key, relation]) => relation.
|
|
2442
|
+
const promises = Object.entries(resolvedRelations).filter(([key, relation]) => relation.kind === "via").map(async ([key, relation]) => {
|
|
2547
2443
|
try {
|
|
2548
2444
|
const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, { limit: relation.cardinality === "one" ? 1 : void 0 });
|
|
2549
2445
|
if (relation.cardinality === "one" && relatedRows.length > 0) {
|
|
@@ -2565,7 +2461,7 @@ var FetchService = class {
|
|
|
2565
2461
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
2566
2462
|
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
2567
2463
|
const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
2568
|
-
const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.
|
|
2464
|
+
const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.kind === "via" && propertyKeys.has(key) && shouldInclude(key));
|
|
2569
2465
|
if (joinPathRelations.length === 0) return;
|
|
2570
2466
|
const parentIdOf = (row) => {
|
|
2571
2467
|
const address = buildCompositeId(row, idInfoArray);
|
|
@@ -2719,7 +2615,7 @@ var FetchService = class {
|
|
|
2719
2615
|
return flatRow;
|
|
2720
2616
|
} catch (e) {
|
|
2721
2617
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
2722
|
-
logger.error(`[FetchService]
|
|
2618
|
+
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
2723
2619
|
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
2724
2620
|
}
|
|
2725
2621
|
logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
@@ -2770,7 +2666,7 @@ var FetchService = class {
|
|
|
2770
2666
|
return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
|
|
2771
2667
|
} catch (e) {
|
|
2772
2668
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
2773
|
-
logger.error(`[FetchService]
|
|
2669
|
+
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
2774
2670
|
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
2775
2671
|
}
|
|
2776
2672
|
logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
@@ -2985,7 +2881,7 @@ var FetchService = class {
|
|
|
2985
2881
|
return restRows;
|
|
2986
2882
|
} catch (e) {
|
|
2987
2883
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
2988
|
-
logger.error(`[FetchService]
|
|
2884
|
+
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
2989
2885
|
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
2990
2886
|
}
|
|
2991
2887
|
logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
|
|
@@ -3048,7 +2944,7 @@ var FetchService = class {
|
|
|
3048
2944
|
return restRow;
|
|
3049
2945
|
} catch (e) {
|
|
3050
2946
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
3051
|
-
logger.error(`[FetchService]
|
|
2947
|
+
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
3052
2948
|
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
3053
2949
|
}
|
|
3054
2950
|
logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
|
|
@@ -3196,7 +3092,7 @@ var FetchService = class {
|
|
|
3196
3092
|
if (parentIds.length === 0) return /* @__PURE__ */ new Map();
|
|
3197
3093
|
const relation = resolveCollectionRelations(getCollectionByPath(parentCollectionPath, this.registry))[relationKey];
|
|
3198
3094
|
if (!relation) {
|
|
3199
|
-
logger.warn(`[batchFetchManyRelatedRows]
|
|
3095
|
+
logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
|
|
3200
3096
|
return /* @__PURE__ */ new Map();
|
|
3201
3097
|
}
|
|
3202
3098
|
const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, relationKey, relation);
|
|
@@ -3421,7 +3317,7 @@ var PersistService = class {
|
|
|
3421
3317
|
assertWritableThrough(hop, collectionPath);
|
|
3422
3318
|
if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
|
|
3423
3319
|
if (isJunctionBackedRelation(hop.relation)) {
|
|
3424
|
-
if (!hop.relation
|
|
3320
|
+
if (!isManyToMany(hop.relation)) throw ApiError.badRequest(`"${collectionPath}" reaches '${hop.targetCollection.slug}' through a multi-hop joinPath, so there is no single link to remove. Delete the row at "${hop.targetCollection.slug}" directly if that is what you meant.`, "RELATION_NOT_UNLINKABLE");
|
|
3425
3321
|
await this.relationService.unlinkRelatedEntity(this.db, hop, id);
|
|
3426
3322
|
return;
|
|
3427
3323
|
}
|
|
@@ -3457,16 +3353,13 @@ var PersistService = class {
|
|
|
3457
3353
|
* do, and first — stamped the parent's own foreign key onto the child row.
|
|
3458
3354
|
*/
|
|
3459
3355
|
resolveParentForeignKeyColumn(hop) {
|
|
3460
|
-
const { relation
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
return DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to)[0];
|
|
3356
|
+
const { relation } = hop;
|
|
3357
|
+
switch (relation.kind) {
|
|
3358
|
+
case "hasOne":
|
|
3359
|
+
case "hasMany": return relation.foreignKeyOnTarget;
|
|
3360
|
+
case "via": return relation.joinPath.length === 1 ? DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0] : void 0;
|
|
3361
|
+
default: return;
|
|
3467
3362
|
}
|
|
3468
|
-
if (relation.joinPath && relation.joinPath.length > 1) return;
|
|
3469
|
-
throw ApiError.badRequest(`Relation '${relationKey}' on '${hop.parentCollection.slug}' cannot be written through: it declares no \`foreignKeyOnTarget\` (the column on '${targetCollection.slug}' that records the parent) and no \`joinPath\`.`, "RELATION_NOT_WRITABLE");
|
|
3470
3363
|
}
|
|
3471
3364
|
/**
|
|
3472
3365
|
* Save an row (create or update)
|
|
@@ -3489,7 +3382,7 @@ var PersistService = class {
|
|
|
3489
3382
|
const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
|
|
3490
3383
|
return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
3491
3384
|
};
|
|
3492
|
-
if (hop.relation
|
|
3385
|
+
if (isManyToMany(hop.relation)) junctionTableInfo = {
|
|
3493
3386
|
parentCollection: hop.parentCollection,
|
|
3494
3387
|
parentId: parentIdForWrite(),
|
|
3495
3388
|
relation: hop.relation,
|
|
@@ -5542,12 +5435,8 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
5542
5435
|
break;
|
|
5543
5436
|
case "relation": {
|
|
5544
5437
|
const refProp = prop;
|
|
5545
|
-
const relation = findRelation(resolveCollectionRelations(collection), refProp.relationName ?? propName);
|
|
5546
|
-
if (!relation || relation.
|
|
5547
|
-
if (!relation.localKey) {
|
|
5548
|
-
logger.warn(`Could not generate column for owning relation '${relation.relationName}' on '${collection.name}': 'localKey' is not defined.`);
|
|
5549
|
-
return null;
|
|
5550
|
-
}
|
|
5438
|
+
const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
|
|
5439
|
+
if (!relation || relation.kind !== "belongsTo") return null;
|
|
5551
5440
|
if (collection.properties[relation.localKey] && propName !== relation.localKey) return null;
|
|
5552
5441
|
let targetCollection;
|
|
5553
5442
|
try {
|
|
@@ -5641,33 +5530,17 @@ var generateSinglePolicyCode = (collection, rule, operation, policyName, resolve
|
|
|
5641
5530
|
*/
|
|
5642
5531
|
var computeSharedRelationName = (rel, sourceCollection, _collections) => {
|
|
5643
5532
|
const fallback = rel.relationName ?? toSnakeCase(rel.target().slug);
|
|
5644
|
-
if (rel.
|
|
5533
|
+
if (rel.kind === "belongsTo") {
|
|
5645
5534
|
const normalisedKey = resolvePropertyKeyForColumn(sourceCollection, rel.localKey);
|
|
5646
5535
|
return `${getTableName$1(sourceCollection)}_${normalisedKey}`;
|
|
5647
5536
|
}
|
|
5648
|
-
if (rel.
|
|
5537
|
+
if (rel.kind === "hasMany" || rel.kind === "hasOne") try {
|
|
5649
5538
|
const targetCollection = rel.target();
|
|
5650
5539
|
const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
|
|
5651
5540
|
return `${getTableName$1(targetCollection)}_${normalisedFK}`;
|
|
5652
5541
|
} catch {
|
|
5653
5542
|
return fallback;
|
|
5654
5543
|
}
|
|
5655
|
-
if (rel.direction === "inverse" && rel.cardinality === "one") {
|
|
5656
|
-
if (rel.foreignKeyOnTarget) try {
|
|
5657
|
-
const targetCollection = rel.target();
|
|
5658
|
-
const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
|
|
5659
|
-
return `${getTableName$1(targetCollection)}_${normalisedFK}`;
|
|
5660
|
-
} catch {
|
|
5661
|
-
return fallback;
|
|
5662
|
-
}
|
|
5663
|
-
try {
|
|
5664
|
-
const targetCollection = rel.target();
|
|
5665
|
-
const targetResolvedRelations = resolveCollectionRelations(targetCollection);
|
|
5666
|
-
const correspondingRelation = Object.values(targetResolvedRelations).find((targetRel) => targetRel.direction === "owning" && targetRel.cardinality === "one" && targetRel.localKey && targetRel.target().slug === sourceCollection.slug);
|
|
5667
|
-
if (correspondingRelation && correspondingRelation.localKey) return `${getTableName$1(targetCollection)}_${correspondingRelation.localKey}`;
|
|
5668
|
-
} catch {}
|
|
5669
|
-
return fallback;
|
|
5670
|
-
}
|
|
5671
5544
|
return fallback;
|
|
5672
5545
|
};
|
|
5673
5546
|
var generateSchema = async (collections, stripPolicies = false) => {
|
|
@@ -5732,7 +5605,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
5732
5605
|
const tableName = getTableName$1(collection);
|
|
5733
5606
|
if (tableName) allTablesToGenerate.set(tableName, { collection });
|
|
5734
5607
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
5735
|
-
for (const relation of Object.values(resolvedRelations)) if (relation
|
|
5608
|
+
for (const relation of Object.values(resolvedRelations)) if (isManyToMany(relation)) {
|
|
5736
5609
|
const junctionTableName = relation.through.table;
|
|
5737
5610
|
if (!allTablesToGenerate.has(junctionTableName)) allTablesToGenerate.set(junctionTableName, {
|
|
5738
5611
|
collection: {
|
|
@@ -5747,7 +5620,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
5747
5620
|
}
|
|
5748
5621
|
for (const [tableName, { collection, isJunction, relation, sourceCollection }] of allTablesToGenerate.entries()) {
|
|
5749
5622
|
const tableVarName = getTableVarName(tableName);
|
|
5750
|
-
if (isJunction && relation && sourceCollection && relation
|
|
5623
|
+
if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
|
|
5751
5624
|
const targetCollection = relation.target();
|
|
5752
5625
|
const schema = (isPostgresCollectionConfig(targetCollection) ? targetCollection.schema : void 0) || (isPostgresCollectionConfig(sourceCollection) ? sourceCollection.schema : void 0);
|
|
5753
5626
|
const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
|
|
@@ -5801,7 +5674,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
5801
5674
|
const tableRelations = [];
|
|
5802
5675
|
if (isJunction) {
|
|
5803
5676
|
const relationInfo = Array.from(allTablesToGenerate.values()).find((v) => v.isJunction && getTableName$1(v.collection) === tableName);
|
|
5804
|
-
if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && relationInfo.relation
|
|
5677
|
+
if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && isManyToMany(relationInfo.relation)) {
|
|
5805
5678
|
const { relation, sourceCollection } = relationInfo;
|
|
5806
5679
|
const targetCollection = relation.target();
|
|
5807
5680
|
const sourceTableVar = getTableVarName(getTableName$1(sourceCollection));
|
|
@@ -5813,7 +5686,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
5813
5686
|
let inverseRelationName = null;
|
|
5814
5687
|
try {
|
|
5815
5688
|
const targetRelations = resolveCollectionRelations(targetCollection);
|
|
5816
|
-
for (const [, targetRel] of Object.entries(targetRelations)) if (targetRel.
|
|
5689
|
+
for (const [, targetRel] of Object.entries(targetRelations)) if (targetRel.kind !== "belongsTo" && targetRel.cardinality === "many" && targetRel.relationName === owningRelationName) {
|
|
5817
5690
|
inverseRelationName = targetRel.relationName ?? null;
|
|
5818
5691
|
break;
|
|
5819
5692
|
}
|
|
@@ -5829,27 +5702,25 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
5829
5702
|
const target = rel.target();
|
|
5830
5703
|
const targetTableVar = getTableVarName(getTableName$1(target));
|
|
5831
5704
|
const drizzleRelationName = computeSharedRelationName(rel, collection, collections);
|
|
5832
|
-
const deduplicationKey = `${drizzleRelationName}::${rel.
|
|
5705
|
+
const deduplicationKey = `${drizzleRelationName}::${rel.kind}`;
|
|
5833
5706
|
if (emittedRelationNames.has(deduplicationKey)) continue;
|
|
5834
5707
|
emittedRelationNames.add(deduplicationKey);
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5708
|
+
switch (rel.kind) {
|
|
5709
|
+
case "belongsTo":
|
|
5710
|
+
tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
|
|
5711
|
+
break;
|
|
5712
|
+
case "hasOne":
|
|
5713
|
+
tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
|
|
5714
|
+
break;
|
|
5715
|
+
case "hasMany":
|
|
5716
|
+
tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
|
|
5717
|
+
break;
|
|
5718
|
+
case "manyToMany": {
|
|
5841
5719
|
const junctionTableVar = getTableVarName(rel.through.table);
|
|
5842
5720
|
tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
|
|
5843
|
-
|
|
5844
|
-
const targetResolvedRelations = resolveCollectionRelations(rel.target());
|
|
5845
|
-
const correspondingRelation = Object.values(targetResolvedRelations).find((targetRel) => targetRel.direction === "owning" && targetRel.cardinality === "many" && targetRel.through && targetRel.relationName === rel.inverseRelationName);
|
|
5846
|
-
if (correspondingRelation && correspondingRelation.through) {
|
|
5847
|
-
const junctionTableVar = getTableVarName(correspondingRelation.through.table);
|
|
5848
|
-
tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
|
|
5849
|
-
} else logger.warn(`Could not find corresponding owning many-to-many relation for inverse relation '${relationKey}' on '${collection.name}'`);
|
|
5850
|
-
} catch (e) {
|
|
5851
|
-
logger.warn(`Could not resolve inverse many-to-many relation '${relationKey}'`, { error: e });
|
|
5721
|
+
break;
|
|
5852
5722
|
}
|
|
5723
|
+
case "via": break;
|
|
5853
5724
|
}
|
|
5854
5725
|
} catch (e) {
|
|
5855
5726
|
logger.warn(`Could not generate relation ${relationKey} for ${collection.name}`, { error: e });
|
|
@@ -5857,10 +5728,10 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
5857
5728
|
for (const otherCollection of collections) {
|
|
5858
5729
|
if (otherCollection.slug === collection.slug) continue;
|
|
5859
5730
|
const otherRelations = resolveCollectionRelations(otherCollection);
|
|
5860
|
-
for (const [otherKey, otherRel] of Object.entries(otherRelations)) if (otherRel
|
|
5731
|
+
for (const [otherKey, otherRel] of Object.entries(otherRelations)) if (hasForeignKeyOnTarget(otherRel)) try {
|
|
5861
5732
|
if (otherRel.target().slug === collection.slug) {
|
|
5862
5733
|
const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
|
|
5863
|
-
const deduplicationKey = `${drizzleRelationName}::
|
|
5734
|
+
const deduplicationKey = `${drizzleRelationName}::belongsTo`;
|
|
5864
5735
|
if (!emittedRelationNames.has(deduplicationKey)) {
|
|
5865
5736
|
const otherTableVar = getTableVarName(getTableName$1(otherCollection));
|
|
5866
5737
|
const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
|
|
@@ -5962,6 +5833,58 @@ var main = () => {
|
|
|
5962
5833
|
};
|
|
5963
5834
|
if (import.meta.url.endsWith(process.argv[1])) main();
|
|
5964
5835
|
//#endregion
|
|
5836
|
+
//#region src/services/cdc/junction-tables.ts
|
|
5837
|
+
/**
|
|
5838
|
+
* Every junction table reachable from a registered collection, once per
|
|
5839
|
+
* relation that uses it.
|
|
5840
|
+
*
|
|
5841
|
+
* A junction is listed once per *direction* when both sides declare it, because
|
|
5842
|
+
* each direction addresses a different child list: `posts/1/tags` and
|
|
5843
|
+
* `tags/t/posts` both change when one link is written.
|
|
5844
|
+
*/
|
|
5845
|
+
function collectJunctionLinks(registry) {
|
|
5846
|
+
const links = [];
|
|
5847
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5848
|
+
for (const collection of registry.getCollections()) {
|
|
5849
|
+
let relations;
|
|
5850
|
+
try {
|
|
5851
|
+
relations = resolveCollectionRelations(collection);
|
|
5852
|
+
} catch {
|
|
5853
|
+
continue;
|
|
5854
|
+
}
|
|
5855
|
+
for (const [relationKey, relation] of Object.entries(relations)) {
|
|
5856
|
+
if (!isManyToMany(relation)) continue;
|
|
5857
|
+
const through = relation.through;
|
|
5858
|
+
const key = `${collection.slug}::${relationKey}::${through.table}`;
|
|
5859
|
+
if (seen.has(key)) continue;
|
|
5860
|
+
seen.add(key);
|
|
5861
|
+
links.push({
|
|
5862
|
+
schema: collection.schema ?? "public",
|
|
5863
|
+
table: through.table,
|
|
5864
|
+
parentCollection: collection,
|
|
5865
|
+
relationKey,
|
|
5866
|
+
sourceColumn: through.sourceColumn,
|
|
5867
|
+
targetColumn: through.targetColumn
|
|
5868
|
+
});
|
|
5869
|
+
}
|
|
5870
|
+
}
|
|
5871
|
+
return links;
|
|
5872
|
+
}
|
|
5873
|
+
/**
|
|
5874
|
+
* Index {@link collectJunctionLinks} by table, under both the qualified and the
|
|
5875
|
+
* bare name — a change event carries whatever the trigger reports, and a
|
|
5876
|
+
* collection need not declare a schema.
|
|
5877
|
+
*/
|
|
5878
|
+
function buildJunctionLinkMap(registry) {
|
|
5879
|
+
const map = /* @__PURE__ */ new Map();
|
|
5880
|
+
for (const link of collectJunctionLinks(registry)) for (const key of [`${link.schema}.${link.table}`, link.table]) {
|
|
5881
|
+
const existing = map.get(key);
|
|
5882
|
+
if (existing) existing.push(link);
|
|
5883
|
+
else map.set(key, [link]);
|
|
5884
|
+
}
|
|
5885
|
+
return map;
|
|
5886
|
+
}
|
|
5887
|
+
//#endregion
|
|
5965
5888
|
//#region src/services/cdc/trigger-cdc.ts
|
|
5966
5889
|
/**
|
|
5967
5890
|
* Trigger-based Change Data Capture (CDC).
|
|
@@ -7086,6 +7009,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7086
7009
|
cdcListener;
|
|
7087
7010
|
/** Whether database-level CDC is the active cross-instance change source. */
|
|
7088
7011
|
cdcActive = false;
|
|
7012
|
+
/** Junction table → the child lists its rows belong to, built when CDC starts. */
|
|
7013
|
+
junctionLinkMap;
|
|
7089
7014
|
/** Reverse lookup: `schema.table` (and bare `table`) → collection, built when CDC starts. */
|
|
7090
7015
|
cdcTableMap;
|
|
7091
7016
|
/**
|
|
@@ -8202,6 +8127,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8202
8127
|
return;
|
|
8203
8128
|
}
|
|
8204
8129
|
this.cdcTableMap = this.buildCdcTableMap();
|
|
8130
|
+
this.junctionLinkMap = buildJunctionLinkMap(this.registry);
|
|
8205
8131
|
this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event));
|
|
8206
8132
|
try {
|
|
8207
8133
|
await this.cdcListener.start();
|
|
@@ -8209,6 +8135,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8209
8135
|
await this.cdcListener.stop().catch(() => {});
|
|
8210
8136
|
this.cdcListener = void 0;
|
|
8211
8137
|
this.cdcTableMap = void 0;
|
|
8138
|
+
this.junctionLinkMap = void 0;
|
|
8212
8139
|
throw err;
|
|
8213
8140
|
}
|
|
8214
8141
|
this.cdcActive = true;
|
|
@@ -8222,6 +8149,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8222
8149
|
this.cdcListener = void 0;
|
|
8223
8150
|
}
|
|
8224
8151
|
this.cdcTableMap = void 0;
|
|
8152
|
+
this.junctionLinkMap = void 0;
|
|
8225
8153
|
this.recentAppEmits.clear();
|
|
8226
8154
|
}
|
|
8227
8155
|
/**
|
|
@@ -8258,6 +8186,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8258
8186
|
async handleCdcEvent(event) {
|
|
8259
8187
|
const collection = this.resolveCollectionForTable(event.schema, event.table);
|
|
8260
8188
|
if (!collection) {
|
|
8189
|
+
if (await this.handleJunctionCdcEvent(event)) return;
|
|
8261
8190
|
this.debugLog(`📡 [CDC] Ignoring change on unmapped table ${event.schema}.${event.table}`);
|
|
8262
8191
|
return;
|
|
8263
8192
|
}
|
|
@@ -8267,6 +8196,38 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8267
8196
|
const row = event.op === "DELETE" ? null : { _rebase_invalidated: true };
|
|
8268
8197
|
await this.notifyUpdate(path, id, row, databaseId, false, "cdc");
|
|
8269
8198
|
}
|
|
8199
|
+
/**
|
|
8200
|
+
* Deliver a change on a many-to-many junction table as a change to the child
|
|
8201
|
+
* lists it belongs to.
|
|
8202
|
+
*
|
|
8203
|
+
* Linking a tag to a post writes only `posts_tags`. That table backs no
|
|
8204
|
+
* collection, so change capture dropped the event as unmapped and the
|
|
8205
|
+
* subscribers of `posts/1/tags` never heard about it — every other write in
|
|
8206
|
+
* the system was realtime, and this one silently was not. The junction row
|
|
8207
|
+
* carries both ids, so it names its own paths exactly.
|
|
8208
|
+
*
|
|
8209
|
+
* Notifies the nested path rather than either endpoint collection, because
|
|
8210
|
+
* invalidation walks *parent* paths and never child ones: telling `tags` it
|
|
8211
|
+
* changed would not reach a subscription on `posts/1/tags`.
|
|
8212
|
+
*
|
|
8213
|
+
* Returns whether the table was recognised as a junction.
|
|
8214
|
+
*/
|
|
8215
|
+
async handleJunctionCdcEvent(event) {
|
|
8216
|
+
const links = this.junctionLinkMap?.get(`${event.schema}.${event.table}`) ?? this.junctionLinkMap?.get(event.table);
|
|
8217
|
+
if (!links?.length) return false;
|
|
8218
|
+
for (const link of links) {
|
|
8219
|
+
const sourceId = event.row?.[link.sourceColumn];
|
|
8220
|
+
const targetId = event.row?.[link.targetColumn];
|
|
8221
|
+
if (sourceId === void 0 || sourceId === null || targetId === void 0 || targetId === null) {
|
|
8222
|
+
this.debugLog(`📡 [CDC] Junction row on ${event.table} is missing '${link.sourceColumn}'/'${link.targetColumn}' — skipping.`);
|
|
8223
|
+
continue;
|
|
8224
|
+
}
|
|
8225
|
+
const path = `${link.parentCollection.slug}/${String(sourceId)}/${link.relationKey}`;
|
|
8226
|
+
const row = event.op === "DELETE" ? null : { _rebase_invalidated: true };
|
|
8227
|
+
await this.notifyUpdate(path, String(targetId), row, link.parentCollection.databaseId, false, "cdc");
|
|
8228
|
+
}
|
|
8229
|
+
return true;
|
|
8230
|
+
}
|
|
8270
8231
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
8271
8232
|
extractIdFromCdcRow(collection, row) {
|
|
8272
8233
|
return deriveRowAddress(row, collection, this.registry) || "*";
|
|
@@ -8986,8 +8947,8 @@ var PostgresCollectionRegistry = class extends CollectionRegistry {
|
|
|
8986
8947
|
*/
|
|
8987
8948
|
getRelationKeysForCollection(collectionPath) {
|
|
8988
8949
|
const collection = this.getCollectionByPath(collectionPath);
|
|
8989
|
-
if (!collection
|
|
8990
|
-
return
|
|
8950
|
+
if (!collection) return [];
|
|
8951
|
+
return Object.keys(resolveCollectionRelations(collection));
|
|
8991
8952
|
}
|
|
8992
8953
|
};
|
|
8993
8954
|
//#endregion
|
|
@@ -17938,6 +17899,178 @@ function formatBytes(bytes) {
|
|
|
17938
17899
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
17939
17900
|
}
|
|
17940
17901
|
//#endregion
|
|
17902
|
+
//#region src/collections/validate-relations.ts
|
|
17903
|
+
/**
|
|
17904
|
+
* Every name a column answers to: the key in the drizzle schema and the real
|
|
17905
|
+
* column name in Postgres. A relation may legitimately be written with either,
|
|
17906
|
+
* and reporting a working relation as broken is worse than not checking.
|
|
17907
|
+
*/
|
|
17908
|
+
function columnNames(table) {
|
|
17909
|
+
const names = /* @__PURE__ */ new Set();
|
|
17910
|
+
for (const [key, col] of Object.entries(getTableColumns(table))) {
|
|
17911
|
+
names.add(key);
|
|
17912
|
+
const dbName = col?.name;
|
|
17913
|
+
if (dbName) names.add(dbName);
|
|
17914
|
+
}
|
|
17915
|
+
return names;
|
|
17916
|
+
}
|
|
17917
|
+
var quote = (xs) => Array.from(xs).map((s) => `\`${s}\``).join(", ");
|
|
17918
|
+
/** `on.from` / `on.to` accept a single column or a composite tuple. */
|
|
17919
|
+
var asColumns = (value) => Array.isArray(value) ? value : [value];
|
|
17920
|
+
/**
|
|
17921
|
+
* Relations whose names do not resolve against the registered schema.
|
|
17922
|
+
*
|
|
17923
|
+
* Fails open wherever it cannot see enough to be sure — an unregistered source
|
|
17924
|
+
* table, a target belonging to another backend — because a false alarm here
|
|
17925
|
+
* costs more than a missed one: it would block boot on a working app.
|
|
17926
|
+
*/
|
|
17927
|
+
function findRelationDefects(collections, registry) {
|
|
17928
|
+
const defects = [];
|
|
17929
|
+
const registeredSlugs = new Set(registry.getCollections().map((c) => c.slug));
|
|
17930
|
+
for (const collection of collections) {
|
|
17931
|
+
const sourceTableName = getTableName$1(collection);
|
|
17932
|
+
const sourceTable = registry.getTable(sourceTableName);
|
|
17933
|
+
if (!sourceTable) continue;
|
|
17934
|
+
const sourceColumns = columnNames(sourceTable);
|
|
17935
|
+
const relations = resolveCollectionRelations(collection);
|
|
17936
|
+
for (const relation of Object.values(relations)) {
|
|
17937
|
+
const at = {
|
|
17938
|
+
collection: collection.slug,
|
|
17939
|
+
relationName: relation.relationName,
|
|
17940
|
+
kind: relation.kind
|
|
17941
|
+
};
|
|
17942
|
+
let targetCollection;
|
|
17943
|
+
try {
|
|
17944
|
+
targetCollection = relation.target();
|
|
17945
|
+
} catch (e) {
|
|
17946
|
+
defects.push({
|
|
17947
|
+
...at,
|
|
17948
|
+
problem: `its \`target()\` threw: ${e instanceof Error ? e.message : String(e)}`,
|
|
17949
|
+
fix: "a target thunk usually throws because of a circular import — make sure it is `() => otherCollection` and not evaluated at module load"
|
|
17950
|
+
});
|
|
17951
|
+
continue;
|
|
17952
|
+
}
|
|
17953
|
+
if (!registeredSlugs.has(targetCollection.slug)) continue;
|
|
17954
|
+
const targetTableName = getTableName$1(targetCollection);
|
|
17955
|
+
const targetTable = registry.getTable(targetTableName);
|
|
17956
|
+
if (!targetTable) {
|
|
17957
|
+
defects.push({
|
|
17958
|
+
...at,
|
|
17959
|
+
problem: `it points at collection \`${targetCollection.slug}\`, which has no table \`${targetTableName}\` in the schema`,
|
|
17960
|
+
fix: `create the \`${targetTableName}\` table, or correct \`table\` on the \`${targetCollection.slug}\` collection`
|
|
17961
|
+
});
|
|
17962
|
+
continue;
|
|
17963
|
+
}
|
|
17964
|
+
const targetColumns = columnNames(targetTable);
|
|
17965
|
+
switch (relation.kind) {
|
|
17966
|
+
case "belongsTo":
|
|
17967
|
+
if (!sourceColumns.has(relation.localKey)) defects.push({
|
|
17968
|
+
...at,
|
|
17969
|
+
problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
|
|
17970
|
+
fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
|
|
17971
|
+
});
|
|
17972
|
+
break;
|
|
17973
|
+
case "hasOne":
|
|
17974
|
+
case "hasMany":
|
|
17975
|
+
if (!targetColumns.has(relation.foreignKeyOnTarget)) defects.push({
|
|
17976
|
+
...at,
|
|
17977
|
+
problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
|
|
17978
|
+
fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
|
|
17979
|
+
});
|
|
17980
|
+
break;
|
|
17981
|
+
case "manyToMany": {
|
|
17982
|
+
const { table, sourceColumn, targetColumn } = relation.through;
|
|
17983
|
+
const junction = registry.getTable(table);
|
|
17984
|
+
if (!junction) {
|
|
17985
|
+
defects.push({
|
|
17986
|
+
...at,
|
|
17987
|
+
problem: `its junction table \`${table}\` does not exist`,
|
|
17988
|
+
fix: `create \`${table}\`, or name the real one with \`through: { table: "..." }\`. Note that an omitted \`through.table\` is derived from the two table names sorted and joined, so renaming a table changes it`
|
|
17989
|
+
});
|
|
17990
|
+
break;
|
|
17991
|
+
}
|
|
17992
|
+
const junctionColumns = columnNames(junction);
|
|
17993
|
+
for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]]) if (!junctionColumns.has(column)) defects.push({
|
|
17994
|
+
...at,
|
|
17995
|
+
problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
|
|
17996
|
+
fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` + (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
|
|
17997
|
+
});
|
|
17998
|
+
break;
|
|
17999
|
+
}
|
|
18000
|
+
case "via": {
|
|
18001
|
+
if (relation.joinPath.length === 0) {
|
|
18002
|
+
defects.push({
|
|
18003
|
+
...at,
|
|
18004
|
+
problem: "its `joinPath` is empty, so it joins nothing",
|
|
18005
|
+
fix: "add at least one step, ending at the target's table"
|
|
18006
|
+
});
|
|
18007
|
+
break;
|
|
18008
|
+
}
|
|
18009
|
+
let prevName = sourceTableName;
|
|
18010
|
+
let prevColumns = sourceColumns;
|
|
18011
|
+
let broken = false;
|
|
18012
|
+
for (const [i, step] of relation.joinPath.entries()) {
|
|
18013
|
+
const stepTable = registry.getTable(step.table);
|
|
18014
|
+
if (!stepTable) {
|
|
18015
|
+
defects.push({
|
|
18016
|
+
...at,
|
|
18017
|
+
problem: `step ${i + 1} of its \`joinPath\` joins \`${step.table}\`, which is not a table in the schema`,
|
|
18018
|
+
fix: `correct \`joinPath[${i}].table\``
|
|
18019
|
+
});
|
|
18020
|
+
broken = true;
|
|
18021
|
+
break;
|
|
18022
|
+
}
|
|
18023
|
+
const stepColumns = columnNames(stepTable);
|
|
18024
|
+
for (const column of asColumns(step.on.from)) if (!prevColumns.has(column)) defects.push({
|
|
18025
|
+
...at,
|
|
18026
|
+
problem: `step ${i + 1} joins \`${prevName}.${column}\` → \`${step.table}\`, but \`${column}\` is not a column on \`${prevName}\``,
|
|
18027
|
+
fix: `\`joinPath[${i}].on.from\` names columns on ${i === 0 ? "this collection's table" : `the previous step's table (\`${prevName}\`)`}: ${quote(prevColumns)}`
|
|
18028
|
+
});
|
|
18029
|
+
for (const column of asColumns(step.on.to)) if (!stepColumns.has(column)) defects.push({
|
|
18030
|
+
...at,
|
|
18031
|
+
problem: `step ${i + 1} joins into \`${step.table}.${column}\`, but \`${column}\` is not a column on \`${step.table}\``,
|
|
18032
|
+
fix: `\`joinPath[${i}].on.to\` names columns on \`${step.table}\`: ${quote(stepColumns)}`
|
|
18033
|
+
});
|
|
18034
|
+
if (asColumns(step.on.from).length !== asColumns(step.on.to).length) defects.push({
|
|
18035
|
+
...at,
|
|
18036
|
+
problem: `step ${i + 1} compares ${asColumns(step.on.from).length} column(s) against ${asColumns(step.on.to).length}`,
|
|
18037
|
+
fix: `\`from\` and \`to\` must name the same number of columns in \`joinPath[${i}]\``
|
|
18038
|
+
});
|
|
18039
|
+
prevName = step.table;
|
|
18040
|
+
prevColumns = stepColumns;
|
|
18041
|
+
}
|
|
18042
|
+
if (!broken && prevName !== targetTableName) defects.push({
|
|
18043
|
+
...at,
|
|
18044
|
+
problem: `its \`joinPath\` ends at \`${prevName}\`, but it targets \`${targetCollection.slug}\` (table \`${targetTableName}\`)`,
|
|
18045
|
+
fix: `make the last step join \`${targetTableName}\`, or point \`target\` at the collection backed by \`${prevName}\``
|
|
18046
|
+
});
|
|
18047
|
+
break;
|
|
18048
|
+
}
|
|
18049
|
+
default: throw new Error(`Unhandled relation kind: ${JSON.stringify(relation)}`);
|
|
18050
|
+
}
|
|
18051
|
+
}
|
|
18052
|
+
}
|
|
18053
|
+
return defects;
|
|
18054
|
+
}
|
|
18055
|
+
/**
|
|
18056
|
+
* Fail boot on any relation that cannot resolve, listing all of them at once.
|
|
18057
|
+
*
|
|
18058
|
+
* Deliberately fatal rather than a warning. Every one of these produces an
|
|
18059
|
+
* empty result at query time and nothing else — an empty tab, an empty
|
|
18060
|
+
* `include`, a subcollection that looks like it has no rows. A server that
|
|
18061
|
+
* refuses to start is recoverable in a minute; a relation that quietly answers
|
|
18062
|
+
* "nothing" is the kind of bug found in production, weeks later, by a user
|
|
18063
|
+
* asking where their data went.
|
|
18064
|
+
*/
|
|
18065
|
+
function assertRelationsResolve(collections, registry) {
|
|
18066
|
+
const defects = findRelationDefects(collections, registry);
|
|
18067
|
+
if (defects.length === 0) return;
|
|
18068
|
+
const lines = defects.map((d) => ` • ${d.collection}.${d.relationName} (${d.kind})\n ${d.problem}\n fix: ${d.fix}`);
|
|
18069
|
+
throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against the database schema.\n\nEach of these would return no rows at query time rather than reporting an error, so they are fatal at boot instead.
|
|
18070
|
+
|
|
18071
|
+
` + lines.join("\n\n") + "\n");
|
|
18072
|
+
}
|
|
18073
|
+
//#endregion
|
|
17941
18074
|
//#region src/collections/buildRegistry.ts
|
|
17942
18075
|
/**
|
|
17943
18076
|
* Build the collection registry for a driver.
|
|
@@ -17962,8 +18095,163 @@ function buildCollectionRegistry(schema) {
|
|
|
17962
18095
|
if (schema.enums) registry.registerEnums(schema.enums);
|
|
17963
18096
|
if (schema.relations) registry.registerRelations(schema.relations);
|
|
17964
18097
|
warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
|
|
18098
|
+
assertRelationsResolve(registry.getCollections(), registry);
|
|
17965
18099
|
return registry;
|
|
17966
18100
|
}
|
|
18101
|
+
/** Key under which the version is stored in the auth schema's meta table. */
|
|
18102
|
+
var VERSION_KEY = "auth_schema_version";
|
|
18103
|
+
/**
|
|
18104
|
+
* Columns `refresh_tokens` must have for the current runtime's auth write path
|
|
18105
|
+
* to work. Checked by the health probe so a database that drifted *below* this
|
|
18106
|
+
* runtime is reported as unhealthy rather than discovered one failed login at a
|
|
18107
|
+
* time. Kept in step with the migration in `ensureAuthTablesExist`.
|
|
18108
|
+
*/
|
|
18109
|
+
var REQUIRED_REFRESH_TOKEN_COLUMNS = [
|
|
18110
|
+
"session_id",
|
|
18111
|
+
"revoked",
|
|
18112
|
+
"rotated_at",
|
|
18113
|
+
"session_started_at"
|
|
18114
|
+
];
|
|
18115
|
+
/**
|
|
18116
|
+
* A constraint whose *presence* means the database is still at version 1, in a
|
|
18117
|
+
* shape this runtime's rotation logic cannot write to: it makes two live tokens
|
|
18118
|
+
* of one rotating session collide.
|
|
18119
|
+
*/
|
|
18120
|
+
var RETIRED_REFRESH_TOKEN_CONSTRAINT = "unique_device_session";
|
|
18121
|
+
/**
|
|
18122
|
+
* Thrown when the database was migrated by a runtime newer than this one.
|
|
18123
|
+
*
|
|
18124
|
+
* Distinct class rather than a bare `Error` because `ensureAuthTablesExist`
|
|
18125
|
+
* wraps its migrations in a catch that deliberately swallows failures and
|
|
18126
|
+
* continues — every other problem there is better survived than crashed on.
|
|
18127
|
+
* This one is not, so the catch rethrows on this type specifically.
|
|
18128
|
+
*/
|
|
18129
|
+
var AuthSchemaVersionError = class extends Error {
|
|
18130
|
+
databaseVersion;
|
|
18131
|
+
runtimeVersion;
|
|
18132
|
+
constructor(databaseVersion, runtimeVersion) {
|
|
18133
|
+
super(`Auth schema version mismatch: the database is at version ${databaseVersion}, but this runtime understands version ${runtimeVersion}.\n\nA newer version of the framework has already migrated this database. Running this older runtime against it would boot cleanly and then fail every login and token refresh, because the auth schema it expects no longer exists.
|
|
18134
|
+
|
|
18135
|
+
Refusing to start. Deploy a framework version at or above the one that migrated this database, or restore the database from a backup taken before the upgrade.`);
|
|
18136
|
+
this.name = "AuthSchemaVersionError";
|
|
18137
|
+
this.databaseVersion = databaseVersion;
|
|
18138
|
+
this.runtimeVersion = runtimeVersion;
|
|
18139
|
+
}
|
|
18140
|
+
};
|
|
18141
|
+
/**
|
|
18142
|
+
* The schema the auth tables live in, derived exactly as `ensureAuthTablesExist`
|
|
18143
|
+
* derives it. Shared so the two cannot drift: a stamp written to one schema and
|
|
18144
|
+
* read from another would read as "never stamped" forever.
|
|
18145
|
+
*/
|
|
18146
|
+
function resolveAuthSchema(collection) {
|
|
18147
|
+
if (!collection) return "rebase";
|
|
18148
|
+
const usersSchema = "schema" in collection && typeof collection.schema === "string" ? collection.schema : "public";
|
|
18149
|
+
return usersSchema === "public" ? "rebase" : usersSchema;
|
|
18150
|
+
}
|
|
18151
|
+
/**
|
|
18152
|
+
* Read the stamped version, or `null` when the database has never been stamped.
|
|
18153
|
+
*
|
|
18154
|
+
* `null` is not an error and must not be treated as one: every database
|
|
18155
|
+
* provisioned before this file existed is unstamped, and so is every fresh one.
|
|
18156
|
+
* Uses `to_regclass` rather than selecting straight from the table so a missing
|
|
18157
|
+
* schema or table is a `null` rather than a thrown 42P01.
|
|
18158
|
+
*/
|
|
18159
|
+
async function readAuthSchemaVersion(db, authSchema) {
|
|
18160
|
+
const qualified = `"${authSchema}"."schema_meta"`;
|
|
18161
|
+
if (!(await db.execute(sql`SELECT to_regclass(${qualified}) IS NOT NULL AS present`)).rows[0]?.present) return null;
|
|
18162
|
+
const raw = (await db.execute(sql`
|
|
18163
|
+
SELECT value FROM ${sql.raw(qualified)} WHERE key = ${VERSION_KEY}
|
|
18164
|
+
`)).rows[0]?.value;
|
|
18165
|
+
if (raw === void 0) return null;
|
|
18166
|
+
const parsed = Number.parseInt(raw, 10);
|
|
18167
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
18168
|
+
}
|
|
18169
|
+
/**
|
|
18170
|
+
* Refuse to run against a database a newer runtime has already migrated.
|
|
18171
|
+
*
|
|
18172
|
+
* Deliberately one-directional. A database *older* than this runtime is the
|
|
18173
|
+
* normal upgrade path — the migrations in `ensureAuthTablesExist` are about to
|
|
18174
|
+
* bring it forward, so it is not an error. Only the reverse is unrecoverable.
|
|
18175
|
+
*/
|
|
18176
|
+
async function assertAuthSchemaCompatible(db, authSchema) {
|
|
18177
|
+
const databaseVersion = await readAuthSchemaVersion(db, authSchema);
|
|
18178
|
+
if (databaseVersion !== null && databaseVersion > 2) throw new AuthSchemaVersionError(databaseVersion, 2);
|
|
18179
|
+
}
|
|
18180
|
+
/**
|
|
18181
|
+
* Record that this runtime's migrations have been applied.
|
|
18182
|
+
*
|
|
18183
|
+
* Called at the end of `ensureAuthTablesExist`, so a boot that failed partway
|
|
18184
|
+
* through leaves the older stamp in place and the next boot migrates again.
|
|
18185
|
+
*/
|
|
18186
|
+
async function stampAuthSchemaVersion(db, authSchema) {
|
|
18187
|
+
const qualified = `"${authSchema}"."schema_meta"`;
|
|
18188
|
+
await db.execute(sql`
|
|
18189
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(qualified)} (
|
|
18190
|
+
key TEXT PRIMARY KEY,
|
|
18191
|
+
value TEXT NOT NULL,
|
|
18192
|
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
18193
|
+
)
|
|
18194
|
+
`);
|
|
18195
|
+
await db.execute(sql`
|
|
18196
|
+
INSERT INTO ${sql.raw(qualified)} (key, value)
|
|
18197
|
+
VALUES (${VERSION_KEY}, ${String(2)})
|
|
18198
|
+
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
|
18199
|
+
`);
|
|
18200
|
+
}
|
|
18201
|
+
/**
|
|
18202
|
+
* Check that the auth schema is one this runtime can actually write to.
|
|
18203
|
+
*
|
|
18204
|
+
* Two independent checks, because either alone has a blind spot:
|
|
18205
|
+
*
|
|
18206
|
+
* - The **stamp** catches a runtime older than the database. It is the precise
|
|
18207
|
+
* signal, but it is blind on every database provisioned before stamping
|
|
18208
|
+
* existed — which today is all of them.
|
|
18209
|
+
* - The **structure** catches a database older than the runtime, and works on
|
|
18210
|
+
* unstamped databases. It is what makes this useful immediately rather than
|
|
18211
|
+
* one upgrade cycle from now.
|
|
18212
|
+
*
|
|
18213
|
+
* Never throws: a probe that fails to run reports unhealthy with the reason, so
|
|
18214
|
+
* a broken check surfaces as a degraded health response rather than a 500 from
|
|
18215
|
+
* the health endpoint itself.
|
|
18216
|
+
*/
|
|
18217
|
+
async function probeAuthSchema(db, authSchema) {
|
|
18218
|
+
const problems = [];
|
|
18219
|
+
let databaseVersion = null;
|
|
18220
|
+
try {
|
|
18221
|
+
databaseVersion = await readAuthSchemaVersion(db, authSchema);
|
|
18222
|
+
if (databaseVersion !== null && databaseVersion > 2) problems.push(`database is at auth schema version ${databaseVersion}, this runtime understands 2 — it was migrated by a newer framework version`);
|
|
18223
|
+
const refreshTokens = `"${authSchema}"."refresh_tokens"`;
|
|
18224
|
+
if (!(await db.execute(sql`SELECT to_regclass(${refreshTokens}) IS NOT NULL AS present`)).rows[0]?.present) return {
|
|
18225
|
+
healthy: problems.length === 0,
|
|
18226
|
+
databaseVersion,
|
|
18227
|
+
runtimeVersion: 2,
|
|
18228
|
+
problems
|
|
18229
|
+
};
|
|
18230
|
+
const columns = await db.execute(sql`
|
|
18231
|
+
SELECT column_name FROM information_schema.columns
|
|
18232
|
+
WHERE table_schema = ${authSchema} AND table_name = 'refresh_tokens'
|
|
18233
|
+
`);
|
|
18234
|
+
const found = new Set(columns.rows.map((row) => row.column_name));
|
|
18235
|
+
const missing = REQUIRED_REFRESH_TOKEN_COLUMNS.filter((column) => !found.has(column));
|
|
18236
|
+
if (missing.length > 0) problems.push(`refresh_tokens is missing ${missing.join(", ")} — the auth migrations have not been applied to this database, so token rotation will fail`);
|
|
18237
|
+
if ((await db.execute(sql`
|
|
18238
|
+
SELECT 1 FROM pg_constraint c
|
|
18239
|
+
JOIN pg_class t ON t.oid = c.conrelid
|
|
18240
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
18241
|
+
WHERE n.nspname = ${authSchema}
|
|
18242
|
+
AND t.relname = 'refresh_tokens'
|
|
18243
|
+
AND c.conname = ${RETIRED_REFRESH_TOKEN_CONSTRAINT}
|
|
18244
|
+
`)).rows.length > 0) problems.push(`refresh_tokens still carries ${RETIRED_REFRESH_TOKEN_CONSTRAINT} — concurrent token rotation for one session will fail on it`);
|
|
18245
|
+
} catch (error) {
|
|
18246
|
+
problems.push(`auth schema probe failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
18247
|
+
}
|
|
18248
|
+
return {
|
|
18249
|
+
healthy: problems.length === 0,
|
|
18250
|
+
databaseVersion,
|
|
18251
|
+
runtimeVersion: 2,
|
|
18252
|
+
problems
|
|
18253
|
+
};
|
|
18254
|
+
}
|
|
17967
18255
|
//#endregion
|
|
17968
18256
|
//#region src/auth/ensure-tables.ts
|
|
17969
18257
|
/**
|
|
@@ -17975,6 +18263,7 @@ function buildCollectionRegistry(schema) {
|
|
|
17975
18263
|
*/
|
|
17976
18264
|
async function ensureAuthTablesExist(db, collection) {
|
|
17977
18265
|
logger.info("🔍 Checking auth tables...");
|
|
18266
|
+
await assertAuthSchemaCompatible(db, resolveAuthSchema(collection));
|
|
17978
18267
|
try {
|
|
17979
18268
|
let usersTableName = "\"rebase\".\"users\"";
|
|
17980
18269
|
let userIdType = "TEXT";
|
|
@@ -18355,8 +18644,10 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
18355
18644
|
} catch (rlsReconcileError) {
|
|
18356
18645
|
logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
|
|
18357
18646
|
}
|
|
18647
|
+
await stampAuthSchemaVersion(db, authSchema);
|
|
18358
18648
|
logger.info("✅ Auth tables ready");
|
|
18359
18649
|
} catch (error) {
|
|
18650
|
+
if (error instanceof AuthSchemaVersionError) throw error;
|
|
18360
18651
|
logger.error("❌ Failed to create auth tables", { error });
|
|
18361
18652
|
logger.warn("⚠️ Continuing without creating auth tables.");
|
|
18362
18653
|
}
|
|
@@ -20251,7 +20542,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
20251
20542
|
} catch {}
|
|
20252
20543
|
const message = `
|
|
20253
20544
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
20254
|
-
❌ Cannot connect to PostgreSQL at ${hostInfo}\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n The database server is not running or is not accepting\n connections. Common fixes:\n\n •
|
|
20545
|
+
❌ Cannot connect to PostgreSQL at ${hostInfo}\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n The database server is not running or is not accepting\n connections. Common fixes:\n\n • docker compose up -d db (the service a Rebase scaffold ships)\n • brew services start postgresql@18\n • Verify DATABASE_URL in your .env file\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
|
|
20255
20546
|
logger.error(message);
|
|
20256
20547
|
throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
|
|
20257
20548
|
}
|
|
@@ -20336,10 +20627,15 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
20336
20627
|
const cdcRunSql = async (text) => {
|
|
20337
20628
|
return (await schemaAwareDb.execute(sql.raw(text))).rows ?? [];
|
|
20338
20629
|
};
|
|
20339
|
-
|
|
20630
|
+
const cdcTables = registry.getCollections().map((c) => ({
|
|
20340
20631
|
schema: c.schema ?? "public",
|
|
20341
20632
|
table: getTableName$1(c)
|
|
20342
|
-
})).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table))
|
|
20633
|
+
})).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table));
|
|
20634
|
+
for (const link of collectJunctionLinks(registry)) cdcTables.push({
|
|
20635
|
+
schema: link.schema,
|
|
20636
|
+
table: link.table
|
|
20637
|
+
});
|
|
20638
|
+
await provisionTriggerCdc(cdcRunSql, cdcTables);
|
|
20343
20639
|
await realtimeService.enableCdc(directUrl);
|
|
20344
20640
|
cdcEnabled = true;
|
|
20345
20641
|
provisionCdcForTables = async (tables) => {
|
|
@@ -20449,7 +20745,8 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
20449
20745
|
userService,
|
|
20450
20746
|
roleService: userService,
|
|
20451
20747
|
emailService,
|
|
20452
|
-
authRepository
|
|
20748
|
+
authRepository,
|
|
20749
|
+
schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
|
|
20453
20750
|
};
|
|
20454
20751
|
},
|
|
20455
20752
|
async initializeHistory(config, driverResult) {
|
|
@@ -20477,7 +20774,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
20477
20774
|
*/
|
|
20478
20775
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
20479
20776
|
const internals = driverResult.internals;
|
|
20480
|
-
const { ensureCollectionTables } = await import("./ensure-collection-tables-
|
|
20777
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-DGMYK0fr.js");
|
|
20481
20778
|
return { applied: (await ensureCollectionTables({ async query(text) {
|
|
20482
20779
|
const result = await internals.db.execute(sql.raw(text));
|
|
20483
20780
|
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|