@rebasepro/server 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.
@@ -41,6 +41,20 @@ export declare class RestApiGenerator {
41
41
  * slug, never an id.
42
42
  */
43
43
  private enforceSubcollectionApiKeyPermission;
44
+ /**
45
+ * The collection a nested path writes into — the target of the relation its
46
+ * last segment names.
47
+ *
48
+ * Needed so a nested write can be checked against a schema at all. Without
49
+ * it these routes skipped `assertKnownWriteFields` entirely, which is why a
50
+ * typo `POST /posts` rejected with a 400 while the same typo on
51
+ * `POST /authors/1/posts` reached the database.
52
+ *
53
+ * Returns `undefined` rather than throwing when the path cannot be walked:
54
+ * the driver raises the authoritative error a moment later, and duplicating
55
+ * it here would report a resolution failure as a validation failure.
56
+ */
57
+ private resolveNestedWriteCollection;
44
58
  /**
45
59
  * Get the request-scoped driver. Throws if none is set — never falls
46
60
  * back to the unscoped `this.driver` to avoid bypassing RLS/auth.
package/dist/index.es.js CHANGED
@@ -1309,6 +1309,19 @@ function sanitizeRelation(relation, sourceCollection, resolveCollection) {
1309
1309
  if (newRelation.cardinality === "many" && newRelation.direction === "inverse" && !newRelation.foreignKeyOnTarget && !newRelation.joinPath && !newRelation.inverseRelationName) throw new Error(`Configuration Error in relation from '${sourceCollection.name}': An 'inverse' one-to-many relation requires a 'foreignKeyOnTarget'. Check the relation config for '${newRelation.relationName}'`);
1310
1310
  return newRelation;
1311
1311
  }
1312
+ /**
1313
+ * Whether the target rows of this relation are reached through a junction — a
1314
+ * many-to-many, or a multi-hop `joinPath`.
1315
+ *
1316
+ * The distinction decides what a write "through" the relation may touch: a
1317
+ * junction-backed target is shared with other parents, so the parent owns the
1318
+ * *link* and not the row. The backend enforces that (an unlink rather than a
1319
+ * delete) and the admin renders it (remove-from-parent rather than delete), so
1320
+ * the predicate lives here rather than once per side.
1321
+ */
1322
+ function isJunctionBackedRelation(relation) {
1323
+ return Boolean(relation.through) || Boolean(relation.joinPath && relation.joinPath.length > 1);
1324
+ }
1312
1325
  /** WeakMap cache — same collection instance always yields the same relation map. */
1313
1326
  var _resolvedRelationsCache = /* @__PURE__ */ new WeakMap();
1314
1327
  function resolveCollectionRelations(collection) {
@@ -1385,34 +1398,115 @@ function findRelation(resolvedRelations, key) {
1385
1398
  }
1386
1399
  //#endregion
1387
1400
  //#region ../common/src/util/resolutions.ts
1388
- function getSubcollections(collection) {
1389
- if (collection.childCollections) return collection.childCollections() ?? [];
1390
- const declaredSubcollections = getDeclaredSubcollections(collection);
1391
- if (getDataSourceCapabilities(collection.engine).supportsSubcollections && declaredSubcollections) return declaredSubcollections() ?? [];
1392
- if (getDataSourceCapabilities(collection.engine).supportsRelations) {
1393
- const resolvedRelations = resolveCollectionRelations(collection);
1394
- return Object.values(resolvedRelations).filter((r) => r.cardinality === "many").map((r) => {
1395
- const target = r.target();
1396
- if (!target) return void 0;
1397
- const relationKey = r.relationName || target.slug;
1398
- let customName;
1399
- if (collection.properties) {
1400
- const prop = Object.entries(collection.properties).find(([_, p]) => p.type === "relation" && p.relationName === relationKey);
1401
- if (prop && prop[1].name) customName = prop[1].name;
1401
+ /**
1402
+ * Relation identities that only ever appear *below* the top level of
1403
+ * `properties` — inside a `map`, an `array`, or a `oneOf` branch.
1404
+ *
1405
+ * A relation declared at the top level is a list hanging off the record and
1406
+ * earns a tab. One declared inside a map is a field of that map. Both end up in
1407
+ * the collection's flat `relations` array after normalization, so the shape of
1408
+ * `properties` is the only remaining evidence of which is which.
1409
+ *
1410
+ * An identity declared in both places is *not* nested-only, and keeps its tab.
1411
+ */
1412
+ function getNestedRelationIdentities(properties) {
1413
+ const topLevel = /* @__PURE__ */ new Set();
1414
+ const nested = /* @__PURE__ */ new Set();
1415
+ const walk = (props, depth) => {
1416
+ if (!props) return;
1417
+ for (const [key, property] of Object.entries(props)) {
1418
+ if (!property) continue;
1419
+ if (property.type === "relation") {
1420
+ const relProp = property;
1421
+ (depth === 0 ? topLevel : nested).add(relProp.relationName ?? key);
1422
+ } else if (property.type === "map") walk(property.properties, depth + 1);
1423
+ else if (property.type === "array") {
1424
+ const arrayProp = property;
1425
+ if (Array.isArray(arrayProp.of)) arrayProp.of.forEach((p) => walk({ entry: p }, depth + 1));
1426
+ else if (arrayProp.of) walk({ entry: arrayProp.of }, depth + 1);
1427
+ if (arrayProp.oneOf?.properties) walk(arrayProp.oneOf.properties, depth + 1);
1402
1428
  }
1403
- const baseOverrides = { slug: relationKey };
1404
- if (customName) {
1405
- baseOverrides.name = customName;
1406
- baseOverrides.singularName = customName;
1429
+ }
1430
+ };
1431
+ walk(properties, 0);
1432
+ for (const identity of topLevel) nested.delete(identity);
1433
+ return nested;
1434
+ }
1435
+ /**
1436
+ * The lists rendered inside an entity view of `collection` — its tabs.
1437
+ *
1438
+ * The single derivation. There used to be two that disagreed: this one, and a
1439
+ * copy in `CollectionRegistry.normalizeCollection` that stamped each child with
1440
+ * the *target collection's* slug instead of the relation key. Since the
1441
+ * registry ran first and cached its answer onto `childCollections`, its version
1442
+ * was the one that won, and the frontend addressed child listings by a segment
1443
+ * the backend could not resolve.
1444
+ *
1445
+ * Order of precedence:
1446
+ * 1. `childCollections` — the explicit escape hatch for custom drivers.
1447
+ * 2. `subcollections` on an engine that has real containment (Firestore).
1448
+ * 3. many-relations on an engine that has relations (SQL).
1449
+ */
1450
+ function getEntityChildViews(collection) {
1451
+ const asSubcollections = (collections) => collections.filter(Boolean).map((child) => ({
1452
+ key: child.slug,
1453
+ collection: child,
1454
+ source: { kind: "subcollection" }
1455
+ }));
1456
+ if (collection.childCollections) return asSubcollections(collection.childCollections() ?? []);
1457
+ const capabilities = getDataSourceCapabilities(collection.engine);
1458
+ const declaredSubcollections = getDeclaredSubcollections(collection);
1459
+ if (capabilities.supportsSubcollections && declaredSubcollections) return asSubcollections(declaredSubcollections() ?? []);
1460
+ if (!capabilities.supportsRelations) return [];
1461
+ const resolvedRelations = resolveCollectionRelations(collection);
1462
+ const nestedOnly = getNestedRelationIdentities(collection.properties);
1463
+ const views = [];
1464
+ const seen = /* @__PURE__ */ new Set();
1465
+ for (const [relationKey, relation] of Object.entries(resolvedRelations)) {
1466
+ if (relation.cardinality !== "many") continue;
1467
+ const identity = relation.relationName ?? relationKey;
1468
+ if (seen.has(identity)) continue;
1469
+ if (nestedOnly.has(identity)) continue;
1470
+ let target;
1471
+ try {
1472
+ target = relation.target();
1473
+ } catch {
1474
+ continue;
1475
+ }
1476
+ if (!target) continue;
1477
+ seen.add(identity);
1478
+ const customName = Object.entries(collection.properties ?? {}).find(([propKey, p]) => p.type === "relation" && (p.relationName ?? propKey) === identity)?.[1]?.name;
1479
+ const base = {
1480
+ ...target,
1481
+ slug: relationKey,
1482
+ ...customName ? {
1483
+ name: customName,
1484
+ singularName: customName
1485
+ } : {}
1486
+ };
1487
+ views.push({
1488
+ key: relationKey,
1489
+ collection: relation.overrides ? mergeDeep(base, relation.overrides) : base,
1490
+ source: {
1491
+ kind: "relation",
1492
+ relationKey,
1493
+ mode: isJunctionBackedRelation(relation) ? "linked" : "owned",
1494
+ targetSlug: target.slug
1407
1495
  }
1408
- const targetWithOverrides = {
1409
- ...target,
1410
- ...baseOverrides
1411
- };
1412
- return r.overrides ? mergeDeep(targetWithOverrides, r.overrides) : targetWithOverrides;
1413
- }).filter((c) => Boolean(c));
1496
+ });
1414
1497
  }
1415
- return [];
1498
+ return views;
1499
+ }
1500
+ /**
1501
+ * The child views of `collection` as bare collections.
1502
+ *
1503
+ * The flattened view of {@link getEntityChildViews}, for navigation code that
1504
+ * only needs to match a path segment against a slug. Anything that cares *what
1505
+ * kind* of list it is showing — chiefly the admin, which must not offer a
1506
+ * global delete on a shared row — should read the views instead.
1507
+ */
1508
+ function getSubcollections(collection) {
1509
+ return getEntityChildViews(collection).map((view) => view.collection);
1416
1510
  }
1417
1511
  policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
1418
1512
  policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
@@ -2308,18 +2402,6 @@ var CollectionRegistry = class {
2308
2402
  relResult.relations = mergedRelations;
2309
2403
  }
2310
2404
  result.properties = this.normalizeProperties(result.properties, mergedRelations);
2311
- if (!result.childCollections) {
2312
- const capabilities = getDataSourceCapabilities(result.engine);
2313
- const declaredSubcollections = getDeclaredSubcollections(result);
2314
- if (capabilities.supportsSubcollections && declaredSubcollections) result.childCollections = declaredSubcollections;
2315
- else if (capabilities.supportsRelations && relResult.relations) {
2316
- const manyRelations = relResult.relations.filter((r) => r.cardinality === "many");
2317
- if (manyRelations.length > 0) result.childCollections = () => manyRelations.map((r) => {
2318
- const target = r.target();
2319
- return r.overrides ? mergeDeep(target, r.overrides) : target;
2320
- });
2321
- }
2322
- }
2323
2405
  return result;
2324
2406
  }
2325
2407
  /**
@@ -2418,9 +2500,7 @@ var CollectionRegistry = class {
2418
2500
  const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
2419
2501
  if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
2420
2502
  const target = relation.target();
2421
- const targetRelationKey = relation.relationName || target.slug;
2422
- const targetSlug = relation.overrides?.slug ?? targetRelationKey;
2423
- currentCollection = this.get(targetSlug) || this.normalizeCollection(target);
2503
+ currentCollection = this.collectionsByTableName.get(getTableName(target)) ?? this.normalizeCollection(target);
2424
2504
  if (i + 1 < pathSegments.length) {}
2425
2505
  }
2426
2506
  return currentCollection;
@@ -2455,7 +2535,7 @@ var CollectionRegistry = class {
2455
2535
  if (!subcollections || subcollections.length === 0) throw new Error(`No subcollections found for ${currentCollection.slug} in path: ${path}`);
2456
2536
  const subcollection = subcollections.find((c) => c.slug === subcollectionSlug);
2457
2537
  if (!subcollection) throw new Error(`Subcollection '${subcollectionSlug}' not found in ${currentCollection.slug}`);
2458
- currentCollection = this.get(subcollection.slug) || this.normalizeCollection(subcollection);
2538
+ currentCollection = this.normalizeCollection(subcollection);
2459
2539
  collections.push(currentCollection);
2460
2540
  }
2461
2541
  }
@@ -4024,6 +4104,34 @@ var RestApiGenerator = class {
4024
4104
  this.enforceApiKeyPermission(c, collectionPath.split("/").pop());
4025
4105
  }
4026
4106
  /**
4107
+ * The collection a nested path writes into — the target of the relation its
4108
+ * last segment names.
4109
+ *
4110
+ * Needed so a nested write can be checked against a schema at all. Without
4111
+ * it these routes skipped `assertKnownWriteFields` entirely, which is why a
4112
+ * typo `POST /posts` rejected with a 400 while the same typo on
4113
+ * `POST /authors/1/posts` reached the database.
4114
+ *
4115
+ * Returns `undefined` rather than throwing when the path cannot be walked:
4116
+ * the driver raises the authoritative error a moment later, and duplicating
4117
+ * it here would report a resolution failure as a validation failure.
4118
+ */
4119
+ resolveNestedWriteCollection(collectionPath) {
4120
+ const segments = collectionPath.split("/").filter((s) => s && s !== "undefined");
4121
+ let current = this.collections.find((c) => c.slug === segments[0]);
4122
+ for (let i = 2; i < segments.length && current; i += 2) {
4123
+ const relation = findRelation(resolveCollectionRelations(current), segments[i]);
4124
+ if (!relation) return void 0;
4125
+ try {
4126
+ const target = relation.target();
4127
+ current = this.collections.find((c) => c.slug === target?.slug) ?? target;
4128
+ } catch {
4129
+ return;
4130
+ }
4131
+ }
4132
+ return current;
4133
+ }
4134
+ /**
4027
4135
  * Get the request-scoped driver. Throws if none is set — never falls
4028
4136
  * back to the unscoped `this.driver` to avoid bypassing RLS/auth.
4029
4137
  */
@@ -4264,7 +4372,9 @@ var RestApiGenerator = class {
4264
4372
  }) : 0;
4265
4373
  return c.json({ count: total });
4266
4374
  } else if (parsed.id) {
4267
- const entity = await driver.fetchOne({
4375
+ const queryOptions = this.parseQuery(c.req.queries());
4376
+ const fetchService = driver.restFetchService;
4377
+ const entity = fetchService ? await fetchService.fetchOneForRest(parsed.collectionPath, parsed.id, queryOptions.include) : await driver.fetchOne({
4268
4378
  path: parsed.collectionPath,
4269
4379
  id: parsed.id
4270
4380
  });
@@ -4274,13 +4384,18 @@ var RestApiGenerator = class {
4274
4384
  const queryDict = c.req.queries();
4275
4385
  const queryOptions = this.parseQuery(queryDict);
4276
4386
  const searchString = Array.isArray(queryDict.searchString) ? queryDict.searchString[queryDict.searchString.length - 1] : void 0;
4277
- const entities = await driver.fetchCollection({
4278
- path: parsed.collectionPath,
4387
+ const fetchService = driver.restFetchService;
4388
+ const listOptions = {
4279
4389
  filter: queryOptions.where,
4280
4390
  limit: queryOptions.limit,
4391
+ offset: queryOptions.offset,
4281
4392
  orderBy: queryOptions.orderBy?.[0]?.field,
4282
4393
  order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
4283
4394
  searchString
4395
+ };
4396
+ const entities = fetchService ? await fetchService.fetchCollectionForRest(parsed.collectionPath, listOptions, queryOptions.include) : await driver.fetchCollection({
4397
+ path: parsed.collectionPath,
4398
+ ...listOptions
4284
4399
  });
4285
4400
  const total = driver.count ? await driver.count({
4286
4401
  path: parsed.collectionPath,
@@ -4306,6 +4421,8 @@ var RestApiGenerator = class {
4306
4421
  const driver = this.getScopedDriver(c);
4307
4422
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
4308
4423
  const body = await parseJsonBody(c);
4424
+ const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
4425
+ if (targetCollection) assertKnownWriteFields(body, targetCollection);
4309
4426
  const entity = await driver.save({
4310
4427
  path: parsed.collectionPath,
4311
4428
  values: body,
@@ -4322,6 +4439,8 @@ var RestApiGenerator = class {
4322
4439
  const driver = this.getScopedDriver(c);
4323
4440
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
4324
4441
  const body = await parseJsonBody(c);
4442
+ const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
4443
+ if (targetCollection) assertKnownWriteFields(body, targetCollection);
4325
4444
  const entity = await driver.save({
4326
4445
  path: parsed.collectionPath,
4327
4446
  id: parsed.id,