@rebasepro/server-postgres 0.10.1-canary.d8d45b2 → 0.10.1-canary.ed78a2c
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-CNlIONzj.js → ensure-collection-tables-DGMYK0fr.js} +12 -12
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
- package/dist/index.es.js +913 -391
- package/dist/index.es.js.map +1 -1
- package/dist/services/FetchService.d.ts +21 -8
- package/dist/services/PersistService.d.ts +12 -0
- package/dist/services/RelationService.d.ts +39 -8
- package/dist/services/cdc/junction-tables.d.ts +38 -0
- package/dist/services/nested-path.d.ts +59 -0
- package/dist/services/realtimeService.d.ts +19 -0
- package/dist/services/row-pipeline.d.ts +2 -2
- package/dist/{src-DmsRg8MR.js → src-3VmUJ8Xn.js} +214 -276
- package/dist/src-3VmUJ8Xn.js.map +1 -0
- package/dist/{src-B0v4IKaI.js → src-D5xBTl32.js} +19 -2
- package/dist/src-D5xBTl32.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +71 -18
- package/package.json +8 -9
- 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 +14 -14
- package/src/schema/generate-drizzle-schema-logic.ts +62 -110
- package/src/schema/generate-postgres-ddl-logic.ts +28 -21
- package/src/schema/introspect-db-inference.ts +13 -13
- package/src/schema/introspect-db-logic.ts +25 -29
- package/src/services/FetchService.ts +116 -126
- package/src/services/PersistService.ts +126 -88
- package/src/services/RelationService.ts +157 -86
- package/src/services/cdc/junction-tables.ts +91 -0
- package/src/services/nested-path.ts +145 -0
- package/src/services/realtimeService.ts +60 -0
- package/src/services/row-pipeline.ts +5 -6
- package/src/utils/drizzle-conditions.ts +268 -330
- package/dist/ensure-collection-tables-CNlIONzj.js.map +0 -1
- package/dist/src-B0v4IKaI.js.map +0 -1
- package/dist/src-DmsRg8MR.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";
|
|
@@ -392,6 +392,93 @@ function unwrapRelationFilterValue(value) {
|
|
|
392
392
|
* const builder: ConditionBuilderStatic<SQL> = DrizzleConditionBuilder;
|
|
393
393
|
*/
|
|
394
394
|
var DrizzleConditionBuilder = class {
|
|
395
|
+
/**
|
|
396
|
+
* Express "reachable from this parent through this relation" as a plain
|
|
397
|
+
* `WHERE` condition on the target table.
|
|
398
|
+
*
|
|
399
|
+
* This is the primitive that lets a relation be a *filter* rather than an
|
|
400
|
+
* addressing scheme. A nested listing used to be served by its own query
|
|
401
|
+
* builder — `fetchEntitiesUsingJoins`, which grew joins the root pipeline
|
|
402
|
+
* did not have and lost the options the root pipeline did have (offset,
|
|
403
|
+
* filter, orderBy, include). Reduced to a condition, the same listing runs
|
|
404
|
+
* through the ordinary collection query, so it inherits all of them and
|
|
405
|
+
* there is one read path instead of two.
|
|
406
|
+
*
|
|
407
|
+
* The shapes:
|
|
408
|
+
* - inverse FK → `target.<fk> = :parentId`, a column comparison.
|
|
409
|
+
* - `through` → `EXISTS (SELECT 1 FROM junction …)`, correlated on the
|
|
410
|
+
* target's key, so the junction never multiplies rows the
|
|
411
|
+
* way an `INNER JOIN` would.
|
|
412
|
+
* - `joinPath` → the same `EXISTS`, with the path's steps joined inside
|
|
413
|
+
* it and the final step correlating to the outer row.
|
|
414
|
+
*/
|
|
415
|
+
static buildRelationScopeCondition(relation, parent, parentId, targetTable, targetIdColumn, registry) {
|
|
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)}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* `EXISTS` for an explicit `joinPath`.
|
|
445
|
+
*
|
|
446
|
+
* The path is declared source → target. The subquery replays every step but
|
|
447
|
+
* the last from inside, and turns the last one into the correlation with the
|
|
448
|
+
* outer target row — so the target table is never named twice and needs no
|
|
449
|
+
* alias. Each intermediate table is aliased positionally, which keeps a path
|
|
450
|
+
* that revisits a table (a self-referencing many-to-many) unambiguous.
|
|
451
|
+
*/
|
|
452
|
+
static buildJoinPathScopeCondition(joinPath, parentTable, parentIdColumn, parentId, targetTable, registry) {
|
|
453
|
+
const sourceAlias = "__rel_src";
|
|
454
|
+
const aliasFor = (index) => `__rel_j${index}`;
|
|
455
|
+
const fromRef = (stepIndex, column) => sql`${sql.identifier(stepIndex === 0 ? sourceAlias : aliasFor(stepIndex - 1))}.${sql.identifier(getColumnName(column))}`;
|
|
456
|
+
const pairs = (step) => {
|
|
457
|
+
const from = Array.isArray(step.on.from) ? step.on.from : [step.on.from];
|
|
458
|
+
const to = Array.isArray(step.on.to) ? step.on.to : [step.on.to];
|
|
459
|
+
if (from.length !== to.length) throw new Error(`Join step on '${step.table}' has ${from.length} \`from\` columns and ${to.length} \`to\` columns`);
|
|
460
|
+
return from.map((f, i) => ({
|
|
461
|
+
from: f,
|
|
462
|
+
to: to[i]
|
|
463
|
+
}));
|
|
464
|
+
};
|
|
465
|
+
const inner = joinPath.slice(0, -1);
|
|
466
|
+
const last = joinPath[joinPath.length - 1];
|
|
467
|
+
const joins = inner.map((step, index) => {
|
|
468
|
+
const table = registry.getTable(step.table);
|
|
469
|
+
if (!table) throw new Error(`Join table not found: ${step.table}`);
|
|
470
|
+
const on = pairs(step).map(({ from, to }) => sql`${fromRef(index, from)} = ${sql.identifier(aliasFor(index))}.${sql.identifier(getColumnName(to))}`);
|
|
471
|
+
return sql`JOIN ${table} AS ${sql.identifier(aliasFor(index))} ON ${sql.join(on, sql` AND `)}`;
|
|
472
|
+
});
|
|
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 `);
|
|
479
|
+
const joinsSql = joins.length > 0 ? sql` ${sql.join(joins, sql` `)}` : sql``;
|
|
480
|
+
return sql`EXISTS (SELECT 1 FROM ${parentTable} AS ${sql.identifier(sourceAlias)}${joinsSql} WHERE ${sql.identifier(sourceAlias)}.${sql.identifier(parentIdColumn.name)} = ${parentId} AND ${correlation})`;
|
|
481
|
+
}
|
|
395
482
|
/**
|
|
396
483
|
* Build filter conditions from FilterValues
|
|
397
484
|
*/
|
|
@@ -489,58 +576,43 @@ var DrizzleConditionBuilder = class {
|
|
|
489
576
|
/**
|
|
490
577
|
* Build relation-based conditions for different relation types
|
|
491
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
|
+
*/
|
|
492
593
|
static buildRelationConditions(relation, parentId, targetTable, parentTable, parentIdColumn, targetIdColumn, registry) {
|
|
493
|
-
console.debug("🔍 [buildRelationConditions] Building conditions for relation:", {
|
|
494
|
-
relationName: relation.relationName,
|
|
495
|
-
cardinality: relation.cardinality,
|
|
496
|
-
direction: relation.direction,
|
|
497
|
-
hasThrough: !!relation.through,
|
|
498
|
-
hasForeignKeyOnTarget: !!relation.foreignKeyOnTarget,
|
|
499
|
-
inverseRelationName: relation.inverseRelationName,
|
|
500
|
-
parentId
|
|
501
|
-
});
|
|
502
594
|
const joinConditions = [];
|
|
503
595
|
const whereConditions = [];
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
whereConditions.push(junctionResult.condition);
|
|
514
|
-
} else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") {
|
|
515
|
-
console.debug("🔍 [buildRelationConditions] Using inverse many-to-many with explicit through");
|
|
516
|
-
const junctionResult = this.buildInverseJunctionTableConditions(relation.through, targetIdColumn, parentId, registry);
|
|
517
|
-
joinConditions.push(junctionResult.join);
|
|
518
|
-
whereConditions.push(junctionResult.condition);
|
|
519
|
-
} else if (relation.cardinality === "many" && relation.direction === "inverse" && !relation.through) {
|
|
520
|
-
console.debug("🔍 [buildRelationConditions] Handling inverse many relationship without explicit through");
|
|
521
|
-
const junctionInfo = this.findCorrespondingJunctionTable(relation, registry);
|
|
522
|
-
if (junctionInfo) {
|
|
523
|
-
console.debug("🔍 [buildRelationConditions] Found junction info for inverse many-to-many, building junction conditions");
|
|
524
|
-
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);
|
|
525
605
|
joinConditions.push(junctionResult.join);
|
|
526
606
|
whereConditions.push(junctionResult.condition);
|
|
527
|
-
|
|
528
|
-
console.debug("🔍 [buildRelationConditions] No junction table found, treating as inverse one-to-many with foreign key on target");
|
|
529
|
-
const simpleCondition = this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId);
|
|
530
|
-
whereConditions.push(simpleCondition);
|
|
531
|
-
} else {
|
|
532
|
-
logger.error("🔍 [buildRelationConditions] Failed to find junction table info and no foreign key specified");
|
|
533
|
-
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;
|
|
534
608
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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)}`);
|
|
539
615
|
}
|
|
540
|
-
console.debug("🔍 [buildRelationConditions] Final result:", {
|
|
541
|
-
joinConditionsCount: joinConditions.length,
|
|
542
|
-
whereConditionsCount: whereConditions.length
|
|
543
|
-
});
|
|
544
616
|
return {
|
|
545
617
|
joinConditions,
|
|
546
618
|
whereConditions
|
|
@@ -680,48 +752,24 @@ var DrizzleConditionBuilder = class {
|
|
|
680
752
|
};
|
|
681
753
|
}
|
|
682
754
|
/**
|
|
683
|
-
*
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
if (!junctionSourceCol) throw new Error(`Source column '${through.sourceColumn}' not found in junction table '${through.table}'`);
|
|
691
|
-
if (!junctionTargetCol) throw new Error(`Target column '${through.targetColumn}' not found in junction table '${through.table}'`);
|
|
692
|
-
const condition = Array.isArray(parentId) ? inArray(junctionSourceCol, parentId) : eq(junctionSourceCol, parentId);
|
|
693
|
-
return {
|
|
694
|
-
join: {
|
|
695
|
-
table: junctionTable,
|
|
696
|
-
condition: eq(targetIdColumn, junctionTargetCol)
|
|
697
|
-
},
|
|
698
|
-
condition
|
|
699
|
-
};
|
|
700
|
-
}
|
|
701
|
-
/**
|
|
702
|
-
* 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.
|
|
703
762
|
*/
|
|
704
763
|
static buildSimpleRelationCondition(relation, targetTable, parentTable, parentId) {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
const foreignKeyCol = targetTable[relation.foreignKeyOnTarget];
|
|
715
|
-
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.`);
|
|
716
|
-
return Array.isArray(parentId) ? inArray(foreignKeyCol, parentId) : eq(foreignKeyCol, parentId);
|
|
717
|
-
} 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.`);
|
|
718
|
-
else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) {
|
|
719
|
-
const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
|
|
720
|
-
const foreignKeyCol = targetTable[inferredForeignKeyName];
|
|
721
|
-
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.`);
|
|
722
|
-
console.debug(`🔍 [DrizzleConditionBuilder] Auto-inferred foreign key '${inferredForeignKeyName}' for inverse relation '${relation.relationName}'`);
|
|
723
|
-
return Array.isArray(parentId) ? inArray(foreignKeyCol, parentId) : eq(foreignKeyCol, parentId);
|
|
724
|
-
} 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);
|
|
725
773
|
}
|
|
726
774
|
/**
|
|
727
775
|
* Combine multiple conditions with AND operator
|
|
@@ -776,16 +824,23 @@ var DrizzleConditionBuilder = class {
|
|
|
776
824
|
return query;
|
|
777
825
|
}
|
|
778
826
|
/**
|
|
779
|
-
*
|
|
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.
|
|
780
832
|
*/
|
|
781
833
|
static buildRelationCountQuery(baseCountQuery, relation, parentId, targetTable, parentTable, parentIdColumn, targetIdColumn, registry, additionalFilters) {
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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)}`);
|
|
789
844
|
}
|
|
790
845
|
}
|
|
791
846
|
/**
|
|
@@ -826,20 +881,6 @@ var DrizzleConditionBuilder = class {
|
|
|
826
881
|
return baseCountQuery.innerJoin(junctionTable, eq(targetIdColumn, junctionTargetCol)).where(and(...baseConditions));
|
|
827
882
|
}
|
|
828
883
|
/**
|
|
829
|
-
* Build inverse junction table conditions for count queries
|
|
830
|
-
*/
|
|
831
|
-
static buildInverseJunctionCountQuery(baseCountQuery, through, targetIdColumn, parentId, registry, additionalFilters) {
|
|
832
|
-
const junctionTable = registry.getTable(through.table);
|
|
833
|
-
if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
|
|
834
|
-
const junctionSourceCol = junctionTable[through.sourceColumn];
|
|
835
|
-
const junctionTargetCol = junctionTable[through.targetColumn];
|
|
836
|
-
if (!junctionSourceCol) throw new Error(`Source column '${through.sourceColumn}' not found in junction table '${through.table}'`);
|
|
837
|
-
if (!junctionTargetCol) throw new Error(`Target column '${through.targetColumn}' not found in junction table '${through.table}'`);
|
|
838
|
-
const baseConditions = [eq(junctionSourceCol, parentId)];
|
|
839
|
-
if (additionalFilters && additionalFilters.length > 0) baseConditions.push(...additionalFilters);
|
|
840
|
-
return baseCountQuery.innerJoin(junctionTable, eq(targetIdColumn, junctionTargetCol)).where(and(...baseConditions));
|
|
841
|
-
}
|
|
842
|
-
/**
|
|
843
884
|
* Helper method to extract table names from columns
|
|
844
885
|
*/
|
|
845
886
|
static getTableNamesFromColumns(columns) {
|
|
@@ -854,49 +895,6 @@ var DrizzleConditionBuilder = class {
|
|
|
854
895
|
return [getColumnName(columns)];
|
|
855
896
|
}
|
|
856
897
|
/**
|
|
857
|
-
* Find the corresponding junction table for an inverse many-to-many relation
|
|
858
|
-
*/
|
|
859
|
-
static findCorrespondingJunctionTable(relation, registry) {
|
|
860
|
-
try {
|
|
861
|
-
console.debug(`🔍 [findCorrespondingJunctionTable] Looking for junction table for inverse relation '${relation.relationName}' with inverseRelationName '${relation.inverseRelationName}'`);
|
|
862
|
-
if (!relation.inverseRelationName) {
|
|
863
|
-
console.debug("🔍 [findCorrespondingJunctionTable] No inverseRelationName specified");
|
|
864
|
-
return null;
|
|
865
|
-
}
|
|
866
|
-
const targetCollection = relation.target();
|
|
867
|
-
console.debug(`🔍 [findCorrespondingJunctionTable] Target collection: ${targetCollection.slug}`);
|
|
868
|
-
const targetCollectionRelations = resolveCollectionRelations(targetCollection);
|
|
869
|
-
console.debug("🔍 [findCorrespondingJunctionTable] Target collection relations:", Object.keys(targetCollectionRelations));
|
|
870
|
-
const correspondingRelation = targetCollectionRelations[relation.inverseRelationName];
|
|
871
|
-
if (!correspondingRelation) {
|
|
872
|
-
console.debug(`🔍 [findCorrespondingJunctionTable] No relation found with key '${relation.inverseRelationName}' on target collection`);
|
|
873
|
-
return null;
|
|
874
|
-
}
|
|
875
|
-
console.debug("🔍 [findCorrespondingJunctionTable] Found relation:", {
|
|
876
|
-
relationName: correspondingRelation.relationName,
|
|
877
|
-
cardinality: correspondingRelation.cardinality,
|
|
878
|
-
direction: correspondingRelation.direction,
|
|
879
|
-
hasThrough: !!correspondingRelation.through
|
|
880
|
-
});
|
|
881
|
-
if (correspondingRelation.cardinality !== "many" || correspondingRelation.direction !== "owning" || !correspondingRelation.through) {
|
|
882
|
-
console.debug("🔍 [findCorrespondingJunctionTable] Relation is not an owning many-to-many with junction table");
|
|
883
|
-
return null;
|
|
884
|
-
}
|
|
885
|
-
console.debug("🔍 [findCorrespondingJunctionTable] Found matching owning relation with junction table!");
|
|
886
|
-
const through = correspondingRelation.through;
|
|
887
|
-
const result = {
|
|
888
|
-
table: through.table,
|
|
889
|
-
sourceColumn: through.targetColumn,
|
|
890
|
-
targetColumn: through.sourceColumn
|
|
891
|
-
};
|
|
892
|
-
console.debug("🔍 [findCorrespondingJunctionTable] Returning junction info:", result);
|
|
893
|
-
return result;
|
|
894
|
-
} catch (error) {
|
|
895
|
-
logger.error(`🔍 [findCorrespondingJunctionTable] Error finding corresponding junction table for relation '${relation.relationName}'`, { error });
|
|
896
|
-
return null;
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
/**
|
|
900
898
|
* Build vector similarity search expressions for pgvector.
|
|
901
899
|
*
|
|
902
900
|
* Returns:
|
|
@@ -973,7 +971,7 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
973
971
|
const joinPathRelationUpdates = [];
|
|
974
972
|
const foreignKeys = /* @__PURE__ */ new Set();
|
|
975
973
|
Object.values(resolvedRelations).forEach((relation) => {
|
|
976
|
-
if (relation.
|
|
974
|
+
if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
|
|
977
975
|
});
|
|
978
976
|
for (const [key, value] of Object.entries(row)) {
|
|
979
977
|
const property = properties[key];
|
|
@@ -985,11 +983,11 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
985
983
|
if (property.type === "relation" && collection) {
|
|
986
984
|
const relation = findRelation(resolvedRelations, key);
|
|
987
985
|
if (relation) {
|
|
988
|
-
if (relation.
|
|
986
|
+
if (relation.kind === "belongsTo") {
|
|
989
987
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
990
988
|
if (serializedValue !== void 0) result[relation.localKey] = serializedValue;
|
|
991
989
|
continue;
|
|
992
|
-
} else if (relation
|
|
990
|
+
} else if (hasForeignKeyOnTarget(relation)) {
|
|
993
991
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
994
992
|
inverseRelationUpdates.push({
|
|
995
993
|
relationKey: key,
|
|
@@ -997,7 +995,7 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
997
995
|
newValue: serializedValue
|
|
998
996
|
});
|
|
999
997
|
continue;
|
|
1000
|
-
} else if (relation.
|
|
998
|
+
} else if (relation.kind === "via") {
|
|
1001
999
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1002
1000
|
if (relation.cardinality === "one") joinPathRelationUpdates.push({
|
|
1003
1001
|
relationKey: key,
|
|
@@ -1010,14 +1008,6 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1010
1008
|
newValue: serializedValue
|
|
1011
1009
|
});
|
|
1012
1010
|
continue;
|
|
1013
|
-
} else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
|
|
1014
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1015
|
-
joinPathRelationUpdates.push({
|
|
1016
|
-
relationKey: key,
|
|
1017
|
-
relation,
|
|
1018
|
-
newTargetId: serializedValue
|
|
1019
|
-
});
|
|
1020
|
-
continue;
|
|
1021
1011
|
}
|
|
1022
1012
|
}
|
|
1023
1013
|
}
|
|
@@ -1103,7 +1093,7 @@ async function parseDataFromServer(data, collection, db, registry) {
|
|
|
1103
1093
|
for (const [propKey, property] of Object.entries(properties)) if (property.type === "relation" && !(propKey in result)) {
|
|
1104
1094
|
const relation = findRelation(resolvedRelations, propKey);
|
|
1105
1095
|
if (relation) {
|
|
1106
|
-
if (relation.
|
|
1096
|
+
if (relation.kind === "belongsTo" && relation.localKey in data) {
|
|
1107
1097
|
const fkValue = data[relation.localKey];
|
|
1108
1098
|
if (fkValue !== null && fkValue !== void 0) try {
|
|
1109
1099
|
const targetCollection = relation.target();
|
|
@@ -1111,7 +1101,7 @@ async function parseDataFromServer(data, collection, db, registry) {
|
|
|
1111
1101
|
} catch (e) {
|
|
1112
1102
|
logger.warn(`Could not resolve target collection for relation property: ${propKey}`, { error: e });
|
|
1113
1103
|
}
|
|
1114
|
-
} else if (relation
|
|
1104
|
+
} else if (hasForeignKeyOnTarget(relation) && db && registry) try {
|
|
1115
1105
|
const targetCollection = relation.target();
|
|
1116
1106
|
const targetTable = registry.getTable(getTableName$1(targetCollection));
|
|
1117
1107
|
const currentId = buildCompositeId(data, getPrimaryKeys(collection, registry));
|
|
@@ -1132,7 +1122,7 @@ async function parseDataFromServer(data, collection, db, registry) {
|
|
|
1132
1122
|
} catch (e) {
|
|
1133
1123
|
logger.warn(`Could not resolve inverse relation property: ${propKey}`, { error: e });
|
|
1134
1124
|
}
|
|
1135
|
-
else if (relation.
|
|
1125
|
+
else if (relation.kind === "via" && db && registry) try {
|
|
1136
1126
|
const targetCollection = relation.target();
|
|
1137
1127
|
const pks = getPrimaryKeys(collection, registry);
|
|
1138
1128
|
const currentId = buildCompositeId(data, pks);
|
|
@@ -1249,11 +1239,10 @@ function parsePropertyFromServer(value, property, collection, propertyKey) {
|
|
|
1249
1239
|
}
|
|
1250
1240
|
case "relation":
|
|
1251
1241
|
if (typeof value === "string" || typeof value === "number") {
|
|
1252
|
-
let relationDef = property.
|
|
1242
|
+
let relationDef = property.resolvedRelation;
|
|
1253
1243
|
if (!relationDef && propertyKey) relationDef = findRelation(resolveCollectionRelations(collection), propertyKey);
|
|
1254
|
-
if (!relationDef) relationDef = collection.relations?.find((rel) => rel.relationName === property.relationName);
|
|
1255
1244
|
if (!relationDef) {
|
|
1256
|
-
logger.warn(`
|
|
1245
|
+
logger.warn(`ResolvedRelation not defined in property for key: ${propertyKey || "unknown"}`);
|
|
1257
1246
|
return value;
|
|
1258
1247
|
}
|
|
1259
1248
|
try {
|
|
@@ -1353,7 +1342,7 @@ function normalizeScalarValues(data, properties, collection, resolvedRelations,
|
|
|
1353
1342
|
const result = {};
|
|
1354
1343
|
const internalFKColumns = /* @__PURE__ */ new Set();
|
|
1355
1344
|
Object.values(resolvedRelations).forEach((relation) => {
|
|
1356
|
-
if (relation.
|
|
1345
|
+
if (relation.kind === "belongsTo" && !properties[relation.localKey]) internalFKColumns.add(relation.localKey);
|
|
1357
1346
|
});
|
|
1358
1347
|
for (const [key, value] of Object.entries(data)) {
|
|
1359
1348
|
if (internalFKColumns.has(key)) {
|
|
@@ -1491,7 +1480,7 @@ var RelationService = class {
|
|
|
1491
1480
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
1492
1481
|
if (!parentTable) throw new Error("Parent table not found");
|
|
1493
1482
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
1494
|
-
if (relation.
|
|
1483
|
+
if (relation.kind === "via") {
|
|
1495
1484
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
1496
1485
|
let currentTable = parentTable;
|
|
1497
1486
|
for (const join of relation.joinPath) {
|
|
@@ -1551,6 +1540,18 @@ var RelationService = class {
|
|
|
1551
1540
|
const available = Object.keys(resolvedRelations).join(", ") || "(none)";
|
|
1552
1541
|
throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
|
|
1553
1542
|
}
|
|
1543
|
+
return this.countRelatedRows(parentCollection, parentId, relation, []);
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Count the target rows a parent reaches through `relation`, narrowed by
|
|
1547
|
+
* `additionalFilters` (conditions on the target table).
|
|
1548
|
+
*
|
|
1549
|
+
* Shared by the public count and by {@link isRelated}, so "how many children
|
|
1550
|
+
* does this parent have" and "is this row one of them" are answered by the
|
|
1551
|
+
* same join — a membership test that reconstructed the join separately would
|
|
1552
|
+
* be free to disagree with the listing it is supposed to gate.
|
|
1553
|
+
*/
|
|
1554
|
+
async countRelatedRows(parentCollection, parentId, relation, additionalFilters) {
|
|
1554
1555
|
const targetCollection = relation.target();
|
|
1555
1556
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
1556
1557
|
const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
|
|
@@ -1561,11 +1562,54 @@ var RelationService = class {
|
|
|
1561
1562
|
if (!parentTable) throw new Error("Parent table not found");
|
|
1562
1563
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
1563
1564
|
let query = this.db.select({ count: sql`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
|
|
1564
|
-
query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, parsedParentId, targetTable, parentTable, parentIdCol, targetIdField, this.registry,
|
|
1565
|
+
query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, parsedParentId, targetTable, parentTable, parentIdCol, targetIdField, this.registry, additionalFilters);
|
|
1565
1566
|
const result = await query;
|
|
1566
1567
|
return Number(result[0]?.count || 0);
|
|
1567
1568
|
}
|
|
1568
1569
|
/**
|
|
1570
|
+
* Whether `targetId` is actually reachable from the parent named in `hop`.
|
|
1571
|
+
*
|
|
1572
|
+
* A nested address like `authors/1/posts/43` used to resolve to the target
|
|
1573
|
+
* collection and then match on the primary key alone, so the parent segment
|
|
1574
|
+
* decided nothing: the row came back, and was updated or deleted, whoever it
|
|
1575
|
+
* belonged to. Reads, updates and deletes now all gate on this.
|
|
1576
|
+
*/
|
|
1577
|
+
async isRelated(hop, targetId) {
|
|
1578
|
+
const targetTable = getTableForCollection(hop.targetCollection, this.registry);
|
|
1579
|
+
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
1580
|
+
const parsedTargetId = parseIdValues(targetId, targetPks);
|
|
1581
|
+
const identity = targetPks.map((pk) => {
|
|
1582
|
+
const column = targetTable[pk.fieldName];
|
|
1583
|
+
if (!column) throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
|
|
1584
|
+
return eq(column, parsedTargetId[pk.fieldName]);
|
|
1585
|
+
});
|
|
1586
|
+
return await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity) > 0;
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
1590
|
+
* row itself alone.
|
|
1591
|
+
*
|
|
1592
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
1593
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
1594
|
+
* post that uses it. It used to do exactly that — resolve the path to the
|
|
1595
|
+
* `tags` table and delete by primary key.
|
|
1596
|
+
*/
|
|
1597
|
+
async unlinkRelatedEntity(tx, hop, targetId) {
|
|
1598
|
+
if (!isManyToMany(hop.relation)) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
|
|
1599
|
+
const through = hop.relation.through;
|
|
1600
|
+
const junctionTable = this.registry.getTable(through.table);
|
|
1601
|
+
if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
|
|
1602
|
+
const sourceJunctionColumn = junctionTable[through.sourceColumn];
|
|
1603
|
+
const targetJunctionColumn = junctionTable[through.targetColumn];
|
|
1604
|
+
if (!sourceJunctionColumn || !targetJunctionColumn) throw new Error(`Junction columns not found for relation '${hop.relationKey}' on table '${through.table}'`);
|
|
1605
|
+
const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
|
|
1606
|
+
const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
1607
|
+
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
1608
|
+
const parsedTargetId = parseIdValues(targetId, targetPks)[targetPks[0].fieldName];
|
|
1609
|
+
await tx.delete(junctionTable).where(and(eq(sourceJunctionColumn, parsedParentId), eq(targetJunctionColumn, parsedTargetId)));
|
|
1610
|
+
logger.info(`Unlinked '${hop.relationKey}' ${parsedTargetId} from ${hop.parentCollection.slug} ${parsedParentId}`);
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1569
1613
|
* Batch fetch related rows for multiple parent rows to avoid N+1 queries
|
|
1570
1614
|
*/
|
|
1571
1615
|
async batchFetchRelatedEntities(parentCollectionPath, parentIds, _relationKey, relation) {
|
|
@@ -1582,7 +1626,7 @@ var RelationService = class {
|
|
|
1582
1626
|
if (!parentTable) throw new Error("Parent table not found");
|
|
1583
1627
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
1584
1628
|
const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
1585
|
-
if (relation.
|
|
1629
|
+
if (relation.kind === "via") {
|
|
1586
1630
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
1587
1631
|
let currentTable = parentTable;
|
|
1588
1632
|
for (const join of relation.joinPath) {
|
|
@@ -1611,7 +1655,7 @@ var RelationService = class {
|
|
|
1611
1655
|
}
|
|
1612
1656
|
return resultMap;
|
|
1613
1657
|
}
|
|
1614
|
-
if (relation.
|
|
1658
|
+
if (relation.kind === "belongsTo") {
|
|
1615
1659
|
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
|
|
1616
1660
|
const localKeyCol = parentTable[relation.localKey];
|
|
1617
1661
|
if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
|
|
@@ -1645,7 +1689,7 @@ var RelationService = class {
|
|
|
1645
1689
|
}
|
|
1646
1690
|
return resultMap;
|
|
1647
1691
|
}
|
|
1648
|
-
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget
|
|
1692
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName);
|
|
1649
1693
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
1650
1694
|
query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
1651
1695
|
const results = await query;
|
|
@@ -1654,8 +1698,7 @@ var RelationService = class {
|
|
|
1654
1698
|
for (const row of results) {
|
|
1655
1699
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
1656
1700
|
let parentId;
|
|
1657
|
-
if (relation
|
|
1658
|
-
else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
|
|
1701
|
+
if (hasForeignKeyOnTarget(relation)) parentId = targetRow[relation.foreignKeyOnTarget];
|
|
1659
1702
|
if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
1660
1703
|
}
|
|
1661
1704
|
return resultMap;
|
|
@@ -1678,7 +1721,7 @@ var RelationService = class {
|
|
|
1678
1721
|
if (!parentTable) throw new Error("Parent table not found");
|
|
1679
1722
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
1680
1723
|
const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
1681
|
-
if (relation.
|
|
1724
|
+
if (relation.kind === "via") {
|
|
1682
1725
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
1683
1726
|
let currentTable = parentTable;
|
|
1684
1727
|
for (const join of relation.joinPath) {
|
|
@@ -1708,7 +1751,7 @@ var RelationService = class {
|
|
|
1708
1751
|
}
|
|
1709
1752
|
return resultMap;
|
|
1710
1753
|
}
|
|
1711
|
-
if (relation.
|
|
1754
|
+
if (relation.kind === "manyToMany") {
|
|
1712
1755
|
this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
|
|
1713
1756
|
const junctionTable = this.registry.getTable(relation.through.table);
|
|
1714
1757
|
if (!junctionTable) {
|
|
@@ -1734,7 +1777,7 @@ var RelationService = class {
|
|
|
1734
1777
|
}
|
|
1735
1778
|
return resultMap;
|
|
1736
1779
|
}
|
|
1737
|
-
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget
|
|
1780
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName);
|
|
1738
1781
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
1739
1782
|
query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
1740
1783
|
const results = await query;
|
|
@@ -1743,9 +1786,7 @@ var RelationService = class {
|
|
|
1743
1786
|
for (const row of results) {
|
|
1744
1787
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
1745
1788
|
let parentId;
|
|
1746
|
-
if (relation
|
|
1747
|
-
else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
|
|
1748
|
-
else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
|
|
1789
|
+
if (hasForeignKeyOnTarget(relation)) parentId = targetRow[relation.foreignKeyOnTarget];
|
|
1749
1790
|
if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
|
|
1750
1791
|
const key = String(parentId);
|
|
1751
1792
|
const arr = resultMap.get(key) || [];
|
|
@@ -1765,7 +1806,7 @@ var RelationService = class {
|
|
|
1765
1806
|
if (!relation || relation.cardinality !== "many") continue;
|
|
1766
1807
|
const targetEntityIds = value && Array.isArray(value) ? value.map((rel) => rel.id) : [];
|
|
1767
1808
|
const targetCollection = relation.target();
|
|
1768
|
-
if (relation.
|
|
1809
|
+
if (relation.kind === "via") {
|
|
1769
1810
|
const parentTableName = getTableName$1(collection);
|
|
1770
1811
|
const targetTableName = getTableName$1(targetCollection);
|
|
1771
1812
|
let junctionTable = void 0;
|
|
@@ -1810,7 +1851,7 @@ var RelationService = class {
|
|
|
1810
1851
|
}));
|
|
1811
1852
|
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
1812
1853
|
}
|
|
1813
|
-
} else if (relation.
|
|
1854
|
+
} else if (relation.kind === "manyToMany") {
|
|
1814
1855
|
const junctionTable = this.registry.getTable(relation.through.table);
|
|
1815
1856
|
if (!junctionTable) {
|
|
1816
1857
|
logger.warn(`Junction table '${relation.through.table}' not found for relation '${key}' in collection '${collection.slug}'`);
|
|
@@ -1835,8 +1876,7 @@ var RelationService = class {
|
|
|
1835
1876
|
}));
|
|
1836
1877
|
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
1837
1878
|
}
|
|
1838
|
-
} else if (relation.
|
|
1839
|
-
else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
1879
|
+
} else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
1840
1880
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
1841
1881
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1842
1882
|
const targetIdInfo = targetPks[0];
|
|
@@ -1870,28 +1910,20 @@ var RelationService = class {
|
|
|
1870
1910
|
const targetIdInfo = targetPks[0];
|
|
1871
1911
|
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
1872
1912
|
const sourceIdInfo = sourcePks[0];
|
|
1873
|
-
if (relation.
|
|
1913
|
+
if (relation.kind === "via") {
|
|
1874
1914
|
await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
|
|
1875
1915
|
continue;
|
|
1876
1916
|
}
|
|
1877
|
-
if (relation
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
targetColumn: targetRelation.through.sourceColumn
|
|
1885
|
-
};
|
|
1886
|
-
break;
|
|
1887
|
-
}
|
|
1888
|
-
if (junctionInfo) {
|
|
1889
|
-
await this.updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, junctionInfo);
|
|
1890
|
-
continue;
|
|
1891
|
-
}
|
|
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;
|
|
1892
1924
|
}
|
|
1893
|
-
if (!relation
|
|
1894
|
-
logger.warn(`
|
|
1925
|
+
if (!hasForeignKeyOnTarget(relation)) {
|
|
1926
|
+
logger.warn(`Relation '${relation.relationName}' has no column on the target to write. Skipping.`);
|
|
1895
1927
|
continue;
|
|
1896
1928
|
}
|
|
1897
1929
|
const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget];
|
|
@@ -1917,10 +1949,6 @@ var RelationService = class {
|
|
|
1917
1949
|
*/
|
|
1918
1950
|
async updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue) {
|
|
1919
1951
|
try {
|
|
1920
|
-
if (!relation.joinPath || relation.joinPath.length === 0) {
|
|
1921
|
-
logger.warn(`Inverse relation '${relation.relationName}' missing joinPath`);
|
|
1922
|
-
return;
|
|
1923
|
-
}
|
|
1924
1952
|
const sourceTableName = getTableName$1(sourceCollection);
|
|
1925
1953
|
const targetTableName = getTableName$1(targetCollection);
|
|
1926
1954
|
const intermediateTables = relation.joinPath.map((step) => step.table).filter((table) => table !== sourceTableName && table !== targetTableName);
|
|
@@ -2102,8 +2130,8 @@ var RelationService = class {
|
|
|
2102
2130
|
[sourceJunctionColumn.name]: parentId,
|
|
2103
2131
|
[targetJunctionColumn.name]: parsedNewEntityId
|
|
2104
2132
|
};
|
|
2105
|
-
await tx.insert(junctionTable).values(junctionData);
|
|
2106
|
-
logger.info(`
|
|
2133
|
+
await tx.insert(junctionTable).values(junctionData).onConflictDoNothing();
|
|
2134
|
+
logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
|
|
2107
2135
|
} catch (error) {
|
|
2108
2136
|
logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error });
|
|
2109
2137
|
throw error;
|
|
@@ -2120,9 +2148,8 @@ var RelationService = class {
|
|
|
2120
2148
|
* to unwrap that same level back out.
|
|
2121
2149
|
*/
|
|
2122
2150
|
function isJunctionRelation(relation) {
|
|
2123
|
-
if (relation
|
|
2124
|
-
|
|
2125
|
-
return false;
|
|
2151
|
+
if (isManyToMany(relation)) return true;
|
|
2152
|
+
return relation.kind === "via" && relation.joinPath.length > 1;
|
|
2126
2153
|
}
|
|
2127
2154
|
/**
|
|
2128
2155
|
* Reach the target row inside a junction row.
|
|
@@ -2255,6 +2282,82 @@ function toRestRow(row, collection, registry) {
|
|
|
2255
2282
|
return stripExcluded(flat, collection);
|
|
2256
2283
|
}
|
|
2257
2284
|
//#endregion
|
|
2285
|
+
//#region src/services/nested-path.ts
|
|
2286
|
+
/**
|
|
2287
|
+
* True when `path` addresses rows through a relation rather than a root
|
|
2288
|
+
* collection.
|
|
2289
|
+
*
|
|
2290
|
+
* Any separator at all counts — a root collection slug never contains one — so
|
|
2291
|
+
* a malformed path like `collection/id` is a *broken* nested path and gets
|
|
2292
|
+
* reported as one by {@link resolveNestedPath}, rather than being looked up as
|
|
2293
|
+
* a root collection whose slug happens to contain a slash.
|
|
2294
|
+
*/
|
|
2295
|
+
function isNestedPath(path) {
|
|
2296
|
+
return path.includes("/");
|
|
2297
|
+
}
|
|
2298
|
+
function splitPathSegments(path) {
|
|
2299
|
+
return path.split("/").filter((s) => s && s !== "undefined");
|
|
2300
|
+
}
|
|
2301
|
+
/**
|
|
2302
|
+
* Walk a nested collection path down to the relation it ends in.
|
|
2303
|
+
*
|
|
2304
|
+
* Returns `undefined` for a plain root-collection path so callers can keep the
|
|
2305
|
+
* root case on its existing code path. Throws when the path is malformed, or
|
|
2306
|
+
* when a segment names a relation that does not exist — the same errors the
|
|
2307
|
+
* individual walks used to raise, with the available names attached.
|
|
2308
|
+
*/
|
|
2309
|
+
function resolveNestedPath(path, registry) {
|
|
2310
|
+
if (!isNestedPath(path)) return void 0;
|
|
2311
|
+
const segments = splitPathSegments(path);
|
|
2312
|
+
if (segments.length < 3 || segments.length % 2 === 0) throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
|
|
2313
|
+
let parentCollection = getCollectionByPath(segments[0], registry);
|
|
2314
|
+
let parentId = segments[1];
|
|
2315
|
+
for (let i = 2; i < segments.length; i += 2) {
|
|
2316
|
+
const relationKey = segments[i];
|
|
2317
|
+
const resolvedRelations = resolveCollectionRelations(parentCollection);
|
|
2318
|
+
const relation = findRelation(resolvedRelations, relationKey);
|
|
2319
|
+
if (!relation) {
|
|
2320
|
+
const available = Object.keys(resolvedRelations).join(", ") || "(none)";
|
|
2321
|
+
throw new Error(`Relation '${relationKey}' not found in collection '${parentCollection.slug}'. Available relations: [${available}]`);
|
|
2322
|
+
}
|
|
2323
|
+
const targetCollection = relation.target();
|
|
2324
|
+
if (i === segments.length - 1) return {
|
|
2325
|
+
parentCollection,
|
|
2326
|
+
parentId,
|
|
2327
|
+
relationKey,
|
|
2328
|
+
relation,
|
|
2329
|
+
targetCollection
|
|
2330
|
+
};
|
|
2331
|
+
parentCollection = targetCollection;
|
|
2332
|
+
parentId = segments[i + 1];
|
|
2333
|
+
}
|
|
2334
|
+
throw new Error(`Unable to resolve path: ${path}`);
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* A relation reached through a junction table — many-to-many, or a multi-hop
|
|
2338
|
+
* `joinPath`. The target row is shared with other parents, so writing "through"
|
|
2339
|
+
* such a path addresses the *link*, not the row.
|
|
2340
|
+
*/
|
|
2341
|
+
function isJunctionBackedRelation(relation) {
|
|
2342
|
+
return relation.shared;
|
|
2343
|
+
}
|
|
2344
|
+
/**
|
|
2345
|
+
* Reject a nested write whose final segment is a to-one relation.
|
|
2346
|
+
*
|
|
2347
|
+
* There is no column on the target row that records a to-one parent — the
|
|
2348
|
+
* foreign key lives on the *parent* table. The write path used to fall through
|
|
2349
|
+
* to `relation.localKey` here and stamp the parent's own FK column onto the
|
|
2350
|
+
* target row, which either raised an opaque "column does not exist" or, when a
|
|
2351
|
+
* column of that name happened to exist on the target, silently wrote the wrong
|
|
2352
|
+
* one.
|
|
2353
|
+
*/
|
|
2354
|
+
function assertWritableThrough(hop, path) {
|
|
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");
|
|
2359
|
+
}
|
|
2360
|
+
//#endregion
|
|
2258
2361
|
//#region src/services/FetchService.ts
|
|
2259
2362
|
/**
|
|
2260
2363
|
* Service for handling all row read operations.
|
|
@@ -2291,7 +2394,7 @@ var FetchService = class {
|
|
|
2291
2394
|
let orderByField = table[orderBy];
|
|
2292
2395
|
if (!orderByField && collection) {
|
|
2293
2396
|
const property = collection.properties[orderBy];
|
|
2294
|
-
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`];
|
|
2295
2398
|
}
|
|
2296
2399
|
return orderByField;
|
|
2297
2400
|
}
|
|
@@ -2312,7 +2415,7 @@ var FetchService = class {
|
|
|
2312
2415
|
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
2313
2416
|
if (!shouldInclude(key)) continue;
|
|
2314
2417
|
const drizzleRelName = relation.relationName || key;
|
|
2315
|
-
if (relation.
|
|
2418
|
+
if (relation.kind === "via") continue;
|
|
2316
2419
|
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
2317
2420
|
const targetFkName = this.getJunctionTargetRelationName(relation, collection);
|
|
2318
2421
|
if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
|
|
@@ -2326,7 +2429,7 @@ var FetchService = class {
|
|
|
2326
2429
|
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
2327
2430
|
*/
|
|
2328
2431
|
getJunctionTargetRelationName(relation, _collection) {
|
|
2329
|
-
if (relation
|
|
2432
|
+
if (isManyToMany(relation)) return relation.through.targetColumn.replace(/_id$/, "_id");
|
|
2330
2433
|
return null;
|
|
2331
2434
|
}
|
|
2332
2435
|
/**
|
|
@@ -2336,7 +2439,7 @@ var FetchService = class {
|
|
|
2336
2439
|
*/
|
|
2337
2440
|
async resolveJoinPathRelations(row, collection, collectionPath, parsedId, _databaseId) {
|
|
2338
2441
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
2339
|
-
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]) => {
|
|
2340
2443
|
try {
|
|
2341
2444
|
const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, { limit: relation.cardinality === "one" ? 1 : void 0 });
|
|
2342
2445
|
if (relation.cardinality === "one" && relatedRows.length > 0) {
|
|
@@ -2358,7 +2461,7 @@ var FetchService = class {
|
|
|
2358
2461
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
2359
2462
|
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
2360
2463
|
const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
2361
|
-
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));
|
|
2362
2465
|
if (joinPathRelations.length === 0) return;
|
|
2363
2466
|
const parentIdOf = (row) => {
|
|
2364
2467
|
const address = buildCompositeId(row, idInfoArray);
|
|
@@ -2386,10 +2489,11 @@ var FetchService = class {
|
|
|
2386
2489
|
* Build db.query-compatible options from standard fetch options.
|
|
2387
2490
|
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
2388
2491
|
*/
|
|
2389
|
-
buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig) {
|
|
2492
|
+
buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition) {
|
|
2390
2493
|
const queryOpts = {};
|
|
2391
2494
|
if (withConfig) queryOpts.with = withConfig;
|
|
2392
2495
|
const allConditions = [];
|
|
2496
|
+
if (scopeCondition) allConditions.push(scopeCondition);
|
|
2393
2497
|
if (options.searchString) {
|
|
2394
2498
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
2395
2499
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
@@ -2447,9 +2551,49 @@ var FetchService = class {
|
|
|
2447
2551
|
return [];
|
|
2448
2552
|
}
|
|
2449
2553
|
/**
|
|
2554
|
+
* Compile "rows reachable from this parent" into a `WHERE` condition on the
|
|
2555
|
+
* target table, so a nested listing can run as an ordinary collection query.
|
|
2556
|
+
*/
|
|
2557
|
+
buildRelationScope(hop) {
|
|
2558
|
+
const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
|
|
2559
|
+
const parentIdInfo = parentPks[0];
|
|
2560
|
+
const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentIdInfo.fieldName];
|
|
2561
|
+
const parent = () => {
|
|
2562
|
+
const table = getTableForCollection(hop.parentCollection, this.registry);
|
|
2563
|
+
const idColumn = table[parentIdInfo.fieldName];
|
|
2564
|
+
if (!idColumn) throw new Error(`ID field '${parentIdInfo.fieldName}' not found in table for collection '${hop.parentCollection.slug}'`);
|
|
2565
|
+
return {
|
|
2566
|
+
table,
|
|
2567
|
+
idColumn
|
|
2568
|
+
};
|
|
2569
|
+
};
|
|
2570
|
+
const targetTable = getTableForCollection(hop.targetCollection, this.registry);
|
|
2571
|
+
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
2572
|
+
const targetIdColumn = targetTable[targetPks[0].fieldName];
|
|
2573
|
+
if (!targetIdColumn) throw new Error(`ID field '${targetPks[0].fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
|
|
2574
|
+
return DrizzleConditionBuilder.buildRelationScopeCondition(hop.relation, parent, parsedParentId, targetTable, targetIdColumn, this.registry);
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* Whether `id` is actually reachable at `collectionPath`.
|
|
2578
|
+
*
|
|
2579
|
+
* Trivially true for a root path. For a nested one it is a real question:
|
|
2580
|
+
* the path resolves to the target collection, and matching on the primary
|
|
2581
|
+
* key alone made the parent segment decorative — `authors/1/posts/43`
|
|
2582
|
+
* returned post 43 whoever wrote it, and the REST layer's delete then
|
|
2583
|
+
* deleted it. A row that is not under this parent is reported as absent,
|
|
2584
|
+
* which is what a caller addressing it through the parent should see.
|
|
2585
|
+
*/
|
|
2586
|
+
async isAddressableUnder(collectionPath, id) {
|
|
2587
|
+
if (!isNestedPath(collectionPath)) return true;
|
|
2588
|
+
const hop = resolveNestedPath(collectionPath, this.registry);
|
|
2589
|
+
if (!hop) return true;
|
|
2590
|
+
return this.relationService.isRelated(hop, id);
|
|
2591
|
+
}
|
|
2592
|
+
/**
|
|
2450
2593
|
* Fetch a single row by ID
|
|
2451
2594
|
*/
|
|
2452
2595
|
async fetchOne(collectionPath, id, databaseId) {
|
|
2596
|
+
if (!await this.isAddressableUnder(collectionPath, id)) return void 0;
|
|
2453
2597
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
2454
2598
|
const table = getTableForCollection(collection, this.registry);
|
|
2455
2599
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
@@ -2471,7 +2615,7 @@ var FetchService = class {
|
|
|
2471
2615
|
return flatRow;
|
|
2472
2616
|
} catch (e) {
|
|
2473
2617
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
2474
|
-
logger.error(`[FetchService]
|
|
2618
|
+
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
2475
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.");
|
|
2476
2620
|
}
|
|
2477
2621
|
logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
@@ -2506,6 +2650,7 @@ var FetchService = class {
|
|
|
2506
2650
|
* Unified method to fetch rows with optional search functionality
|
|
2507
2651
|
*/
|
|
2508
2652
|
async fetchRowsWithConditions(collectionPath, options = {}) {
|
|
2653
|
+
const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : void 0;
|
|
2509
2654
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
2510
2655
|
const table = getTableForCollection(collection, this.registry);
|
|
2511
2656
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
@@ -2517,11 +2662,11 @@ var FetchService = class {
|
|
|
2517
2662
|
const withConfig = this.buildWithConfig(collection);
|
|
2518
2663
|
const hasRelations = withConfig && Object.keys(withConfig).length > 0;
|
|
2519
2664
|
if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
|
|
2520
|
-
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
|
|
2665
|
+
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0, scopeCondition);
|
|
2521
2666
|
return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
|
|
2522
2667
|
} catch (e) {
|
|
2523
2668
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
2524
|
-
logger.error(`[FetchService]
|
|
2669
|
+
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
2525
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.");
|
|
2526
2671
|
}
|
|
2527
2672
|
logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
@@ -2533,6 +2678,7 @@ var FetchService = class {
|
|
|
2533
2678
|
_distance: vectorMeta.distanceSelect
|
|
2534
2679
|
}).from(table).$dynamic() : this.db.select().from(table).$dynamic();
|
|
2535
2680
|
const allConditions = [];
|
|
2681
|
+
if (scopeCondition) allConditions.push(scopeCondition);
|
|
2536
2682
|
if (options.searchString) {
|
|
2537
2683
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
2538
2684
|
if (searchConditions.length === 0) return [];
|
|
@@ -2637,7 +2783,11 @@ var FetchService = class {
|
|
|
2637
2783
|
* Fetch a collection of rows
|
|
2638
2784
|
*/
|
|
2639
2785
|
async fetchCollection(collectionPath, options = {}) {
|
|
2640
|
-
|
|
2786
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
|
|
2787
|
+
if (hop) return this.fetchRowsWithConditions(hop.targetCollection.slug, {
|
|
2788
|
+
...options,
|
|
2789
|
+
relatedTo: hop
|
|
2790
|
+
});
|
|
2641
2791
|
return this.fetchRowsWithConditions(collectionPath, options);
|
|
2642
2792
|
}
|
|
2643
2793
|
/**
|
|
@@ -2650,43 +2800,23 @@ var FetchService = class {
|
|
|
2650
2800
|
});
|
|
2651
2801
|
}
|
|
2652
2802
|
/**
|
|
2653
|
-
* Fetch collection from multi-segment path
|
|
2654
|
-
*/
|
|
2655
|
-
async fetchCollectionFromPath(path, options = {}) {
|
|
2656
|
-
const pathSegments = path.split("/").filter((p) => p && p !== "undefined");
|
|
2657
|
-
if (pathSegments.length < 3 || pathSegments.length % 2 === 0) throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
|
|
2658
|
-
const rootCollectionPath = pathSegments[0];
|
|
2659
|
-
let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
|
|
2660
|
-
let currentId = pathSegments[1];
|
|
2661
|
-
for (let i = 2; i < pathSegments.length; i += 2) {
|
|
2662
|
-
const relationKey = pathSegments[i];
|
|
2663
|
-
const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
|
|
2664
|
-
if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
|
|
2665
|
-
if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({ ...row.values }));
|
|
2666
|
-
if (i + 1 < pathSegments.length) {
|
|
2667
|
-
const nextEntityId = pathSegments[i + 1];
|
|
2668
|
-
currentCollection = relation.target();
|
|
2669
|
-
currentId = nextEntityId;
|
|
2670
|
-
}
|
|
2671
|
-
}
|
|
2672
|
-
throw new Error(`Unable to resolve path: ${path}`);
|
|
2673
|
-
}
|
|
2674
|
-
/**
|
|
2675
2803
|
* Count rows in a collection
|
|
2676
2804
|
*/
|
|
2677
2805
|
async count(collectionPath, options = {}) {
|
|
2678
|
-
|
|
2679
|
-
const
|
|
2806
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
|
|
2807
|
+
const effectivePath = hop ? hop.targetCollection.slug : collectionPath;
|
|
2808
|
+
const collection = getCollectionByPath(effectivePath, this.registry);
|
|
2680
2809
|
const table = getTableForCollection(collection, this.registry);
|
|
2681
2810
|
let query = this.db.select({ count: count() }).from(table).$dynamic();
|
|
2682
2811
|
const allConditions = [];
|
|
2812
|
+
if (hop) allConditions.push(this.buildRelationScope(hop));
|
|
2683
2813
|
if (options.searchString) {
|
|
2684
2814
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
2685
2815
|
if (searchConditions.length === 0) return 0;
|
|
2686
2816
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
2687
2817
|
}
|
|
2688
2818
|
if (options.filter) {
|
|
2689
|
-
const filterConditions = this.buildFilterConditions(options.filter, table,
|
|
2819
|
+
const filterConditions = this.buildFilterConditions(options.filter, table, effectivePath);
|
|
2690
2820
|
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
2691
2821
|
}
|
|
2692
2822
|
if (allConditions.length > 0) {
|
|
@@ -2697,27 +2827,6 @@ var FetchService = class {
|
|
|
2697
2827
|
return Number(result[0]?.count || 0);
|
|
2698
2828
|
}
|
|
2699
2829
|
/**
|
|
2700
|
-
* Count rows from multi-segment path
|
|
2701
|
-
*/
|
|
2702
|
-
async countEntitiesFromPath(path, options = {}) {
|
|
2703
|
-
const pathSegments = path.split("/").filter((p) => p && p !== "undefined");
|
|
2704
|
-
if (pathSegments.length < 3 || pathSegments.length % 2 === 0) throw new Error(`Invalid relation path: ${path}`);
|
|
2705
|
-
const rootCollectionPath = pathSegments[0];
|
|
2706
|
-
let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
|
|
2707
|
-
let currentId = pathSegments[1];
|
|
2708
|
-
for (let i = 2; i < pathSegments.length; i += 2) {
|
|
2709
|
-
const relationKey = pathSegments[i];
|
|
2710
|
-
const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
|
|
2711
|
-
if (!relation) throw new Error(`Relation '${relationKey}' not found`);
|
|
2712
|
-
if (i === pathSegments.length - 1) return this.relationService.countRelatedEntities(currentCollection.slug, currentId, relationKey, options);
|
|
2713
|
-
if (i + 1 < pathSegments.length) {
|
|
2714
|
-
currentCollection = relation.target();
|
|
2715
|
-
currentId = pathSegments[i + 1];
|
|
2716
|
-
}
|
|
2717
|
-
}
|
|
2718
|
-
throw new Error(`Unable to count for path: ${path}`);
|
|
2719
|
-
}
|
|
2720
|
-
/**
|
|
2721
2830
|
* Check if a field value is unique
|
|
2722
2831
|
*/
|
|
2723
2832
|
async checkUniqueField(collectionPath, fieldName, value, excludeEntityId, _databaseId) {
|
|
@@ -2749,6 +2858,14 @@ var FetchService = class {
|
|
|
2749
2858
|
* @param include - Array of relation keys to populate, or ["*"] for all
|
|
2750
2859
|
*/
|
|
2751
2860
|
async fetchCollectionForRest(collectionPath, options = {}, include) {
|
|
2861
|
+
if (isNestedPath(collectionPath)) {
|
|
2862
|
+
const hop = resolveNestedPath(collectionPath, this.registry);
|
|
2863
|
+
if (hop) return this.fetchCollectionForRest(hop.targetCollection.slug, {
|
|
2864
|
+
...options,
|
|
2865
|
+
relatedTo: hop
|
|
2866
|
+
}, include);
|
|
2867
|
+
}
|
|
2868
|
+
const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : void 0;
|
|
2752
2869
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
2753
2870
|
const table = getTableForCollection(collection, this.registry);
|
|
2754
2871
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
@@ -2758,13 +2875,13 @@ var FetchService = class {
|
|
|
2758
2875
|
const qb = this.getQueryBuilder(tableName);
|
|
2759
2876
|
if (qb && !options.searchString && !options.vectorSearch) try {
|
|
2760
2877
|
const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
|
|
2761
|
-
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
|
|
2878
|
+
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition);
|
|
2762
2879
|
const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
|
|
2763
2880
|
await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
|
|
2764
2881
|
return restRows;
|
|
2765
2882
|
} catch (e) {
|
|
2766
2883
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
2767
|
-
logger.error(`[FetchService]
|
|
2884
|
+
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
2768
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.");
|
|
2769
2886
|
}
|
|
2770
2887
|
logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
|
|
@@ -2806,6 +2923,7 @@ var FetchService = class {
|
|
|
2806
2923
|
* Fetch a single row with optional relation includes for REST API.
|
|
2807
2924
|
*/
|
|
2808
2925
|
async fetchOneForRest(collectionPath, id, include, databaseId) {
|
|
2926
|
+
if (!await this.isAddressableUnder(collectionPath, id)) return null;
|
|
2809
2927
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
2810
2928
|
const table = getTableForCollection(collection, this.registry);
|
|
2811
2929
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
@@ -2826,7 +2944,7 @@ var FetchService = class {
|
|
|
2826
2944
|
return restRow;
|
|
2827
2945
|
} catch (e) {
|
|
2828
2946
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
2829
|
-
logger.error(`[FetchService]
|
|
2947
|
+
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
2830
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.");
|
|
2831
2949
|
}
|
|
2832
2950
|
logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
|
|
@@ -2874,6 +2992,7 @@ var FetchService = class {
|
|
|
2874
2992
|
_distance: vectorMeta.distanceSelect
|
|
2875
2993
|
}).from(table).$dynamic() : this.db.select().from(table).$dynamic();
|
|
2876
2994
|
const allConditions = [];
|
|
2995
|
+
if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
|
|
2877
2996
|
if (options.searchString) {
|
|
2878
2997
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
2879
2998
|
if (searchConditions.length === 0) return [];
|
|
@@ -2973,7 +3092,7 @@ var FetchService = class {
|
|
|
2973
3092
|
if (parentIds.length === 0) return /* @__PURE__ */ new Map();
|
|
2974
3093
|
const relation = resolveCollectionRelations(getCollectionByPath(parentCollectionPath, this.registry))[relationKey];
|
|
2975
3094
|
if (!relation) {
|
|
2976
|
-
logger.warn(`[batchFetchManyRelatedRows]
|
|
3095
|
+
logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
|
|
2977
3096
|
return /* @__PURE__ */ new Map();
|
|
2978
3097
|
}
|
|
2979
3098
|
const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, relationKey, relation);
|
|
@@ -3193,6 +3312,16 @@ var PersistService = class {
|
|
|
3193
3312
|
* Delete an row by ID
|
|
3194
3313
|
*/
|
|
3195
3314
|
async delete(collectionPath, id, _databaseId) {
|
|
3315
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
|
|
3316
|
+
if (hop) {
|
|
3317
|
+
assertWritableThrough(hop, collectionPath);
|
|
3318
|
+
if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
|
|
3319
|
+
if (isJunctionBackedRelation(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");
|
|
3321
|
+
await this.relationService.unlinkRelatedEntity(this.db, hop, id);
|
|
3322
|
+
return;
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3196
3325
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
3197
3326
|
const table = getTableForCollection(collection, this.registry);
|
|
3198
3327
|
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
@@ -3213,6 +3342,26 @@ var PersistService = class {
|
|
|
3213
3342
|
await this.db.delete(table);
|
|
3214
3343
|
}
|
|
3215
3344
|
/**
|
|
3345
|
+
* The column on the *target* table that records the parent, for a create
|
|
3346
|
+
* under a nested one-to-many path.
|
|
3347
|
+
*
|
|
3348
|
+
* Returns `undefined` when the link is not a column at all (a multi-hop
|
|
3349
|
+
* `joinPath`), so the caller writes the row without stamping anything.
|
|
3350
|
+
*
|
|
3351
|
+
* `relation.localKey` is deliberately not consulted: it names a column on
|
|
3352
|
+
* the *source* table. Falling back to it here — which is what this used to
|
|
3353
|
+
* do, and first — stamped the parent's own foreign key onto the child row.
|
|
3354
|
+
*/
|
|
3355
|
+
resolveParentForeignKeyColumn(hop) {
|
|
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;
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
/**
|
|
3216
3365
|
* Save an row (create or update)
|
|
3217
3366
|
*
|
|
3218
3367
|
* With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
|
|
@@ -3225,60 +3374,29 @@ var PersistService = class {
|
|
|
3225
3374
|
let effectiveCollectionPath = collectionPath;
|
|
3226
3375
|
const effectiveValues = { ...values };
|
|
3227
3376
|
let junctionTableInfo;
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
parentId: parsedParentId,
|
|
3252
|
-
relation,
|
|
3253
|
-
relationKey
|
|
3254
|
-
};
|
|
3255
|
-
break;
|
|
3256
|
-
}
|
|
3257
|
-
let targetColumnName;
|
|
3258
|
-
if (relation.localKey) targetColumnName = relation.localKey;
|
|
3259
|
-
else if (relation.foreignKeyOnTarget) targetColumnName = relation.foreignKeyOnTarget;
|
|
3260
|
-
else if (relation.joinPath && relation.joinPath.length === 1) {
|
|
3261
|
-
const targetTableName = getTableName$1(targetCollection);
|
|
3262
|
-
const relevantJoinStep = relation.joinPath.find((joinStep) => joinStep.table === targetTableName);
|
|
3263
|
-
if (relevantJoinStep) targetColumnName = DrizzleConditionBuilder.getColumnNamesFromColumns(relevantJoinStep.on.to)[0];
|
|
3264
|
-
else {
|
|
3265
|
-
logger.warn(`Could not find specific join step for target table ${targetTableName} in relation '${relationKey}'.`);
|
|
3266
|
-
targetColumnName = DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0];
|
|
3267
|
-
}
|
|
3268
|
-
} else if (relation.joinPath && relation.joinPath.length > 1) break;
|
|
3269
|
-
else throw new Error(`Relation '${relationKey}' lacks configuration for path-based saving.`);
|
|
3270
|
-
const parentIdInfoArray = getPrimaryKeys(currentCollection, this.registry);
|
|
3271
|
-
const parentIdInfo = parentIdInfoArray[0];
|
|
3272
|
-
const parsedParentId = parseIdValues(currentId, parentIdInfoArray)[parentIdInfo.fieldName];
|
|
3273
|
-
const existingValue = effectiveValues[targetColumnName];
|
|
3274
|
-
if (existingValue !== void 0 && existingValue !== null && existingValue !== parsedParentId) logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
|
|
3275
|
-
effectiveValues[targetColumnName] = parsedParentId;
|
|
3276
|
-
break;
|
|
3277
|
-
} else {
|
|
3278
|
-
const nextEntityId = segments[i + 1];
|
|
3279
|
-
currentCollection = relation.target();
|
|
3280
|
-
currentId = nextEntityId;
|
|
3281
|
-
}
|
|
3377
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
|
|
3378
|
+
if (hop) {
|
|
3379
|
+
assertWritableThrough(hop, collectionPath);
|
|
3380
|
+
effectiveCollectionPath = hop.targetCollection.slug;
|
|
3381
|
+
const parentIdForWrite = () => {
|
|
3382
|
+
const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
|
|
3383
|
+
return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
3384
|
+
};
|
|
3385
|
+
if (isManyToMany(hop.relation)) junctionTableInfo = {
|
|
3386
|
+
parentCollection: hop.parentCollection,
|
|
3387
|
+
parentId: parentIdForWrite(),
|
|
3388
|
+
relation: hop.relation,
|
|
3389
|
+
relationKey: hop.relationKey
|
|
3390
|
+
};
|
|
3391
|
+
else if (id !== void 0) {
|
|
3392
|
+
if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to update.`);
|
|
3393
|
+
} else {
|
|
3394
|
+
const targetColumnName = this.resolveParentForeignKeyColumn(hop);
|
|
3395
|
+
if (targetColumnName) {
|
|
3396
|
+
const parsedParentId = parentIdForWrite();
|
|
3397
|
+
const existingValue = effectiveValues[targetColumnName];
|
|
3398
|
+
if (existingValue !== void 0 && existingValue !== null && existingValue !== parsedParentId) logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
|
|
3399
|
+
effectiveValues[targetColumnName] = parsedParentId;
|
|
3282
3400
|
}
|
|
3283
3401
|
}
|
|
3284
3402
|
}
|
|
@@ -3349,7 +3467,7 @@ var PersistService = class {
|
|
|
3349
3467
|
}
|
|
3350
3468
|
if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
|
|
3351
3469
|
if (Object.keys(relationValues).length > 0) await this.relationService.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
|
|
3352
|
-
if (junctionTableInfo
|
|
3470
|
+
if (junctionTableInfo) await this.relationService.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
|
|
3353
3471
|
return currentId;
|
|
3354
3472
|
});
|
|
3355
3473
|
} catch (error) {
|
|
@@ -5246,9 +5364,9 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
5246
5364
|
if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}("${colName}")`;
|
|
5247
5365
|
else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid("${colName}")`;
|
|
5248
5366
|
else if (stringProp.columnType === "uuid") columnDefinition = `uuid("${colName}")`;
|
|
5249
|
-
else if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) columnDefinition = `text("${colName}")`;
|
|
5250
5367
|
else if (stringProp.columnType === "char") columnDefinition = `char("${colName}")`;
|
|
5251
|
-
else columnDefinition = `varchar("${colName}")`;
|
|
5368
|
+
else if (stringProp.columnType === "varchar") columnDefinition = `varchar("${colName}")`;
|
|
5369
|
+
else columnDefinition = `text("${colName}")`;
|
|
5252
5370
|
if (isIdProperty(propName, prop, collection)) columnDefinition += ".primaryKey()";
|
|
5253
5371
|
if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
|
|
5254
5372
|
if (stringProp.isId === "uuid") columnDefinition += ".defaultRandom()";
|
|
@@ -5317,12 +5435,8 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
5317
5435
|
break;
|
|
5318
5436
|
case "relation": {
|
|
5319
5437
|
const refProp = prop;
|
|
5320
|
-
const relation = findRelation(resolveCollectionRelations(collection), refProp.relationName ?? propName);
|
|
5321
|
-
if (!relation || relation.
|
|
5322
|
-
if (!relation.localKey) {
|
|
5323
|
-
logger.warn(`Could not generate column for owning relation '${relation.relationName}' on '${collection.name}': 'localKey' is not defined.`);
|
|
5324
|
-
return null;
|
|
5325
|
-
}
|
|
5438
|
+
const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
|
|
5439
|
+
if (!relation || relation.kind !== "belongsTo") return null;
|
|
5326
5440
|
if (collection.properties[relation.localKey] && propName !== relation.localKey) return null;
|
|
5327
5441
|
let targetCollection;
|
|
5328
5442
|
try {
|
|
@@ -5334,7 +5448,7 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
5334
5448
|
const targetTableVar = getTableVarName(getTableName$1(targetCollection));
|
|
5335
5449
|
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
5336
5450
|
const targetIdField = pkProp.name;
|
|
5337
|
-
const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : pkProp.isUuid ? `uuid("${fkColumnName}")` : `
|
|
5451
|
+
const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : pkProp.isUuid ? `uuid("${fkColumnName}")` : `text("${fkColumnName}")`;
|
|
5338
5452
|
const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
|
|
5339
5453
|
const required = prop.validation?.required;
|
|
5340
5454
|
const refOptionsParts = [onUpdate, `onDelete: \"${relation.onDelete ?? (required ? "cascade" : "set null")}\"`].filter(Boolean);
|
|
@@ -5347,13 +5461,13 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
5347
5461
|
const refProp = prop;
|
|
5348
5462
|
const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName$1(c) === refProp.path);
|
|
5349
5463
|
if (!targetCollection) {
|
|
5350
|
-
columnDefinition = `
|
|
5464
|
+
columnDefinition = `text("${colName}")`;
|
|
5351
5465
|
break;
|
|
5352
5466
|
}
|
|
5353
5467
|
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
5354
5468
|
const targetTableVar = getTableVarName(getTableName$1(targetCollection));
|
|
5355
5469
|
const targetIdField = pkProp.name;
|
|
5356
|
-
const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : pkProp.isUuid ? `uuid("${colName}")` : `
|
|
5470
|
+
const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : pkProp.isUuid ? `uuid("${colName}")` : `text("${colName}")`;
|
|
5357
5471
|
const required = prop.validation?.required;
|
|
5358
5472
|
columnDefinition = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}, ${`{ onDelete: "${required ? "cascade" : "set null"}" }`})`;
|
|
5359
5473
|
if (required) columnDefinition += ".notNull()";
|
|
@@ -5416,33 +5530,17 @@ var generateSinglePolicyCode = (collection, rule, operation, policyName, resolve
|
|
|
5416
5530
|
*/
|
|
5417
5531
|
var computeSharedRelationName = (rel, sourceCollection, _collections) => {
|
|
5418
5532
|
const fallback = rel.relationName ?? toSnakeCase(rel.target().slug);
|
|
5419
|
-
if (rel.
|
|
5533
|
+
if (rel.kind === "belongsTo") {
|
|
5420
5534
|
const normalisedKey = resolvePropertyKeyForColumn(sourceCollection, rel.localKey);
|
|
5421
5535
|
return `${getTableName$1(sourceCollection)}_${normalisedKey}`;
|
|
5422
5536
|
}
|
|
5423
|
-
if (rel.
|
|
5537
|
+
if (rel.kind === "hasMany" || rel.kind === "hasOne") try {
|
|
5424
5538
|
const targetCollection = rel.target();
|
|
5425
5539
|
const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
|
|
5426
5540
|
return `${getTableName$1(targetCollection)}_${normalisedFK}`;
|
|
5427
5541
|
} catch {
|
|
5428
5542
|
return fallback;
|
|
5429
5543
|
}
|
|
5430
|
-
if (rel.direction === "inverse" && rel.cardinality === "one") {
|
|
5431
|
-
if (rel.foreignKeyOnTarget) try {
|
|
5432
|
-
const targetCollection = rel.target();
|
|
5433
|
-
const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
|
|
5434
|
-
return `${getTableName$1(targetCollection)}_${normalisedFK}`;
|
|
5435
|
-
} catch {
|
|
5436
|
-
return fallback;
|
|
5437
|
-
}
|
|
5438
|
-
try {
|
|
5439
|
-
const targetCollection = rel.target();
|
|
5440
|
-
const targetResolvedRelations = resolveCollectionRelations(targetCollection);
|
|
5441
|
-
const correspondingRelation = Object.values(targetResolvedRelations).find((targetRel) => targetRel.direction === "owning" && targetRel.cardinality === "one" && targetRel.localKey && targetRel.target().slug === sourceCollection.slug);
|
|
5442
|
-
if (correspondingRelation && correspondingRelation.localKey) return `${getTableName$1(targetCollection)}_${correspondingRelation.localKey}`;
|
|
5443
|
-
} catch {}
|
|
5444
|
-
return fallback;
|
|
5445
|
-
}
|
|
5446
5544
|
return fallback;
|
|
5447
5545
|
};
|
|
5448
5546
|
var generateSchema = async (collections, stripPolicies = false) => {
|
|
@@ -5507,7 +5605,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
5507
5605
|
const tableName = getTableName$1(collection);
|
|
5508
5606
|
if (tableName) allTablesToGenerate.set(tableName, { collection });
|
|
5509
5607
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
5510
|
-
for (const relation of Object.values(resolvedRelations)) if (relation
|
|
5608
|
+
for (const relation of Object.values(resolvedRelations)) if (isManyToMany(relation)) {
|
|
5511
5609
|
const junctionTableName = relation.through.table;
|
|
5512
5610
|
if (!allTablesToGenerate.has(junctionTableName)) allTablesToGenerate.set(junctionTableName, {
|
|
5513
5611
|
collection: {
|
|
@@ -5522,15 +5620,15 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
5522
5620
|
}
|
|
5523
5621
|
for (const [tableName, { collection, isJunction, relation, sourceCollection }] of allTablesToGenerate.entries()) {
|
|
5524
5622
|
const tableVarName = getTableVarName(tableName);
|
|
5525
|
-
if (isJunction && relation && sourceCollection && relation
|
|
5623
|
+
if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
|
|
5526
5624
|
const targetCollection = relation.target();
|
|
5527
5625
|
const schema = (isPostgresCollectionConfig(targetCollection) ? targetCollection.schema : void 0) || (isPostgresCollectionConfig(sourceCollection) ? sourceCollection.schema : void 0);
|
|
5528
5626
|
const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
|
|
5529
5627
|
const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
|
|
5530
5628
|
const { sourceColumn, targetColumn } = relation.through;
|
|
5531
5629
|
const refOptions = `{ onDelete: \"${relation.onDelete ?? "cascade"}\" }`;
|
|
5532
|
-
const sourceColType = isNumericId(sourceCollection) ? "integer" : getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "
|
|
5533
|
-
const targetColType = isNumericId(targetCollection) ? "integer" : getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "
|
|
5630
|
+
const sourceColType = isNumericId(sourceCollection) ? "integer" : getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "text";
|
|
5631
|
+
const targetColType = isNumericId(targetCollection) ? "integer" : getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "text";
|
|
5534
5632
|
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
5535
5633
|
const targetId = getPrimaryKeyName(targetCollection);
|
|
5536
5634
|
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
@@ -5557,7 +5655,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
5557
5655
|
const columnString = getDrizzleColumn(propName, prop, collection, collections);
|
|
5558
5656
|
if (columnString) columns.add(columnString);
|
|
5559
5657
|
});
|
|
5560
|
-
if (!Array.from(columns).some((col) => col.includes(".primaryKey()"))) columns.add(" id:
|
|
5658
|
+
if (!Array.from(columns).some((col) => col.includes(".primaryKey()"))) columns.add(" id: text(\"id\").primaryKey()");
|
|
5561
5659
|
schemaContent += `${Array.from(columns).join(",\n")}`;
|
|
5562
5660
|
const securityRules = getEffectiveSecurityRules(collection);
|
|
5563
5661
|
if (!stripPolicies && securityRules.length > 0) {
|
|
@@ -5576,7 +5674,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
5576
5674
|
const tableRelations = [];
|
|
5577
5675
|
if (isJunction) {
|
|
5578
5676
|
const relationInfo = Array.from(allTablesToGenerate.values()).find((v) => v.isJunction && getTableName$1(v.collection) === tableName);
|
|
5579
|
-
if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && relationInfo.relation
|
|
5677
|
+
if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && isManyToMany(relationInfo.relation)) {
|
|
5580
5678
|
const { relation, sourceCollection } = relationInfo;
|
|
5581
5679
|
const targetCollection = relation.target();
|
|
5582
5680
|
const sourceTableVar = getTableVarName(getTableName$1(sourceCollection));
|
|
@@ -5588,7 +5686,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
5588
5686
|
let inverseRelationName = null;
|
|
5589
5687
|
try {
|
|
5590
5688
|
const targetRelations = resolveCollectionRelations(targetCollection);
|
|
5591
|
-
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) {
|
|
5592
5690
|
inverseRelationName = targetRel.relationName ?? null;
|
|
5593
5691
|
break;
|
|
5594
5692
|
}
|
|
@@ -5604,27 +5702,25 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
5604
5702
|
const target = rel.target();
|
|
5605
5703
|
const targetTableVar = getTableVarName(getTableName$1(target));
|
|
5606
5704
|
const drizzleRelationName = computeSharedRelationName(rel, collection, collections);
|
|
5607
|
-
const deduplicationKey = `${drizzleRelationName}::${rel.
|
|
5705
|
+
const deduplicationKey = `${drizzleRelationName}::${rel.kind}`;
|
|
5608
5706
|
if (emittedRelationNames.has(deduplicationKey)) continue;
|
|
5609
5707
|
emittedRelationNames.add(deduplicationKey);
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
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": {
|
|
5616
5719
|
const junctionTableVar = getTableVarName(rel.through.table);
|
|
5617
5720
|
tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
|
|
5618
|
-
|
|
5619
|
-
const targetResolvedRelations = resolveCollectionRelations(rel.target());
|
|
5620
|
-
const correspondingRelation = Object.values(targetResolvedRelations).find((targetRel) => targetRel.direction === "owning" && targetRel.cardinality === "many" && targetRel.through && targetRel.relationName === rel.inverseRelationName);
|
|
5621
|
-
if (correspondingRelation && correspondingRelation.through) {
|
|
5622
|
-
const junctionTableVar = getTableVarName(correspondingRelation.through.table);
|
|
5623
|
-
tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
|
|
5624
|
-
} else logger.warn(`Could not find corresponding owning many-to-many relation for inverse relation '${relationKey}' on '${collection.name}'`);
|
|
5625
|
-
} catch (e) {
|
|
5626
|
-
logger.warn(`Could not resolve inverse many-to-many relation '${relationKey}'`, { error: e });
|
|
5721
|
+
break;
|
|
5627
5722
|
}
|
|
5723
|
+
case "via": break;
|
|
5628
5724
|
}
|
|
5629
5725
|
} catch (e) {
|
|
5630
5726
|
logger.warn(`Could not generate relation ${relationKey} for ${collection.name}`, { error: e });
|
|
@@ -5632,10 +5728,10 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
5632
5728
|
for (const otherCollection of collections) {
|
|
5633
5729
|
if (otherCollection.slug === collection.slug) continue;
|
|
5634
5730
|
const otherRelations = resolveCollectionRelations(otherCollection);
|
|
5635
|
-
for (const [otherKey, otherRel] of Object.entries(otherRelations)) if (otherRel
|
|
5731
|
+
for (const [otherKey, otherRel] of Object.entries(otherRelations)) if (hasForeignKeyOnTarget(otherRel)) try {
|
|
5636
5732
|
if (otherRel.target().slug === collection.slug) {
|
|
5637
5733
|
const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
|
|
5638
|
-
const deduplicationKey = `${drizzleRelationName}::
|
|
5734
|
+
const deduplicationKey = `${drizzleRelationName}::belongsTo`;
|
|
5639
5735
|
if (!emittedRelationNames.has(deduplicationKey)) {
|
|
5640
5736
|
const otherTableVar = getTableVarName(getTableName$1(otherCollection));
|
|
5641
5737
|
const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
|
|
@@ -5737,6 +5833,58 @@ var main = () => {
|
|
|
5737
5833
|
};
|
|
5738
5834
|
if (import.meta.url.endsWith(process.argv[1])) main();
|
|
5739
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
|
|
5740
5888
|
//#region src/services/cdc/trigger-cdc.ts
|
|
5741
5889
|
/**
|
|
5742
5890
|
* Trigger-based Change Data Capture (CDC).
|
|
@@ -6861,6 +7009,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
6861
7009
|
cdcListener;
|
|
6862
7010
|
/** Whether database-level CDC is the active cross-instance change source. */
|
|
6863
7011
|
cdcActive = false;
|
|
7012
|
+
/** Junction table → the child lists its rows belong to, built when CDC starts. */
|
|
7013
|
+
junctionLinkMap;
|
|
6864
7014
|
/** Reverse lookup: `schema.table` (and bare `table`) → collection, built when CDC starts. */
|
|
6865
7015
|
cdcTableMap;
|
|
6866
7016
|
/**
|
|
@@ -7977,6 +8127,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7977
8127
|
return;
|
|
7978
8128
|
}
|
|
7979
8129
|
this.cdcTableMap = this.buildCdcTableMap();
|
|
8130
|
+
this.junctionLinkMap = buildJunctionLinkMap(this.registry);
|
|
7980
8131
|
this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event));
|
|
7981
8132
|
try {
|
|
7982
8133
|
await this.cdcListener.start();
|
|
@@ -7984,6 +8135,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7984
8135
|
await this.cdcListener.stop().catch(() => {});
|
|
7985
8136
|
this.cdcListener = void 0;
|
|
7986
8137
|
this.cdcTableMap = void 0;
|
|
8138
|
+
this.junctionLinkMap = void 0;
|
|
7987
8139
|
throw err;
|
|
7988
8140
|
}
|
|
7989
8141
|
this.cdcActive = true;
|
|
@@ -7997,6 +8149,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7997
8149
|
this.cdcListener = void 0;
|
|
7998
8150
|
}
|
|
7999
8151
|
this.cdcTableMap = void 0;
|
|
8152
|
+
this.junctionLinkMap = void 0;
|
|
8000
8153
|
this.recentAppEmits.clear();
|
|
8001
8154
|
}
|
|
8002
8155
|
/**
|
|
@@ -8033,6 +8186,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8033
8186
|
async handleCdcEvent(event) {
|
|
8034
8187
|
const collection = this.resolveCollectionForTable(event.schema, event.table);
|
|
8035
8188
|
if (!collection) {
|
|
8189
|
+
if (await this.handleJunctionCdcEvent(event)) return;
|
|
8036
8190
|
this.debugLog(`📡 [CDC] Ignoring change on unmapped table ${event.schema}.${event.table}`);
|
|
8037
8191
|
return;
|
|
8038
8192
|
}
|
|
@@ -8042,6 +8196,38 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8042
8196
|
const row = event.op === "DELETE" ? null : { _rebase_invalidated: true };
|
|
8043
8197
|
await this.notifyUpdate(path, id, row, databaseId, false, "cdc");
|
|
8044
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
|
+
}
|
|
8045
8231
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
8046
8232
|
extractIdFromCdcRow(collection, row) {
|
|
8047
8233
|
return deriveRowAddress(row, collection, this.registry) || "*";
|
|
@@ -8761,8 +8947,8 @@ var PostgresCollectionRegistry = class extends CollectionRegistry {
|
|
|
8761
8947
|
*/
|
|
8762
8948
|
getRelationKeysForCollection(collectionPath) {
|
|
8763
8949
|
const collection = this.getCollectionByPath(collectionPath);
|
|
8764
|
-
if (!collection
|
|
8765
|
-
return
|
|
8950
|
+
if (!collection) return [];
|
|
8951
|
+
return Object.keys(resolveCollectionRelations(collection));
|
|
8766
8952
|
}
|
|
8767
8953
|
};
|
|
8768
8954
|
//#endregion
|
|
@@ -17713,6 +17899,178 @@ function formatBytes(bytes) {
|
|
|
17713
17899
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
17714
17900
|
}
|
|
17715
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
|
|
17716
18074
|
//#region src/collections/buildRegistry.ts
|
|
17717
18075
|
/**
|
|
17718
18076
|
* Build the collection registry for a driver.
|
|
@@ -17737,8 +18095,163 @@ function buildCollectionRegistry(schema) {
|
|
|
17737
18095
|
if (schema.enums) registry.registerEnums(schema.enums);
|
|
17738
18096
|
if (schema.relations) registry.registerRelations(schema.relations);
|
|
17739
18097
|
warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
|
|
18098
|
+
assertRelationsResolve(registry.getCollections(), registry);
|
|
17740
18099
|
return registry;
|
|
17741
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
|
+
}
|
|
17742
18255
|
//#endregion
|
|
17743
18256
|
//#region src/auth/ensure-tables.ts
|
|
17744
18257
|
/**
|
|
@@ -17750,6 +18263,7 @@ function buildCollectionRegistry(schema) {
|
|
|
17750
18263
|
*/
|
|
17751
18264
|
async function ensureAuthTablesExist(db, collection) {
|
|
17752
18265
|
logger.info("🔍 Checking auth tables...");
|
|
18266
|
+
await assertAuthSchemaCompatible(db, resolveAuthSchema(collection));
|
|
17753
18267
|
try {
|
|
17754
18268
|
let usersTableName = "\"rebase\".\"users\"";
|
|
17755
18269
|
let userIdType = "TEXT";
|
|
@@ -18130,8 +18644,10 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
18130
18644
|
} catch (rlsReconcileError) {
|
|
18131
18645
|
logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
|
|
18132
18646
|
}
|
|
18647
|
+
await stampAuthSchemaVersion(db, authSchema);
|
|
18133
18648
|
logger.info("✅ Auth tables ready");
|
|
18134
18649
|
} catch (error) {
|
|
18650
|
+
if (error instanceof AuthSchemaVersionError) throw error;
|
|
18135
18651
|
logger.error("❌ Failed to create auth tables", { error });
|
|
18136
18652
|
logger.warn("⚠️ Continuing without creating auth tables.");
|
|
18137
18653
|
}
|
|
@@ -20026,7 +20542,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
20026
20542
|
} catch {}
|
|
20027
20543
|
const message = `
|
|
20028
20544
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
20029
|
-
❌ 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`;
|
|
20030
20546
|
logger.error(message);
|
|
20031
20547
|
throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
|
|
20032
20548
|
}
|
|
@@ -20111,10 +20627,15 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
20111
20627
|
const cdcRunSql = async (text) => {
|
|
20112
20628
|
return (await schemaAwareDb.execute(sql.raw(text))).rows ?? [];
|
|
20113
20629
|
};
|
|
20114
|
-
|
|
20630
|
+
const cdcTables = registry.getCollections().map((c) => ({
|
|
20115
20631
|
schema: c.schema ?? "public",
|
|
20116
20632
|
table: getTableName$1(c)
|
|
20117
|
-
})).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);
|
|
20118
20639
|
await realtimeService.enableCdc(directUrl);
|
|
20119
20640
|
cdcEnabled = true;
|
|
20120
20641
|
provisionCdcForTables = async (tables) => {
|
|
@@ -20224,7 +20745,8 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
20224
20745
|
userService,
|
|
20225
20746
|
roleService: userService,
|
|
20226
20747
|
emailService,
|
|
20227
|
-
authRepository
|
|
20748
|
+
authRepository,
|
|
20749
|
+
schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
|
|
20228
20750
|
};
|
|
20229
20751
|
},
|
|
20230
20752
|
async initializeHistory(config, driverResult) {
|
|
@@ -20252,7 +20774,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
20252
20774
|
*/
|
|
20253
20775
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
20254
20776
|
const internals = driverResult.internals;
|
|
20255
|
-
const { ensureCollectionTables } = await import("./ensure-collection-tables-
|
|
20777
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-DGMYK0fr.js");
|
|
20256
20778
|
return { applied: (await ensureCollectionTables({ async query(text) {
|
|
20257
20779
|
const result = await internals.db.execute(sql.raw(text));
|
|
20258
20780
|
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|