@rebasepro/server-postgres 0.10.1-canary.8d27c46 → 0.10.1-canary.af2af91
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/{ensure-collection-tables-C9gy4STB.js → ensure-collection-tables-BVvtkkRm.js} +2 -2
- package/dist/{ensure-collection-tables-C9gy4STB.js.map → ensure-collection-tables-BVvtkkRm.js.map} +1 -1
- package/dist/index.es.js +437 -113
- 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 +30 -0
- 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/src-CBgtrPhJ.js.map +1 -1
- package/dist/{src-DG6ZsQQ3.js → src-lcfUP4xg.js} +122 -42
- package/dist/src-lcfUP4xg.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +41 -0
- package/package.json +6 -6
- package/src/PostgresBootstrapper.ts +10 -1
- package/src/services/FetchService.ts +104 -114
- package/src/services/PersistService.ts +127 -85
- package/src/services/RelationService.ts +95 -6
- package/src/services/cdc/junction-tables.ts +91 -0
- package/src/services/nested-path.ts +130 -0
- package/src/services/realtimeService.ts +60 -0
- package/src/utils/drizzle-conditions.ts +154 -0
- package/dist/src-DG6ZsQQ3.js.map +0 -1
package/dist/index.es.js
CHANGED
|
@@ -3,7 +3,7 @@ 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
5
|
import { a as isSchemaAdmin, i as isSQLAdmin, l as isPostgresCollectionConfig, m as Vector, n as getDataSourceCapabilities, o as ANONYMOUS_USER_ID, r as isChannelBusInstance } from "./src-CBgtrPhJ.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-
|
|
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-lcfUP4xg.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,89 @@ 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
|
+
if (relation.joinPath && relation.joinPath.length > 0) {
|
|
417
|
+
const { table, idColumn } = parent();
|
|
418
|
+
return this.buildJoinPathScopeCondition(relation.joinPath, table, idColumn, parentId, targetTable, registry);
|
|
419
|
+
}
|
|
420
|
+
if (relation.through) {
|
|
421
|
+
const junctionTable = registry.getTable(relation.through.table);
|
|
422
|
+
if (!junctionTable) throw new Error(`Junction table not found: ${relation.through.table}`);
|
|
423
|
+
const sourceCol = junctionTable[relation.through.sourceColumn];
|
|
424
|
+
const targetCol = junctionTable[relation.through.targetColumn];
|
|
425
|
+
if (!sourceCol || !targetCol) throw new Error(`Junction columns '${relation.through.sourceColumn}'/'${relation.through.targetColumn}' not found in '${relation.through.table}'`);
|
|
426
|
+
return sql`EXISTS (SELECT 1 FROM ${junctionTable} WHERE ${targetCol} = ${targetIdColumn} AND ${sourceCol} = ${parentId})`;
|
|
427
|
+
}
|
|
428
|
+
if (relation.foreignKeyOnTarget) {
|
|
429
|
+
const fkColumn = targetTable[relation.foreignKeyOnTarget];
|
|
430
|
+
if (!fkColumn) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'. A many-to-many relation needs \`through\` instead.`);
|
|
431
|
+
return eq(fkColumn, parentId);
|
|
432
|
+
}
|
|
433
|
+
if (relation.localKey) {
|
|
434
|
+
const { table, idColumn } = parent();
|
|
435
|
+
return sql`${targetIdColumn} = (SELECT ${sql.identifier(relation.localKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
|
|
436
|
+
}
|
|
437
|
+
throw new Error(`Relation '${relation.relationName}' declares no \`foreignKeyOnTarget\`, \`through\`, \`joinPath\` or \`localKey\`, so there is no way to tell which target rows belong to a parent.`);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* `EXISTS` for an explicit `joinPath`.
|
|
441
|
+
*
|
|
442
|
+
* The path is declared source → target. The subquery replays every step but
|
|
443
|
+
* the last from inside, and turns the last one into the correlation with the
|
|
444
|
+
* outer target row — so the target table is never named twice and needs no
|
|
445
|
+
* alias. Each intermediate table is aliased positionally, which keeps a path
|
|
446
|
+
* that revisits a table (a self-referencing many-to-many) unambiguous.
|
|
447
|
+
*/
|
|
448
|
+
static buildJoinPathScopeCondition(joinPath, parentTable, parentIdColumn, parentId, targetTable, registry) {
|
|
449
|
+
const sourceAlias = "__rel_src";
|
|
450
|
+
const aliasFor = (index) => `__rel_j${index}`;
|
|
451
|
+
const fromRef = (stepIndex, column) => sql`${sql.identifier(stepIndex === 0 ? sourceAlias : aliasFor(stepIndex - 1))}.${sql.identifier(getColumnName(column))}`;
|
|
452
|
+
const pairs = (step) => {
|
|
453
|
+
const from = Array.isArray(step.on.from) ? step.on.from : [step.on.from];
|
|
454
|
+
const to = Array.isArray(step.on.to) ? step.on.to : [step.on.to];
|
|
455
|
+
if (from.length !== to.length) throw new Error(`Join step on '${step.table}' has ${from.length} \`from\` columns and ${to.length} \`to\` columns`);
|
|
456
|
+
return from.map((f, i) => ({
|
|
457
|
+
from: f,
|
|
458
|
+
to: to[i]
|
|
459
|
+
}));
|
|
460
|
+
};
|
|
461
|
+
const inner = joinPath.slice(0, -1);
|
|
462
|
+
const last = joinPath[joinPath.length - 1];
|
|
463
|
+
const joins = inner.map((step, index) => {
|
|
464
|
+
const table = registry.getTable(step.table);
|
|
465
|
+
if (!table) throw new Error(`Join table not found: ${step.table}`);
|
|
466
|
+
const on = pairs(step).map(({ from, to }) => sql`${fromRef(index, from)} = ${sql.identifier(aliasFor(index))}.${sql.identifier(getColumnName(to))}`);
|
|
467
|
+
return sql`JOIN ${table} AS ${sql.identifier(aliasFor(index))} ON ${sql.join(on, sql` AND `)}`;
|
|
468
|
+
});
|
|
469
|
+
const targetColumn = (name) => {
|
|
470
|
+
const column = targetTable[getColumnName(name)];
|
|
471
|
+
if (!column) throw new Error(`Join step column '${name}' not found in the target table of this joinPath`);
|
|
472
|
+
return column;
|
|
473
|
+
};
|
|
474
|
+
const correlation = sql.join(pairs(last).map(({ from, to }) => sql`${fromRef(inner.length, from)} = ${targetColumn(to)}`), sql` AND `);
|
|
475
|
+
const joinsSql = joins.length > 0 ? sql` ${sql.join(joins, sql` `)}` : sql``;
|
|
476
|
+
return sql`EXISTS (SELECT 1 FROM ${parentTable} AS ${sql.identifier(sourceAlias)}${joinsSql} WHERE ${sql.identifier(sourceAlias)}.${sql.identifier(parentIdColumn.name)} = ${parentId} AND ${correlation})`;
|
|
477
|
+
}
|
|
395
478
|
/**
|
|
396
479
|
* Build filter conditions from FilterValues
|
|
397
480
|
*/
|
|
@@ -1551,6 +1634,18 @@ var RelationService = class {
|
|
|
1551
1634
|
const available = Object.keys(resolvedRelations).join(", ") || "(none)";
|
|
1552
1635
|
throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
|
|
1553
1636
|
}
|
|
1637
|
+
return this.countRelatedRows(parentCollection, parentId, relation, []);
|
|
1638
|
+
}
|
|
1639
|
+
/**
|
|
1640
|
+
* Count the target rows a parent reaches through `relation`, narrowed by
|
|
1641
|
+
* `additionalFilters` (conditions on the target table).
|
|
1642
|
+
*
|
|
1643
|
+
* Shared by the public count and by {@link isRelated}, so "how many children
|
|
1644
|
+
* does this parent have" and "is this row one of them" are answered by the
|
|
1645
|
+
* same join — a membership test that reconstructed the join separately would
|
|
1646
|
+
* be free to disagree with the listing it is supposed to gate.
|
|
1647
|
+
*/
|
|
1648
|
+
async countRelatedRows(parentCollection, parentId, relation, additionalFilters) {
|
|
1554
1649
|
const targetCollection = relation.target();
|
|
1555
1650
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
1556
1651
|
const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
|
|
@@ -1561,11 +1656,54 @@ var RelationService = class {
|
|
|
1561
1656
|
if (!parentTable) throw new Error("Parent table not found");
|
|
1562
1657
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
1563
1658
|
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,
|
|
1659
|
+
query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, parsedParentId, targetTable, parentTable, parentIdCol, targetIdField, this.registry, additionalFilters);
|
|
1565
1660
|
const result = await query;
|
|
1566
1661
|
return Number(result[0]?.count || 0);
|
|
1567
1662
|
}
|
|
1568
1663
|
/**
|
|
1664
|
+
* Whether `targetId` is actually reachable from the parent named in `hop`.
|
|
1665
|
+
*
|
|
1666
|
+
* A nested address like `authors/1/posts/43` used to resolve to the target
|
|
1667
|
+
* collection and then match on the primary key alone, so the parent segment
|
|
1668
|
+
* decided nothing: the row came back, and was updated or deleted, whoever it
|
|
1669
|
+
* belonged to. Reads, updates and deletes now all gate on this.
|
|
1670
|
+
*/
|
|
1671
|
+
async isRelated(hop, targetId) {
|
|
1672
|
+
const targetTable = getTableForCollection(hop.targetCollection, this.registry);
|
|
1673
|
+
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
1674
|
+
const parsedTargetId = parseIdValues(targetId, targetPks);
|
|
1675
|
+
const identity = targetPks.map((pk) => {
|
|
1676
|
+
const column = targetTable[pk.fieldName];
|
|
1677
|
+
if (!column) throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
|
|
1678
|
+
return eq(column, parsedTargetId[pk.fieldName]);
|
|
1679
|
+
});
|
|
1680
|
+
return await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity) > 0;
|
|
1681
|
+
}
|
|
1682
|
+
/**
|
|
1683
|
+
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
1684
|
+
* row itself alone.
|
|
1685
|
+
*
|
|
1686
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
1687
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
1688
|
+
* post that uses it. It used to do exactly that — resolve the path to the
|
|
1689
|
+
* `tags` table and delete by primary key.
|
|
1690
|
+
*/
|
|
1691
|
+
async unlinkRelatedEntity(tx, hop, targetId) {
|
|
1692
|
+
const through = hop.relation.through;
|
|
1693
|
+
if (!through) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
|
|
1694
|
+
const junctionTable = this.registry.getTable(through.table);
|
|
1695
|
+
if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
|
|
1696
|
+
const sourceJunctionColumn = junctionTable[through.sourceColumn];
|
|
1697
|
+
const targetJunctionColumn = junctionTable[through.targetColumn];
|
|
1698
|
+
if (!sourceJunctionColumn || !targetJunctionColumn) throw new Error(`Junction columns not found for relation '${hop.relationKey}' on table '${through.table}'`);
|
|
1699
|
+
const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
|
|
1700
|
+
const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
1701
|
+
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
1702
|
+
const parsedTargetId = parseIdValues(targetId, targetPks)[targetPks[0].fieldName];
|
|
1703
|
+
await tx.delete(junctionTable).where(and(eq(sourceJunctionColumn, parsedParentId), eq(targetJunctionColumn, parsedTargetId)));
|
|
1704
|
+
logger.info(`Unlinked '${hop.relationKey}' ${parsedTargetId} from ${hop.parentCollection.slug} ${parsedParentId}`);
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1569
1707
|
* Batch fetch related rows for multiple parent rows to avoid N+1 queries
|
|
1570
1708
|
*/
|
|
1571
1709
|
async batchFetchRelatedEntities(parentCollectionPath, parentIds, _relationKey, relation) {
|
|
@@ -2102,8 +2240,8 @@ var RelationService = class {
|
|
|
2102
2240
|
[sourceJunctionColumn.name]: parentId,
|
|
2103
2241
|
[targetJunctionColumn.name]: parsedNewEntityId
|
|
2104
2242
|
};
|
|
2105
|
-
await tx.insert(junctionTable).values(junctionData);
|
|
2106
|
-
logger.info(`
|
|
2243
|
+
await tx.insert(junctionTable).values(junctionData).onConflictDoNothing();
|
|
2244
|
+
logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
|
|
2107
2245
|
} catch (error) {
|
|
2108
2246
|
logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error });
|
|
2109
2247
|
throw error;
|
|
@@ -2255,6 +2393,79 @@ function toRestRow(row, collection, registry) {
|
|
|
2255
2393
|
return stripExcluded(flat, collection);
|
|
2256
2394
|
}
|
|
2257
2395
|
//#endregion
|
|
2396
|
+
//#region src/services/nested-path.ts
|
|
2397
|
+
/**
|
|
2398
|
+
* True when `path` addresses rows through a relation rather than a root
|
|
2399
|
+
* collection.
|
|
2400
|
+
*
|
|
2401
|
+
* Any separator at all counts — a root collection slug never contains one — so
|
|
2402
|
+
* a malformed path like `collection/id` is a *broken* nested path and gets
|
|
2403
|
+
* reported as one by {@link resolveNestedPath}, rather than being looked up as
|
|
2404
|
+
* a root collection whose slug happens to contain a slash.
|
|
2405
|
+
*/
|
|
2406
|
+
function isNestedPath(path) {
|
|
2407
|
+
return path.includes("/");
|
|
2408
|
+
}
|
|
2409
|
+
function splitPathSegments(path) {
|
|
2410
|
+
return path.split("/").filter((s) => s && s !== "undefined");
|
|
2411
|
+
}
|
|
2412
|
+
/**
|
|
2413
|
+
* Walk a nested collection path down to the relation it ends in.
|
|
2414
|
+
*
|
|
2415
|
+
* Returns `undefined` for a plain root-collection path so callers can keep the
|
|
2416
|
+
* root case on its existing code path. Throws when the path is malformed, or
|
|
2417
|
+
* when a segment names a relation that does not exist — the same errors the
|
|
2418
|
+
* individual walks used to raise, with the available names attached.
|
|
2419
|
+
*/
|
|
2420
|
+
function resolveNestedPath(path, registry) {
|
|
2421
|
+
if (!isNestedPath(path)) return void 0;
|
|
2422
|
+
const segments = splitPathSegments(path);
|
|
2423
|
+
if (segments.length < 3 || segments.length % 2 === 0) throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
|
|
2424
|
+
let parentCollection = getCollectionByPath(segments[0], registry);
|
|
2425
|
+
let parentId = segments[1];
|
|
2426
|
+
for (let i = 2; i < segments.length; i += 2) {
|
|
2427
|
+
const relationKey = segments[i];
|
|
2428
|
+
const resolvedRelations = resolveCollectionRelations(parentCollection);
|
|
2429
|
+
const relation = findRelation(resolvedRelations, relationKey);
|
|
2430
|
+
if (!relation) {
|
|
2431
|
+
const available = Object.keys(resolvedRelations).join(", ") || "(none)";
|
|
2432
|
+
throw new Error(`Relation '${relationKey}' not found in collection '${parentCollection.slug}'. Available relations: [${available}]`);
|
|
2433
|
+
}
|
|
2434
|
+
const targetCollection = relation.target();
|
|
2435
|
+
if (i === segments.length - 1) return {
|
|
2436
|
+
parentCollection,
|
|
2437
|
+
parentId,
|
|
2438
|
+
relationKey,
|
|
2439
|
+
relation,
|
|
2440
|
+
targetCollection
|
|
2441
|
+
};
|
|
2442
|
+
parentCollection = targetCollection;
|
|
2443
|
+
parentId = segments[i + 1];
|
|
2444
|
+
}
|
|
2445
|
+
throw new Error(`Unable to resolve path: ${path}`);
|
|
2446
|
+
}
|
|
2447
|
+
/**
|
|
2448
|
+
* A relation reached through a junction table — many-to-many, or a multi-hop
|
|
2449
|
+
* `joinPath`. The target row is shared with other parents, so writing "through"
|
|
2450
|
+
* such a path addresses the *link*, not the row.
|
|
2451
|
+
*/
|
|
2452
|
+
function isJunctionBackedRelation(relation) {
|
|
2453
|
+
return Boolean(relation.through) || Boolean(relation.joinPath && relation.joinPath.length > 1);
|
|
2454
|
+
}
|
|
2455
|
+
/**
|
|
2456
|
+
* Reject a nested write whose final segment is a to-one relation.
|
|
2457
|
+
*
|
|
2458
|
+
* There is no column on the target row that records a to-one parent — the
|
|
2459
|
+
* foreign key lives on the *parent* table. The write path used to fall through
|
|
2460
|
+
* to `relation.localKey` here and stamp the parent's own FK column onto the
|
|
2461
|
+
* target row, which either raised an opaque "column does not exist" or, when a
|
|
2462
|
+
* column of that name happened to exist on the target, silently wrote the wrong
|
|
2463
|
+
* one.
|
|
2464
|
+
*/
|
|
2465
|
+
function assertWritableThrough(hop, path) {
|
|
2466
|
+
if (hop.relation.cardinality !== "many") 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");
|
|
2467
|
+
}
|
|
2468
|
+
//#endregion
|
|
2258
2469
|
//#region src/services/FetchService.ts
|
|
2259
2470
|
/**
|
|
2260
2471
|
* Service for handling all row read operations.
|
|
@@ -2386,10 +2597,11 @@ var FetchService = class {
|
|
|
2386
2597
|
* Build db.query-compatible options from standard fetch options.
|
|
2387
2598
|
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
2388
2599
|
*/
|
|
2389
|
-
buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig) {
|
|
2600
|
+
buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition) {
|
|
2390
2601
|
const queryOpts = {};
|
|
2391
2602
|
if (withConfig) queryOpts.with = withConfig;
|
|
2392
2603
|
const allConditions = [];
|
|
2604
|
+
if (scopeCondition) allConditions.push(scopeCondition);
|
|
2393
2605
|
if (options.searchString) {
|
|
2394
2606
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
2395
2607
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
@@ -2447,9 +2659,49 @@ var FetchService = class {
|
|
|
2447
2659
|
return [];
|
|
2448
2660
|
}
|
|
2449
2661
|
/**
|
|
2662
|
+
* Compile "rows reachable from this parent" into a `WHERE` condition on the
|
|
2663
|
+
* target table, so a nested listing can run as an ordinary collection query.
|
|
2664
|
+
*/
|
|
2665
|
+
buildRelationScope(hop) {
|
|
2666
|
+
const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
|
|
2667
|
+
const parentIdInfo = parentPks[0];
|
|
2668
|
+
const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentIdInfo.fieldName];
|
|
2669
|
+
const parent = () => {
|
|
2670
|
+
const table = getTableForCollection(hop.parentCollection, this.registry);
|
|
2671
|
+
const idColumn = table[parentIdInfo.fieldName];
|
|
2672
|
+
if (!idColumn) throw new Error(`ID field '${parentIdInfo.fieldName}' not found in table for collection '${hop.parentCollection.slug}'`);
|
|
2673
|
+
return {
|
|
2674
|
+
table,
|
|
2675
|
+
idColumn
|
|
2676
|
+
};
|
|
2677
|
+
};
|
|
2678
|
+
const targetTable = getTableForCollection(hop.targetCollection, this.registry);
|
|
2679
|
+
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
2680
|
+
const targetIdColumn = targetTable[targetPks[0].fieldName];
|
|
2681
|
+
if (!targetIdColumn) throw new Error(`ID field '${targetPks[0].fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
|
|
2682
|
+
return DrizzleConditionBuilder.buildRelationScopeCondition(hop.relation, parent, parsedParentId, targetTable, targetIdColumn, this.registry);
|
|
2683
|
+
}
|
|
2684
|
+
/**
|
|
2685
|
+
* Whether `id` is actually reachable at `collectionPath`.
|
|
2686
|
+
*
|
|
2687
|
+
* Trivially true for a root path. For a nested one it is a real question:
|
|
2688
|
+
* the path resolves to the target collection, and matching on the primary
|
|
2689
|
+
* key alone made the parent segment decorative — `authors/1/posts/43`
|
|
2690
|
+
* returned post 43 whoever wrote it, and the REST layer's delete then
|
|
2691
|
+
* deleted it. A row that is not under this parent is reported as absent,
|
|
2692
|
+
* which is what a caller addressing it through the parent should see.
|
|
2693
|
+
*/
|
|
2694
|
+
async isAddressableUnder(collectionPath, id) {
|
|
2695
|
+
if (!isNestedPath(collectionPath)) return true;
|
|
2696
|
+
const hop = resolveNestedPath(collectionPath, this.registry);
|
|
2697
|
+
if (!hop) return true;
|
|
2698
|
+
return this.relationService.isRelated(hop, id);
|
|
2699
|
+
}
|
|
2700
|
+
/**
|
|
2450
2701
|
* Fetch a single row by ID
|
|
2451
2702
|
*/
|
|
2452
2703
|
async fetchOne(collectionPath, id, databaseId) {
|
|
2704
|
+
if (!await this.isAddressableUnder(collectionPath, id)) return void 0;
|
|
2453
2705
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
2454
2706
|
const table = getTableForCollection(collection, this.registry);
|
|
2455
2707
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
@@ -2506,6 +2758,7 @@ var FetchService = class {
|
|
|
2506
2758
|
* Unified method to fetch rows with optional search functionality
|
|
2507
2759
|
*/
|
|
2508
2760
|
async fetchRowsWithConditions(collectionPath, options = {}) {
|
|
2761
|
+
const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : void 0;
|
|
2509
2762
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
2510
2763
|
const table = getTableForCollection(collection, this.registry);
|
|
2511
2764
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
@@ -2517,7 +2770,7 @@ var FetchService = class {
|
|
|
2517
2770
|
const withConfig = this.buildWithConfig(collection);
|
|
2518
2771
|
const hasRelations = withConfig && Object.keys(withConfig).length > 0;
|
|
2519
2772
|
if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
|
|
2520
|
-
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
|
|
2773
|
+
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0, scopeCondition);
|
|
2521
2774
|
return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
|
|
2522
2775
|
} catch (e) {
|
|
2523
2776
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
@@ -2533,6 +2786,7 @@ var FetchService = class {
|
|
|
2533
2786
|
_distance: vectorMeta.distanceSelect
|
|
2534
2787
|
}).from(table).$dynamic() : this.db.select().from(table).$dynamic();
|
|
2535
2788
|
const allConditions = [];
|
|
2789
|
+
if (scopeCondition) allConditions.push(scopeCondition);
|
|
2536
2790
|
if (options.searchString) {
|
|
2537
2791
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
2538
2792
|
if (searchConditions.length === 0) return [];
|
|
@@ -2637,7 +2891,11 @@ var FetchService = class {
|
|
|
2637
2891
|
* Fetch a collection of rows
|
|
2638
2892
|
*/
|
|
2639
2893
|
async fetchCollection(collectionPath, options = {}) {
|
|
2640
|
-
|
|
2894
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
|
|
2895
|
+
if (hop) return this.fetchRowsWithConditions(hop.targetCollection.slug, {
|
|
2896
|
+
...options,
|
|
2897
|
+
relatedTo: hop
|
|
2898
|
+
});
|
|
2641
2899
|
return this.fetchRowsWithConditions(collectionPath, options);
|
|
2642
2900
|
}
|
|
2643
2901
|
/**
|
|
@@ -2650,43 +2908,23 @@ var FetchService = class {
|
|
|
2650
2908
|
});
|
|
2651
2909
|
}
|
|
2652
2910
|
/**
|
|
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
2911
|
* Count rows in a collection
|
|
2676
2912
|
*/
|
|
2677
2913
|
async count(collectionPath, options = {}) {
|
|
2678
|
-
|
|
2679
|
-
const
|
|
2914
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
|
|
2915
|
+
const effectivePath = hop ? hop.targetCollection.slug : collectionPath;
|
|
2916
|
+
const collection = getCollectionByPath(effectivePath, this.registry);
|
|
2680
2917
|
const table = getTableForCollection(collection, this.registry);
|
|
2681
2918
|
let query = this.db.select({ count: count() }).from(table).$dynamic();
|
|
2682
2919
|
const allConditions = [];
|
|
2920
|
+
if (hop) allConditions.push(this.buildRelationScope(hop));
|
|
2683
2921
|
if (options.searchString) {
|
|
2684
2922
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
2685
2923
|
if (searchConditions.length === 0) return 0;
|
|
2686
2924
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
2687
2925
|
}
|
|
2688
2926
|
if (options.filter) {
|
|
2689
|
-
const filterConditions = this.buildFilterConditions(options.filter, table,
|
|
2927
|
+
const filterConditions = this.buildFilterConditions(options.filter, table, effectivePath);
|
|
2690
2928
|
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
2691
2929
|
}
|
|
2692
2930
|
if (allConditions.length > 0) {
|
|
@@ -2697,27 +2935,6 @@ var FetchService = class {
|
|
|
2697
2935
|
return Number(result[0]?.count || 0);
|
|
2698
2936
|
}
|
|
2699
2937
|
/**
|
|
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
2938
|
* Check if a field value is unique
|
|
2722
2939
|
*/
|
|
2723
2940
|
async checkUniqueField(collectionPath, fieldName, value, excludeEntityId, _databaseId) {
|
|
@@ -2749,6 +2966,14 @@ var FetchService = class {
|
|
|
2749
2966
|
* @param include - Array of relation keys to populate, or ["*"] for all
|
|
2750
2967
|
*/
|
|
2751
2968
|
async fetchCollectionForRest(collectionPath, options = {}, include) {
|
|
2969
|
+
if (isNestedPath(collectionPath)) {
|
|
2970
|
+
const hop = resolveNestedPath(collectionPath, this.registry);
|
|
2971
|
+
if (hop) return this.fetchCollectionForRest(hop.targetCollection.slug, {
|
|
2972
|
+
...options,
|
|
2973
|
+
relatedTo: hop
|
|
2974
|
+
}, include);
|
|
2975
|
+
}
|
|
2976
|
+
const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : void 0;
|
|
2752
2977
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
2753
2978
|
const table = getTableForCollection(collection, this.registry);
|
|
2754
2979
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
@@ -2758,7 +2983,7 @@ var FetchService = class {
|
|
|
2758
2983
|
const qb = this.getQueryBuilder(tableName);
|
|
2759
2984
|
if (qb && !options.searchString && !options.vectorSearch) try {
|
|
2760
2985
|
const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
|
|
2761
|
-
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
|
|
2986
|
+
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition);
|
|
2762
2987
|
const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
|
|
2763
2988
|
await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
|
|
2764
2989
|
return restRows;
|
|
@@ -2806,6 +3031,7 @@ var FetchService = class {
|
|
|
2806
3031
|
* Fetch a single row with optional relation includes for REST API.
|
|
2807
3032
|
*/
|
|
2808
3033
|
async fetchOneForRest(collectionPath, id, include, databaseId) {
|
|
3034
|
+
if (!await this.isAddressableUnder(collectionPath, id)) return null;
|
|
2809
3035
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
2810
3036
|
const table = getTableForCollection(collection, this.registry);
|
|
2811
3037
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
@@ -2874,6 +3100,7 @@ var FetchService = class {
|
|
|
2874
3100
|
_distance: vectorMeta.distanceSelect
|
|
2875
3101
|
}).from(table).$dynamic() : this.db.select().from(table).$dynamic();
|
|
2876
3102
|
const allConditions = [];
|
|
3103
|
+
if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
|
|
2877
3104
|
if (options.searchString) {
|
|
2878
3105
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
2879
3106
|
if (searchConditions.length === 0) return [];
|
|
@@ -3193,6 +3420,16 @@ var PersistService = class {
|
|
|
3193
3420
|
* Delete an row by ID
|
|
3194
3421
|
*/
|
|
3195
3422
|
async delete(collectionPath, id, _databaseId) {
|
|
3423
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
|
|
3424
|
+
if (hop) {
|
|
3425
|
+
assertWritableThrough(hop, collectionPath);
|
|
3426
|
+
if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
|
|
3427
|
+
if (isJunctionBackedRelation(hop.relation)) {
|
|
3428
|
+
if (!hop.relation.through) 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");
|
|
3429
|
+
await this.relationService.unlinkRelatedEntity(this.db, hop, id);
|
|
3430
|
+
return;
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3196
3433
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
3197
3434
|
const table = getTableForCollection(collection, this.registry);
|
|
3198
3435
|
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
@@ -3213,6 +3450,29 @@ var PersistService = class {
|
|
|
3213
3450
|
await this.db.delete(table);
|
|
3214
3451
|
}
|
|
3215
3452
|
/**
|
|
3453
|
+
* The column on the *target* table that records the parent, for a create
|
|
3454
|
+
* under a nested one-to-many path.
|
|
3455
|
+
*
|
|
3456
|
+
* Returns `undefined` when the link is not a column at all (a multi-hop
|
|
3457
|
+
* `joinPath`), so the caller writes the row without stamping anything.
|
|
3458
|
+
*
|
|
3459
|
+
* `relation.localKey` is deliberately not consulted: it names a column on
|
|
3460
|
+
* the *source* table. Falling back to it here — which is what this used to
|
|
3461
|
+
* do, and first — stamped the parent's own foreign key onto the child row.
|
|
3462
|
+
*/
|
|
3463
|
+
resolveParentForeignKeyColumn(hop) {
|
|
3464
|
+
const { relation, relationKey, targetCollection } = hop;
|
|
3465
|
+
if (relation.foreignKeyOnTarget) return relation.foreignKeyOnTarget;
|
|
3466
|
+
if (relation.joinPath && relation.joinPath.length === 1) {
|
|
3467
|
+
const joinStep = relation.joinPath[0];
|
|
3468
|
+
const targetTableName = getTableName$1(targetCollection);
|
|
3469
|
+
if (joinStep.table !== targetTableName) logger.warn(`Join step for relation '${relationKey}' targets '${joinStep.table}', not the target table '${targetTableName}'.`);
|
|
3470
|
+
return DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to)[0];
|
|
3471
|
+
}
|
|
3472
|
+
if (relation.joinPath && relation.joinPath.length > 1) return;
|
|
3473
|
+
throw ApiError.badRequest(`Relation '${relationKey}' on '${hop.parentCollection.slug}' cannot be written through: it declares no \`foreignKeyOnTarget\` (the column on '${targetCollection.slug}' that records the parent) and no \`joinPath\`.`, "RELATION_NOT_WRITABLE");
|
|
3474
|
+
}
|
|
3475
|
+
/**
|
|
3216
3476
|
* Save an row (create or update)
|
|
3217
3477
|
*
|
|
3218
3478
|
* With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
|
|
@@ -3225,60 +3485,29 @@ var PersistService = class {
|
|
|
3225
3485
|
let effectiveCollectionPath = collectionPath;
|
|
3226
3486
|
const effectiveValues = { ...values };
|
|
3227
3487
|
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
|
-
}
|
|
3488
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
|
|
3489
|
+
if (hop) {
|
|
3490
|
+
assertWritableThrough(hop, collectionPath);
|
|
3491
|
+
effectiveCollectionPath = hop.targetCollection.slug;
|
|
3492
|
+
const parentIdForWrite = () => {
|
|
3493
|
+
const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
|
|
3494
|
+
return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
3495
|
+
};
|
|
3496
|
+
if (hop.relation.through) junctionTableInfo = {
|
|
3497
|
+
parentCollection: hop.parentCollection,
|
|
3498
|
+
parentId: parentIdForWrite(),
|
|
3499
|
+
relation: hop.relation,
|
|
3500
|
+
relationKey: hop.relationKey
|
|
3501
|
+
};
|
|
3502
|
+
else if (id !== void 0) {
|
|
3503
|
+
if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to update.`);
|
|
3504
|
+
} else {
|
|
3505
|
+
const targetColumnName = this.resolveParentForeignKeyColumn(hop);
|
|
3506
|
+
if (targetColumnName) {
|
|
3507
|
+
const parsedParentId = parentIdForWrite();
|
|
3508
|
+
const existingValue = effectiveValues[targetColumnName];
|
|
3509
|
+
if (existingValue !== void 0 && existingValue !== null && existingValue !== parsedParentId) logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
|
|
3510
|
+
effectiveValues[targetColumnName] = parsedParentId;
|
|
3282
3511
|
}
|
|
3283
3512
|
}
|
|
3284
3513
|
}
|
|
@@ -3349,7 +3578,7 @@ var PersistService = class {
|
|
|
3349
3578
|
}
|
|
3350
3579
|
if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
|
|
3351
3580
|
if (Object.keys(relationValues).length > 0) await this.relationService.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
|
|
3352
|
-
if (junctionTableInfo
|
|
3581
|
+
if (junctionTableInfo) await this.relationService.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
|
|
3353
3582
|
return currentId;
|
|
3354
3583
|
});
|
|
3355
3584
|
} catch (error) {
|
|
@@ -5737,6 +5966,58 @@ var main = () => {
|
|
|
5737
5966
|
};
|
|
5738
5967
|
if (import.meta.url.endsWith(process.argv[1])) main();
|
|
5739
5968
|
//#endregion
|
|
5969
|
+
//#region src/services/cdc/junction-tables.ts
|
|
5970
|
+
/**
|
|
5971
|
+
* Every junction table reachable from a registered collection, once per
|
|
5972
|
+
* relation that uses it.
|
|
5973
|
+
*
|
|
5974
|
+
* A junction is listed once per *direction* when both sides declare it, because
|
|
5975
|
+
* each direction addresses a different child list: `posts/1/tags` and
|
|
5976
|
+
* `tags/t/posts` both change when one link is written.
|
|
5977
|
+
*/
|
|
5978
|
+
function collectJunctionLinks(registry) {
|
|
5979
|
+
const links = [];
|
|
5980
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5981
|
+
for (const collection of registry.getCollections()) {
|
|
5982
|
+
let relations;
|
|
5983
|
+
try {
|
|
5984
|
+
relations = resolveCollectionRelations(collection);
|
|
5985
|
+
} catch {
|
|
5986
|
+
continue;
|
|
5987
|
+
}
|
|
5988
|
+
for (const [relationKey, relation] of Object.entries(relations)) {
|
|
5989
|
+
const through = relation?.through;
|
|
5990
|
+
if (!through?.table) continue;
|
|
5991
|
+
const key = `${collection.slug}::${relationKey}::${through.table}`;
|
|
5992
|
+
if (seen.has(key)) continue;
|
|
5993
|
+
seen.add(key);
|
|
5994
|
+
links.push({
|
|
5995
|
+
schema: collection.schema ?? "public",
|
|
5996
|
+
table: through.table,
|
|
5997
|
+
parentCollection: collection,
|
|
5998
|
+
relationKey,
|
|
5999
|
+
sourceColumn: through.sourceColumn,
|
|
6000
|
+
targetColumn: through.targetColumn
|
|
6001
|
+
});
|
|
6002
|
+
}
|
|
6003
|
+
}
|
|
6004
|
+
return links;
|
|
6005
|
+
}
|
|
6006
|
+
/**
|
|
6007
|
+
* Index {@link collectJunctionLinks} by table, under both the qualified and the
|
|
6008
|
+
* bare name — a change event carries whatever the trigger reports, and a
|
|
6009
|
+
* collection need not declare a schema.
|
|
6010
|
+
*/
|
|
6011
|
+
function buildJunctionLinkMap(registry) {
|
|
6012
|
+
const map = /* @__PURE__ */ new Map();
|
|
6013
|
+
for (const link of collectJunctionLinks(registry)) for (const key of [`${link.schema}.${link.table}`, link.table]) {
|
|
6014
|
+
const existing = map.get(key);
|
|
6015
|
+
if (existing) existing.push(link);
|
|
6016
|
+
else map.set(key, [link]);
|
|
6017
|
+
}
|
|
6018
|
+
return map;
|
|
6019
|
+
}
|
|
6020
|
+
//#endregion
|
|
5740
6021
|
//#region src/services/cdc/trigger-cdc.ts
|
|
5741
6022
|
/**
|
|
5742
6023
|
* Trigger-based Change Data Capture (CDC).
|
|
@@ -6861,6 +7142,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
6861
7142
|
cdcListener;
|
|
6862
7143
|
/** Whether database-level CDC is the active cross-instance change source. */
|
|
6863
7144
|
cdcActive = false;
|
|
7145
|
+
/** Junction table → the child lists its rows belong to, built when CDC starts. */
|
|
7146
|
+
junctionLinkMap;
|
|
6864
7147
|
/** Reverse lookup: `schema.table` (and bare `table`) → collection, built when CDC starts. */
|
|
6865
7148
|
cdcTableMap;
|
|
6866
7149
|
/**
|
|
@@ -7977,6 +8260,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7977
8260
|
return;
|
|
7978
8261
|
}
|
|
7979
8262
|
this.cdcTableMap = this.buildCdcTableMap();
|
|
8263
|
+
this.junctionLinkMap = buildJunctionLinkMap(this.registry);
|
|
7980
8264
|
this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event));
|
|
7981
8265
|
try {
|
|
7982
8266
|
await this.cdcListener.start();
|
|
@@ -7984,6 +8268,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7984
8268
|
await this.cdcListener.stop().catch(() => {});
|
|
7985
8269
|
this.cdcListener = void 0;
|
|
7986
8270
|
this.cdcTableMap = void 0;
|
|
8271
|
+
this.junctionLinkMap = void 0;
|
|
7987
8272
|
throw err;
|
|
7988
8273
|
}
|
|
7989
8274
|
this.cdcActive = true;
|
|
@@ -7997,6 +8282,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7997
8282
|
this.cdcListener = void 0;
|
|
7998
8283
|
}
|
|
7999
8284
|
this.cdcTableMap = void 0;
|
|
8285
|
+
this.junctionLinkMap = void 0;
|
|
8000
8286
|
this.recentAppEmits.clear();
|
|
8001
8287
|
}
|
|
8002
8288
|
/**
|
|
@@ -8033,6 +8319,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8033
8319
|
async handleCdcEvent(event) {
|
|
8034
8320
|
const collection = this.resolveCollectionForTable(event.schema, event.table);
|
|
8035
8321
|
if (!collection) {
|
|
8322
|
+
if (await this.handleJunctionCdcEvent(event)) return;
|
|
8036
8323
|
this.debugLog(`📡 [CDC] Ignoring change on unmapped table ${event.schema}.${event.table}`);
|
|
8037
8324
|
return;
|
|
8038
8325
|
}
|
|
@@ -8042,6 +8329,38 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8042
8329
|
const row = event.op === "DELETE" ? null : { _rebase_invalidated: true };
|
|
8043
8330
|
await this.notifyUpdate(path, id, row, databaseId, false, "cdc");
|
|
8044
8331
|
}
|
|
8332
|
+
/**
|
|
8333
|
+
* Deliver a change on a many-to-many junction table as a change to the child
|
|
8334
|
+
* lists it belongs to.
|
|
8335
|
+
*
|
|
8336
|
+
* Linking a tag to a post writes only `posts_tags`. That table backs no
|
|
8337
|
+
* collection, so change capture dropped the event as unmapped and the
|
|
8338
|
+
* subscribers of `posts/1/tags` never heard about it — every other write in
|
|
8339
|
+
* the system was realtime, and this one silently was not. The junction row
|
|
8340
|
+
* carries both ids, so it names its own paths exactly.
|
|
8341
|
+
*
|
|
8342
|
+
* Notifies the nested path rather than either endpoint collection, because
|
|
8343
|
+
* invalidation walks *parent* paths and never child ones: telling `tags` it
|
|
8344
|
+
* changed would not reach a subscription on `posts/1/tags`.
|
|
8345
|
+
*
|
|
8346
|
+
* Returns whether the table was recognised as a junction.
|
|
8347
|
+
*/
|
|
8348
|
+
async handleJunctionCdcEvent(event) {
|
|
8349
|
+
const links = this.junctionLinkMap?.get(`${event.schema}.${event.table}`) ?? this.junctionLinkMap?.get(event.table);
|
|
8350
|
+
if (!links?.length) return false;
|
|
8351
|
+
for (const link of links) {
|
|
8352
|
+
const sourceId = event.row?.[link.sourceColumn];
|
|
8353
|
+
const targetId = event.row?.[link.targetColumn];
|
|
8354
|
+
if (sourceId === void 0 || sourceId === null || targetId === void 0 || targetId === null) {
|
|
8355
|
+
this.debugLog(`📡 [CDC] Junction row on ${event.table} is missing '${link.sourceColumn}'/'${link.targetColumn}' — skipping.`);
|
|
8356
|
+
continue;
|
|
8357
|
+
}
|
|
8358
|
+
const path = `${link.parentCollection.slug}/${String(sourceId)}/${link.relationKey}`;
|
|
8359
|
+
const row = event.op === "DELETE" ? null : { _rebase_invalidated: true };
|
|
8360
|
+
await this.notifyUpdate(path, String(targetId), row, link.parentCollection.databaseId, false, "cdc");
|
|
8361
|
+
}
|
|
8362
|
+
return true;
|
|
8363
|
+
}
|
|
8045
8364
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
8046
8365
|
extractIdFromCdcRow(collection, row) {
|
|
8047
8366
|
return deriveRowAddress(row, collection, this.registry) || "*";
|
|
@@ -20026,7 +20345,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
20026
20345
|
} catch {}
|
|
20027
20346
|
const message = `
|
|
20028
20347
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
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 •
|
|
20348
|
+
❌ 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
20349
|
logger.error(message);
|
|
20031
20350
|
throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
|
|
20032
20351
|
}
|
|
@@ -20111,10 +20430,15 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
20111
20430
|
const cdcRunSql = async (text) => {
|
|
20112
20431
|
return (await schemaAwareDb.execute(sql.raw(text))).rows ?? [];
|
|
20113
20432
|
};
|
|
20114
|
-
|
|
20433
|
+
const cdcTables = registry.getCollections().map((c) => ({
|
|
20115
20434
|
schema: c.schema ?? "public",
|
|
20116
20435
|
table: getTableName$1(c)
|
|
20117
|
-
})).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table))
|
|
20436
|
+
})).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table));
|
|
20437
|
+
for (const link of collectJunctionLinks(registry)) cdcTables.push({
|
|
20438
|
+
schema: link.schema,
|
|
20439
|
+
table: link.table
|
|
20440
|
+
});
|
|
20441
|
+
await provisionTriggerCdc(cdcRunSql, cdcTables);
|
|
20118
20442
|
await realtimeService.enableCdc(directUrl);
|
|
20119
20443
|
cdcEnabled = true;
|
|
20120
20444
|
provisionCdcForTables = async (tables) => {
|
|
@@ -20252,7 +20576,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
20252
20576
|
*/
|
|
20253
20577
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
20254
20578
|
const internals = driverResult.internals;
|
|
20255
|
-
const { ensureCollectionTables } = await import("./ensure-collection-tables-
|
|
20579
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-BVvtkkRm.js");
|
|
20256
20580
|
return { applied: (await ensureCollectionTables({ async query(text) {
|
|
20257
20581
|
const result = await internals.db.execute(sql.raw(text));
|
|
20258
20582
|
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|