@rebasepro/server 0.10.1-canary.d8d45b2 → 0.10.1-canary.ed8caed

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.
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Move presentation keys into the `admin` block.
3
+ *
4
+ * `ADMIN_COLLECTION_KEYS` comes from `@rebasepro/types` rather than being spelled
5
+ * out here, so adding a field to `AdminCollectionOptions` cannot leave this writer
6
+ * behind. Any such key already inside `admin` wins over a top-level copy: the
7
+ * nested one is what the file said, and a flat duplicate is the view model's
8
+ * flattening leaking back.
9
+ */
10
+ export declare function nestAdminKeys(collectionData: Record<string, unknown>): Record<string, unknown>;
1
11
  export declare class AstSchemaEditor {
2
12
  private project;
3
13
  private collectionsDir;
@@ -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
@@ -2,7 +2,7 @@ import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { _ as __toESM, a as generateRefreshToken, c as getRefreshTokenTtlMs, d as verifyAccessToken, f as verifyDownloadToken, g as __require, h as __exportAll, i as generateDownloadToken, l as hashRefreshToken, m as __commonJSMin, n as configureJwt, o as getAccessTokenExpiry, p as require_jsonwebtoken, r as generateAccessToken, s as getRefreshTokenExpiry, t as MAX_COOKIE_AGE_MS } from "./jwt-Dj7r7QX7.js";
5
- import { _ as EntityRelation, a as DEFAULT_DATA_SOURCE_KEY, b as RebaseApiError, c as policy, d as isPostgresCollectionConfig, f as CANONICAL_TO_REST, g as EntityReference, h as toCanonicalOp, i as DEFAULT_STORAGE_SOURCE_KEY, l as getCollectionDataPath, m as REST_TO_CANONICAL, n as serializeCollections, o as getDataSourceCapabilities, p as NULL_OPS, r as SCHEMA_VERSION_HEADER, s as isSQLAdmin, t as computeSchemaVersion, u as getDeclaredSubcollections, v as GeoPoint, x as RebaseClientError, y as Vector } from "./src-BITicbgD.js";
5
+ import { S as RebaseClientError, _ as EntityReference, a as DEFAULT_DATA_SOURCE_KEY, b as Vector, c as policy, d as isPostgresCollectionConfig, g as toCanonicalOp, h as REST_TO_CANONICAL, i as DEFAULT_STORAGE_SOURCE_KEY, l as getCollectionDataPath, m as NULL_OPS, n as serializeCollections, o as getDataSourceCapabilities, p as CANONICAL_TO_REST, r as SCHEMA_VERSION_HEADER, s as isSQLAdmin, t as computeSchemaVersion, u as getDeclaredSubcollections, v as EntityRelation, x as RebaseApiError, y as GeoPoint } from "./src-DRUswG_w.js";
6
6
  import { t as logger } from "./logger-BYU66ENZ.js";
7
7
  import { t as nativeDynamicImport } from "./dynamic-import-Dvh-K5fl.js";
8
8
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
@@ -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"]));
@@ -1689,39 +1783,9 @@ policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
1689
1783
  return jsonLogic;
1690
1784
  });
1691
1785
  })))();
1692
- //#endregion
1693
- //#region ../common/src/util/filter-operator-resolution.ts
1694
- /**
1695
- * Default operators offered per property type, before engine capabilities and
1696
- * per-property narrowing are applied. These mirror what the built-in filter
1697
- * fields can render.
1698
- */
1699
- var COMPARISON_OPS = [
1700
- "==",
1701
- "!=",
1702
- ">",
1703
- ">=",
1704
- "<",
1705
- "<="
1706
- ];
1707
- var NULL_CHECK_OPS = ["is-null", "is-not-null"];
1708
- var MEMBERSHIP_OPS = ["in", "not-in"];
1709
- var PATTERN_OPS = [
1710
- "like",
1711
- "ilike",
1712
- "not-like",
1713
- "not-ilike"
1714
- ];
1715
- [
1716
- ...COMPARISON_OPS,
1717
- ...MEMBERSHIP_OPS,
1718
- ...PATTERN_OPS,
1719
- ...NULL_CHECK_OPS
1720
- ], [
1721
- ...COMPARISON_OPS,
1722
- ...MEMBERSHIP_OPS,
1723
- ...NULL_CHECK_OPS
1724
- ], [...COMPARISON_OPS, ...NULL_CHECK_OPS], [...NULL_CHECK_OPS], [...MEMBERSHIP_OPS, ...NULL_CHECK_OPS], [...MEMBERSHIP_OPS, ...NULL_CHECK_OPS];
1786
+ /**
1787
+ * Apply PropertyConditions to a resolved property, evaluating all JSON Logic rules.
1788
+ */
1725
1789
  //#endregion
1726
1790
  //#region ../../node_modules/.pnpm/fast-equals@6.0.0/node_modules/fast-equals/dist/es/index.mjs
1727
1791
  var { getOwnPropertyNames, getOwnPropertySymbols } = Object;
@@ -2338,18 +2402,6 @@ var CollectionRegistry = class {
2338
2402
  relResult.relations = mergedRelations;
2339
2403
  }
2340
2404
  result.properties = this.normalizeProperties(result.properties, mergedRelations);
2341
- if (!result.childCollections) {
2342
- const capabilities = getDataSourceCapabilities(result.engine);
2343
- const declaredSubcollections = getDeclaredSubcollections(result);
2344
- if (capabilities.supportsSubcollections && declaredSubcollections) result.childCollections = declaredSubcollections;
2345
- else if (capabilities.supportsRelations && relResult.relations) {
2346
- const manyRelations = relResult.relations.filter((r) => r.cardinality === "many");
2347
- if (manyRelations.length > 0) result.childCollections = () => manyRelations.map((r) => {
2348
- const target = r.target();
2349
- return r.overrides ? mergeDeep(target, r.overrides) : target;
2350
- });
2351
- }
2352
- }
2353
2405
  return result;
2354
2406
  }
2355
2407
  /**
@@ -2448,9 +2500,7 @@ var CollectionRegistry = class {
2448
2500
  const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
2449
2501
  if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
2450
2502
  const target = relation.target();
2451
- const targetRelationKey = relation.relationName || target.slug;
2452
- const targetSlug = relation.overrides?.slug ?? targetRelationKey;
2453
- currentCollection = this.get(targetSlug) || this.normalizeCollection(target);
2503
+ currentCollection = this.collectionsByTableName.get(getTableName(target)) ?? this.normalizeCollection(target);
2454
2504
  if (i + 1 < pathSegments.length) {}
2455
2505
  }
2456
2506
  return currentCollection;
@@ -2485,7 +2535,7 @@ var CollectionRegistry = class {
2485
2535
  if (!subcollections || subcollections.length === 0) throw new Error(`No subcollections found for ${currentCollection.slug} in path: ${path}`);
2486
2536
  const subcollection = subcollections.find((c) => c.slug === subcollectionSlug);
2487
2537
  if (!subcollection) throw new Error(`Subcollection '${subcollectionSlug}' not found in ${currentCollection.slug}`);
2488
- currentCollection = this.get(subcollection.slug) || this.normalizeCollection(subcollection);
2538
+ currentCollection = this.normalizeCollection(subcollection);
2489
2539
  collections.push(currentCollection);
2490
2540
  }
2491
2541
  }
@@ -4054,6 +4104,34 @@ var RestApiGenerator = class {
4054
4104
  this.enforceApiKeyPermission(c, collectionPath.split("/").pop());
4055
4105
  }
4056
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
+ /**
4057
4135
  * Get the request-scoped driver. Throws if none is set — never falls
4058
4136
  * back to the unscoped `this.driver` to avoid bypassing RLS/auth.
4059
4137
  */
@@ -4294,7 +4372,9 @@ var RestApiGenerator = class {
4294
4372
  }) : 0;
4295
4373
  return c.json({ count: total });
4296
4374
  } else if (parsed.id) {
4297
- 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({
4298
4378
  path: parsed.collectionPath,
4299
4379
  id: parsed.id
4300
4380
  });
@@ -4304,13 +4384,18 @@ var RestApiGenerator = class {
4304
4384
  const queryDict = c.req.queries();
4305
4385
  const queryOptions = this.parseQuery(queryDict);
4306
4386
  const searchString = Array.isArray(queryDict.searchString) ? queryDict.searchString[queryDict.searchString.length - 1] : void 0;
4307
- const entities = await driver.fetchCollection({
4308
- path: parsed.collectionPath,
4387
+ const fetchService = driver.restFetchService;
4388
+ const listOptions = {
4309
4389
  filter: queryOptions.where,
4310
4390
  limit: queryOptions.limit,
4391
+ offset: queryOptions.offset,
4311
4392
  orderBy: queryOptions.orderBy?.[0]?.field,
4312
4393
  order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
4313
4394
  searchString
4395
+ };
4396
+ const entities = fetchService ? await fetchService.fetchCollectionForRest(parsed.collectionPath, listOptions, queryOptions.include) : await driver.fetchCollection({
4397
+ path: parsed.collectionPath,
4398
+ ...listOptions
4314
4399
  });
4315
4400
  const total = driver.count ? await driver.count({
4316
4401
  path: parsed.collectionPath,
@@ -4336,6 +4421,8 @@ var RestApiGenerator = class {
4336
4421
  const driver = this.getScopedDriver(c);
4337
4422
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
4338
4423
  const body = await parseJsonBody(c);
4424
+ const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
4425
+ if (targetCollection) assertKnownWriteFields(body, targetCollection);
4339
4426
  const entity = await driver.save({
4340
4427
  path: parsed.collectionPath,
4341
4428
  values: body,
@@ -4352,6 +4439,8 @@ var RestApiGenerator = class {
4352
4439
  const driver = this.getScopedDriver(c);
4353
4440
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
4354
4441
  const body = await parseJsonBody(c);
4442
+ const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
4443
+ if (targetCollection) assertKnownWriteFields(body, targetCollection);
4355
4444
  const entity = await driver.save({
4356
4445
  path: parsed.collectionPath,
4357
4446
  id: parsed.id,
@@ -12888,6 +12977,7 @@ async function verifyGoogleAccessToken(accessToken, expectedClientId) {
12888
12977
  function createGoogleProvider(config) {
12889
12978
  const clientId = typeof config === "string" ? config : config.clientId;
12890
12979
  const clientSecret = typeof config === "string" ? void 0 : config.clientSecret;
12980
+ if (!clientSecret) console.warn("[Rebase] Google provider configured without a client secret. ID-token and access-token sign-in still work, but the authorization-code flow used by the admin login button will fail. Set GOOGLE_CLIENT_SECRET to enable it.");
12891
12981
  let googleClient;
12892
12982
  async function getClient() {
12893
12983
  if (!googleClient) {
@@ -15044,7 +15134,7 @@ function assertStorageAccessControlConfigured(state, isProduction) {
15044
15134
  //#region src/init/docs.ts
15045
15135
  async function mountOpenApiDocs(app, basePath, enableSwagger, activeCollections, requireAuth) {
15046
15136
  if (enableSwagger === false || activeCollections.length === 0) return;
15047
- const { generateOpenApiSpec } = await import("./openapi-generator-DqSIwNLV.js");
15137
+ const { generateOpenApiSpec } = await import("./openapi-generator-C3_pTcsb.js");
15048
15138
  app.get(`${basePath}/docs`, (c) => {
15049
15139
  const spec = generateOpenApiSpec(activeCollections, {
15050
15140
  basePath,
@@ -15546,6 +15636,34 @@ function createAuth(transport, options) {
15546
15636
  transport.setToken(null);
15547
15637
  emit("SIGNED_OUT", null);
15548
15638
  }
15639
+ /**
15640
+ * Recover from a 401 on an ordinary API request.
15641
+ *
15642
+ * Returns `true` when the caller should retry — we minted a fresh access
15643
+ * token. When the refresh is rejected *fatally* (the refresh token itself
15644
+ * is invalid, expired or revoked) this client can no longer act as the
15645
+ * user at all, so we drop the session and emit `SIGNED_OUT`. UIs gate on
15646
+ * that event, so they show their login screen instead of leaving the user
15647
+ * staring at "Invalid or expired token" on every view.
15648
+ *
15649
+ * Transient failures (offline, 5xx, backend restarting) keep the session:
15650
+ * the scheduled refresh backs off and retries, and the token is very
15651
+ * likely still good once the backend answers again.
15652
+ */
15653
+ async function handleUnauthorized() {
15654
+ if (!currentSession) return false;
15655
+ if (authFlowMode !== "cookie" && !currentSession.refreshToken) {
15656
+ abandonSessionLocally();
15657
+ return false;
15658
+ }
15659
+ try {
15660
+ await refreshSession();
15661
+ return true;
15662
+ } catch (err) {
15663
+ if (isFatalRefreshError(err)) abandonSessionLocally();
15664
+ return false;
15665
+ }
15666
+ }
15549
15667
  async function attemptScheduledRefresh(attempt) {
15550
15668
  try {
15551
15669
  await refreshSession();
@@ -16047,6 +16165,7 @@ function createAuth(transport, options) {
16047
16165
  signInWithSpotify,
16048
16166
  signOut,
16049
16167
  refreshSession,
16168
+ handleUnauthorized,
16050
16169
  getUser,
16051
16170
  findUserByEmail,
16052
16171
  updateUser,
@@ -20190,14 +20309,7 @@ function createRebaseClient(options) {
20190
20309
  } catch (e) {}
20191
20310
  return session?.accessToken || options.token || "";
20192
20311
  },
20193
- onUnauthorized: options.onUnauthorized || (async () => {
20194
- try {
20195
- await auth.refreshSession();
20196
- return true;
20197
- } catch (e) {
20198
- return false;
20199
- }
20200
- })
20312
+ onUnauthorized: options.onUnauthorized || (() => auth.handleUnauthorized())
20201
20313
  });
20202
20314
  auth.onAuthStateChange((event, session) => {
20203
20315
  if (!ws) return;
@@ -20207,14 +20319,7 @@ function createRebaseClient(options) {
20207
20319
  }
20208
20320
  });
20209
20321
  }
20210
- if (!options.onUnauthorized) transport.setOnUnauthorized(async () => {
20211
- try {
20212
- await auth.refreshSession();
20213
- return true;
20214
- } catch (e) {
20215
- return false;
20216
- }
20217
- });
20322
+ if (!options.onUnauthorized) transport.setOnUnauthorized(() => auth.handleUnauthorized());
20218
20323
  /**
20219
20324
  * Suggest the closest known collection key for a mistyped accessor.
20220
20325
  * Uses edit-distance-1 and prefix matching — no external dependency.
@@ -20952,7 +21057,7 @@ async function _initializeRebaseBackend(config) {
20952
21057
  if (schemaEditorEnabled && config.collectionsDir) {
20953
21058
  let editorModule;
20954
21059
  try {
20955
- editorModule = await import("./schema-editor-routes-D3ef8zu1.js");
21060
+ editorModule = await import("./schema-editor-routes-CF-g0gVN.js");
20956
21061
  } catch (err) {
20957
21062
  if (err?.code === "ERR_MODULE_NOT_FOUND") logger.warn("Schema Editor disabled: its dependency ts-morph is not installed. Run `npm install ts-morph@28.0.0` to enable it.");
20958
21063
  else throw err;
@@ -22461,8 +22566,11 @@ var DEV_PORT_FILENAME = ".rebase-dev-port";
22461
22566
  * Try to `listen` on `startPort`. If the port is busy (`EADDRINUSE`), increment
22462
22567
  * and retry up to `maxAttempts` times.
22463
22568
  *
22464
- * When a port file already exists (written by a previous run), the saved port
22465
- * is tried first to maintain port affinity across tsx watch restarts.
22569
+ * When a port file written by a previous run exists *and that run asked for the
22570
+ * same `startPort`*, the port it landed on is tried first, so tsx watch restarts
22571
+ * keep the address the frontend was configured with. A different `startPort` means
22572
+ * the configuration changed and the file is ignored — an explicitly requested port
22573
+ * is never overridden by a stale one.
22466
22574
  *
22467
22575
  * Resolves with the port that was actually bound.
22468
22576
  *
@@ -22490,8 +22598,10 @@ function listenWithPortRetry(server, startPort, options) {
22490
22598
  if (portFileDir) try {
22491
22599
  const portFile = path.join(portFileDir, DEV_PORT_FILENAME);
22492
22600
  if (fs.existsSync(portFile)) {
22493
- const saved = parseInt(fs.readFileSync(portFile, "utf-8").trim(), 10);
22494
- if (saved > 0 && saved < 65536 && saved !== startPort) affinityPort = saved;
22601
+ const [savedRaw, requestedRaw] = fs.readFileSync(portFile, "utf-8").trim().split(/\s+/);
22602
+ const saved = parseInt(savedRaw, 10);
22603
+ const requestedThen = requestedRaw === void 0 ? NaN : parseInt(requestedRaw, 10);
22604
+ if (saved > 0 && saved < 65536 && saved !== startPort && (Number.isNaN(requestedThen) || requestedThen === startPort)) affinityPort = saved;
22495
22605
  }
22496
22606
  } catch {}
22497
22607
  return new Promise((resolve, reject) => {
@@ -22509,24 +22619,29 @@ function listenWithPortRetry(server, startPort, options) {
22509
22619
  }
22510
22620
  const port = portsToTry[index];
22511
22621
  attempt++;
22512
- const onError = (err) => {
22513
- if (err.code === "EADDRINUSE") {
22514
- server.removeListener("error", onError);
22515
- tryNext(index + 1);
22516
- } else reject(err);
22517
- };
22518
- server.once("error", onError);
22519
- server.listen(port, host, () => {
22520
- server.removeListener("error", onError);
22622
+ const onListening = () => {
22623
+ cleanup();
22521
22624
  if (portFileDir) {
22522
22625
  try {
22523
22626
  const portFile = path.join(portFileDir, DEV_PORT_FILENAME);
22524
- fs.writeFileSync(portFile, String(port), "utf-8");
22627
+ fs.writeFileSync(portFile, `${port} ${startPort}`, "utf-8");
22525
22628
  } catch {}
22526
22629
  writeStateFile(portFileDir, port, options?.serviceKey);
22527
22630
  }
22528
22631
  resolve(port);
22529
- });
22632
+ };
22633
+ const onError = (err) => {
22634
+ cleanup();
22635
+ if (err.code === "EADDRINUSE") tryNext(index + 1);
22636
+ else reject(err);
22637
+ };
22638
+ function cleanup() {
22639
+ server.removeListener("listening", onListening);
22640
+ server.removeListener("error", onError);
22641
+ }
22642
+ server.once("error", onError);
22643
+ server.once("listening", onListening);
22644
+ server.listen(port, host);
22530
22645
  }
22531
22646
  tryNext(0);
22532
22647
  });