@rebasepro/types 0.22.0 → 0.24.0

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/index.es.js CHANGED
@@ -499,6 +499,7 @@ var ADMIN_PROPERTY_KEYS = [
499
499
  "disabled",
500
500
  "expanded",
501
501
  "Field",
502
+ "filledByServer",
502
503
  "Filter",
503
504
  "filterOperators",
504
505
  "fixedFilter",
@@ -700,12 +701,29 @@ var DEFAULT_CAPABILITIES = {
700
701
  supportsDocumentAdmin: true,
701
702
  supportsSchemaAdmin: true
702
703
  };
703
- var CAPABILITIES_REGISTRY = {
704
- postgres: POSTGRES_CAPABILITIES,
705
- firestore: FIREBASE_CAPABILITIES,
706
- mongodb: MONGODB_CAPABILITIES,
707
- "(default)": DEFAULT_CAPABILITIES
708
- };
704
+ var BUILT_IN_CAPABILITIES = /* @__PURE__ */ new Map([
705
+ ["postgres", POSTGRES_CAPABILITIES],
706
+ ["firestore", FIREBASE_CAPABILITIES],
707
+ ["mongodb", MONGODB_CAPABILITIES],
708
+ ["(default)", DEFAULT_CAPABILITIES]
709
+ ]);
710
+ /**
711
+ * Capabilities registered at runtime, on `globalThis` rather than in a module
712
+ * local: a third-party driver inlines its own copy of this package and
713
+ * registers through it, while the app and the server read through theirs. A
714
+ * module local made the registration land in a copy nobody else reads.
715
+ * See `module-state-across-copies.test.ts`.
716
+ */
717
+ var REGISTERED_CAPABILITIES_KEY = Symbol.for("@rebasepro/types.dataSourceCapabilities.v1");
718
+ function registeredCapabilities() {
719
+ const g = globalThis;
720
+ let registered = g[REGISTERED_CAPABILITIES_KEY];
721
+ if (!registered) {
722
+ registered = /* @__PURE__ */ new Map();
723
+ g[REGISTERED_CAPABILITIES_KEY] = registered;
724
+ }
725
+ return registered;
726
+ }
709
727
  /**
710
728
  * Look up capabilities for a given engine key.
711
729
  * If `engine` is undefined or not found, returns `DEFAULT_CAPABILITIES`.
@@ -713,14 +731,14 @@ var CAPABILITIES_REGISTRY = {
713
731
  */
714
732
  function getDataSourceCapabilities(engine) {
715
733
  if (!engine) return POSTGRES_CAPABILITIES;
716
- return CAPABILITIES_REGISTRY[engine] ?? DEFAULT_CAPABILITIES;
734
+ return registeredCapabilities().get(engine) ?? BUILT_IN_CAPABILITIES.get(engine) ?? DEFAULT_CAPABILITIES;
717
735
  }
718
736
  /**
719
737
  * Register custom capabilities for a third-party driver.
720
738
  * @group Models
721
739
  */
722
740
  function registerDataSourceCapabilities(capabilities) {
723
- CAPABILITIES_REGISTRY[capabilities.key] = capabilities;
741
+ registeredCapabilities().set(capabilities.key, capabilities);
724
742
  }
725
743
  //#endregion
726
744
  //#region src/types/collections.ts
@@ -1131,14 +1149,281 @@ function isBranchAdmin(admin) {
1131
1149
  }
1132
1150
  //#endregion
1133
1151
  //#region src/types/schema_editing.ts
1134
- var DEFAULT_COMMIT_PATHS = {
1135
- schemaFile: "backend/src/schema.generated.ts",
1136
- ddlFile: "drizzle/schema.sql",
1137
- policiesFile: "drizzle/policies.sql",
1138
- searchFile: "drizzle/search.sql",
1139
- vectorFile: "drizzle/vector.sql",
1140
- triggersFile: "drizzle/triggers.sql"
1141
- };
1152
+ /**
1153
+ * The SQL `db push` and `db generate` render — `schema.sql`, `policies.sql` and
1154
+ * the rest — is not here, and must not come back. Both commands regenerate it
1155
+ * from the collections before reading it, so nothing ever read a committed
1156
+ * copy; it lives in the gitignored `.rebase/sql/`. When the commit carried it,
1157
+ * it carried a second copy besides: these paths are from the project root,
1158
+ * while the CLI writes beside the backend, so a live edit left `drizzle/` and
1159
+ * `backend/drizzle/` disagreeing about the schema.
1160
+ */
1161
+ var DEFAULT_COMMIT_PATHS = { schemaFile: "backend/src/schema.generated.ts" };
1162
+ //#endregion
1163
+ //#region src/types/collection_patch.ts
1164
+ /**
1165
+ * What changed about a collection, as opposed to what the collection is.
1166
+ *
1167
+ * ## Why a save is a patch
1168
+ *
1169
+ * The admin panel edits a collection as JSON — the only form a browser and a
1170
+ * server can exchange it in — and JSON cannot carry most of what a collection
1171
+ * file holds: an entity action's `onClick`, a computed field's `value`, a
1172
+ * property shared from another module (`status: statusProperty`), an enum
1173
+ * imported from elsewhere (`enum: LOCALE_ENUM`), the comments around all of
1174
+ * them. A save that posted the whole collection and had it written back
1175
+ * rewrote every key it carried and deleted everything JSON had dropped, so
1176
+ * renaming a collection removed its actions' handlers, inlined its shared
1177
+ * property without the custom `Field`, and froze a copy of the enum.
1178
+ *
1179
+ * So a save sends what the person changed: the difference between the JSON
1180
+ * the editor loaded and the JSON it is saving. Anything JSON could not carry
1181
+ * is identical on both sides of that difference — dropped from both — and
1182
+ * cannot appear in it. The server applies the patch to the collection as it
1183
+ * is (for the plan) and writes only the patched keys into the file.
1184
+ *
1185
+ * ## The shape
1186
+ *
1187
+ * Two operations on paths of object keys. Arrays are values: an array that
1188
+ * changed is set whole. Positions in an array are not stable identities —
1189
+ * element 2 of an enum is not "the third value" once a value is inserted —
1190
+ * and the writer is the one place that can match an old element to its new
1191
+ * self (by `key`/`id`/`name`) and keep what JSON dropped from it.
1192
+ *
1193
+ * Paths are in the AUTHORING shape — presentation keys under `admin`, the
1194
+ * shape the file has — see {@link nestCollectionPatchPaths}.
1195
+ */
1196
+ /**
1197
+ * Keys the runtime adds to a collection that must never be written into its
1198
+ * source. `resolvedRelation` is the registry's resolved form of a relation; a
1199
+ * file holding one does not type-check (`target` is missing from it).
1200
+ */
1201
+ var RUNTIME_ONLY_COLLECTION_KEYS = ["resolvedRelation"];
1202
+ /** Path segments that reach an object's prototype rather than a key of it. */
1203
+ var UNSAFE_SEGMENTS = /* @__PURE__ */ new Set([
1204
+ "__proto__",
1205
+ "constructor",
1206
+ "prototype"
1207
+ ]);
1208
+ var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1209
+ /** A JSON value as JSON would carry it: functions, `undefined` and symbols dropped. */
1210
+ function toCollectionJson(value) {
1211
+ if (value === void 0) return void 0;
1212
+ const text = JSON.stringify(value);
1213
+ return text === void 0 ? void 0 : JSON.parse(text);
1214
+ }
1215
+ function jsonEqual(a, b) {
1216
+ if (a === b) return true;
1217
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((item, index) => jsonEqual(item, b[index]));
1218
+ if (isPlainObject(a) && isPlainObject(b)) {
1219
+ const keys = Object.keys(a);
1220
+ if (keys.length !== Object.keys(b).length) return false;
1221
+ return keys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && jsonEqual(a[key], b[key]));
1222
+ }
1223
+ return false;
1224
+ }
1225
+ /**
1226
+ * The operations that take `before` to `after`, both JSON values.
1227
+ *
1228
+ * Objects are compared key by key; anything else that differs — a primitive,
1229
+ * an array, a change of kind — is set whole.
1230
+ */
1231
+ function diffJsonValues(before, after, path = []) {
1232
+ if (jsonEqual(before, after)) return [];
1233
+ if (isPlainObject(before) && isPlainObject(after)) {
1234
+ const ops = [];
1235
+ for (const key of Object.keys(after)) if (!Object.prototype.hasOwnProperty.call(before, key)) ops.push({
1236
+ op: "set",
1237
+ path: [...path, key],
1238
+ value: after[key]
1239
+ });
1240
+ else ops.push(...diffJsonValues(before[key], after[key], [...path, key]));
1241
+ for (const key of Object.keys(before)) if (!Object.prototype.hasOwnProperty.call(after, key)) ops.push({
1242
+ op: "remove",
1243
+ path: [...path, key]
1244
+ });
1245
+ return ops;
1246
+ }
1247
+ return [{
1248
+ op: "set",
1249
+ path,
1250
+ value: after
1251
+ }];
1252
+ }
1253
+ function step(position, segment) {
1254
+ switch (position) {
1255
+ case "collection": return segment === "properties" ? "properties" : "other";
1256
+ case "properties": return "property";
1257
+ case "property":
1258
+ if (segment === "properties") return "properties";
1259
+ if (segment === "of") return "property";
1260
+ if (segment === "oneOf") return "oneOf";
1261
+ return "other";
1262
+ case "oneOf": return segment === "properties" ? "properties" : "other";
1263
+ default: return "other";
1264
+ }
1265
+ }
1266
+ function nestPath(path) {
1267
+ const nested = [];
1268
+ let position = "collection";
1269
+ for (const segment of path) {
1270
+ const moved = position === "collection" && ADMIN_COLLECTION_KEYS.includes(segment) || position === "property" && ADMIN_PROPERTY_KEYS.includes(segment);
1271
+ if (moved) nested.push("admin");
1272
+ nested.push(segment);
1273
+ position = moved ? "other" : step(position, segment);
1274
+ }
1275
+ return {
1276
+ path: nested,
1277
+ position
1278
+ };
1279
+ }
1280
+ function nestValue(position, value) {
1281
+ if (position === "property") {
1282
+ if (Array.isArray(value)) return value.map((entry) => isPlainObject(entry) ? nestAdminPropertyKeys(entry) : entry);
1283
+ return isPlainObject(value) ? nestAdminPropertyKeys(value) : value;
1284
+ }
1285
+ if (position === "properties" && isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, property]) => [key, isPlainObject(property) ? nestAdminPropertyKeys(property) : property]));
1286
+ if (position === "oneOf" && isPlainObject(value)) return isPlainObject(value.properties) ? {
1287
+ ...value,
1288
+ properties: nestValue("properties", value.properties)
1289
+ } : value;
1290
+ if (position === "collection" && isPlainObject(value)) {
1291
+ const nested = nestAdminCollectionKeys(value);
1292
+ return isPlainObject(nested.properties) ? {
1293
+ ...nested,
1294
+ properties: nestValue("properties", nested.properties)
1295
+ } : nested;
1296
+ }
1297
+ return value;
1298
+ }
1299
+ /**
1300
+ * The patch, with every flat presentation key moved into its `admin` block.
1301
+ *
1302
+ * The panel edits a flat view model — `icon` beside `name`, a property's
1303
+ * `readOnly` beside its `type` — while the file keeps them under `admin`. The
1304
+ * difference is taken on the flat model, because that is where a cleared field
1305
+ * shows up as a removed key (the `admin` copy the model was loaded with still
1306
+ * holds the old value), and moved here.
1307
+ *
1308
+ * The flat value wins over the `admin` copy, as in {@link nestAdminKeysOf}:
1309
+ * operations that were already under `admin` are ordered first, so the flat
1310
+ * one — what the form just wrote — is applied last.
1311
+ */
1312
+ function nestCollectionPatchPaths(patch) {
1313
+ return patch.map((op, index) => {
1314
+ const { path, position } = nestPath(op.path);
1315
+ const wasNested = op.path.includes("admin");
1316
+ return {
1317
+ moved: op.op === "remove" ? {
1318
+ op: "remove",
1319
+ path
1320
+ } : {
1321
+ op: "set",
1322
+ path,
1323
+ value: nestValue(position, op.value)
1324
+ },
1325
+ wasNested,
1326
+ index
1327
+ };
1328
+ }).sort((a, b) => a.wasNested === b.wasNested ? a.index - b.index : a.wasNested ? -1 : 1).map((entry) => entry.moved);
1329
+ }
1330
+ /**
1331
+ * The patch that takes the collection the editor loaded to the one it is
1332
+ * saving, in the authoring shape. Empty when nothing changed.
1333
+ *
1334
+ * Both sides go through JSON first, so whatever JSON cannot carry — a
1335
+ * function, a value it drops — is equally absent from both and never appears
1336
+ * as a change.
1337
+ */
1338
+ function diffCollections(loaded, saving) {
1339
+ return nestCollectionPatchPaths(diffJsonValues(toCollectionJson(loaded), toCollectionJson(saving)));
1340
+ }
1341
+ /**
1342
+ * Why a patch may not be applied, or an empty list.
1343
+ *
1344
+ * Checked by every door that accepts one: a path is a list of non-empty object
1345
+ * keys that cannot reach a prototype, and no runtime-only key may be written.
1346
+ */
1347
+ function collectionPatchProblems(patch) {
1348
+ if (!Array.isArray(patch)) return ["`patch` must be a list of operations."];
1349
+ const problems = [];
1350
+ patch.forEach((op, index) => {
1351
+ const where = `patch[${index}]`;
1352
+ if (!isPlainObject(op) || op.op !== "set" && op.op !== "remove") {
1353
+ problems.push(`${where} must be { op: "set", path, value } or { op: "remove", path }.`);
1354
+ return;
1355
+ }
1356
+ const path = op.path;
1357
+ if (!Array.isArray(path) || path.length === 0 || !path.every((segment) => typeof segment === "string" && segment.length > 0)) {
1358
+ problems.push(`${where}.path must be a non-empty list of object keys.`);
1359
+ return;
1360
+ }
1361
+ const unsafe = path.find((segment) => UNSAFE_SEGMENTS.has(segment));
1362
+ if (unsafe) problems.push(`${where}.path may not contain "${unsafe}".`);
1363
+ const runtime = path.find((segment) => RUNTIME_ONLY_COLLECTION_KEYS.includes(segment));
1364
+ if (runtime) problems.push(`${where} writes "${runtime}", which the runtime adds and a collection file must not hold.`);
1365
+ if (op.op === "set") {
1366
+ const key = keyIn(op.value, (key) => UNSAFE_SEGMENTS.has(key) || RUNTIME_ONLY_COLLECTION_KEYS.includes(key));
1367
+ if (key) problems.push(`${where}.value holds "${key}", which a collection file must not hold.`);
1368
+ }
1369
+ });
1370
+ return problems;
1371
+ }
1372
+ /**
1373
+ * The first runtime-only key (see {@link RUNTIME_ONLY_COLLECTION_KEYS}) found
1374
+ * anywhere in a value, or `undefined`. For the doors that take a whole
1375
+ * collection rather than a patch.
1376
+ */
1377
+ function runtimeOnlyKeyIn(value) {
1378
+ return keyIn(value, (key) => RUNTIME_ONLY_COLLECTION_KEYS.includes(key));
1379
+ }
1380
+ /** A patch every door may apply: {@link collectionPatchProblems} found nothing. */
1381
+ function isCollectionPatch(patch) {
1382
+ return collectionPatchProblems(patch).length === 0;
1383
+ }
1384
+ function keyIn(value, matches) {
1385
+ if (Array.isArray(value)) {
1386
+ for (const item of value) {
1387
+ const found = keyIn(item, matches);
1388
+ if (found) return found;
1389
+ }
1390
+ return;
1391
+ }
1392
+ if (!isPlainObject(value)) return void 0;
1393
+ for (const key of Object.keys(value)) {
1394
+ if (matches(key)) return key;
1395
+ const found = keyIn(value[key], matches);
1396
+ if (found) return found;
1397
+ }
1398
+ }
1399
+ /**
1400
+ * `target` with the patch applied, copying only what it changes.
1401
+ *
1402
+ * Everything the patch does not reach is shared with `target` by reference —
1403
+ * functions, thunks and all — which is what lets a planner see a relation's
1404
+ * `target()` on the patched collection exactly as it was. Intermediate objects
1405
+ * a `set` needs are created; a `remove` of a key that is not there is nothing
1406
+ * to do.
1407
+ */
1408
+ function applyCollectionPatch(target, patch) {
1409
+ let root = target;
1410
+ for (const op of patch) root = applyOp(root, op.path, op);
1411
+ return root;
1412
+ }
1413
+ function applyOp(node, path, op) {
1414
+ const [head, ...rest] = path;
1415
+ if (UNSAFE_SEGMENTS.has(head)) throw new Error(`A collection patch may not reach "${head}".`);
1416
+ const base = isPlainObject(node) ? { ...node } : {};
1417
+ if (rest.length === 0) {
1418
+ if (op.op === "remove") delete base[head];
1419
+ else base[head] = op.value;
1420
+ return base;
1421
+ }
1422
+ const child = Object.prototype.hasOwnProperty.call(base, head) ? base[head] : void 0;
1423
+ if (op.op === "remove" && !isPlainObject(child)) return node;
1424
+ base[head] = applyOp(child, rest, op);
1425
+ return base;
1426
+ }
1142
1427
  //#endregion
1143
1428
  //#region src/types/channel_bus.ts
1144
1429
  /**
@@ -1663,10 +1948,15 @@ amendResourceKind("bucket", {
1663
1948
  "S3_FORCE_PATH_STYLE",
1664
1949
  "GCS_BUCKET",
1665
1950
  "GCS_PROJECT_ID",
1666
- "GCS_KEY_FILENAME"
1951
+ "GCS_KEY_FILENAME",
1952
+ "STORAGE_MAX_FILE_SIZE"
1667
1953
  ],
1668
1954
  envBasesByEngine: {
1669
- local: ["STORAGE_TYPE", "STORAGE_PATH"],
1955
+ local: [
1956
+ "STORAGE_TYPE",
1957
+ "STORAGE_PATH",
1958
+ "STORAGE_MAX_FILE_SIZE"
1959
+ ],
1670
1960
  s3: [
1671
1961
  "STORAGE_TYPE",
1672
1962
  "S3_BUCKET",
@@ -1674,13 +1964,15 @@ amendResourceKind("bucket", {
1674
1964
  "S3_ACCESS_KEY_ID",
1675
1965
  "S3_SECRET_ACCESS_KEY",
1676
1966
  "S3_ENDPOINT",
1677
- "S3_FORCE_PATH_STYLE"
1967
+ "S3_FORCE_PATH_STYLE",
1968
+ "STORAGE_MAX_FILE_SIZE"
1678
1969
  ],
1679
1970
  gcs: [
1680
1971
  "STORAGE_TYPE",
1681
1972
  "GCS_BUCKET",
1682
1973
  "GCS_PROJECT_ID",
1683
- "GCS_KEY_FILENAME"
1974
+ "GCS_KEY_FILENAME",
1975
+ "STORAGE_MAX_FILE_SIZE"
1684
1976
  ],
1685
1977
  azure: [],
1686
1978
  firebase: []
@@ -1698,19 +1990,73 @@ registerResourceKind({
1698
1990
  implicitDefault: false
1699
1991
  });
1700
1992
  amendResourceKind("topic", { envBases: [] });
1701
- var runtimeHolder = { current: null };
1993
+ /**
1994
+ * Declared handlers and installed runtimes, for topics and queues — on
1995
+ * `globalThis` under a shared symbol, never in module locals.
1996
+ *
1997
+ * A module local is one per COPY of this package, and two copies in one
1998
+ * process is the normal layout rather than an accident: `@rebasepro/server`
1999
+ * inlines this package into its own dist, while `config/resources.ts` imports
2000
+ * it from `node_modules`. With locals, a project's `.handler()` and
2001
+ * `.subscription()` went into the project's copy and the worker read the
2002
+ * server's empty one (`Job queue started {"tasks":0}`), and the runtime the
2003
+ * server installed was invisible to the project's `enqueue`/`publish` — so
2004
+ * every queue and topic threw "no runtime is installed" inside a running
2005
+ * backend. The CLI deriving the graph had the same split. Same mechanism and
2006
+ * reasoning as `registry()` in resources.ts; `module-state-across-copies.test.ts`
2007
+ * loads two copies to hold it, and scans for any other module-level state that
2008
+ * would split the same way.
2009
+ *
2010
+ * The version is in the symbol: a change to this object's SHAPE takes a new
2011
+ * suffix, so no copy ever reads a shape it does not know.
2012
+ */
2013
+ var WORK_KEY = Symbol.for("@rebasepro/types.workRegistry.v1");
2014
+ function work() {
2015
+ const g = globalThis;
2016
+ let shared = g[WORK_KEY];
2017
+ if (!shared) {
2018
+ shared = {
2019
+ topicRuntime: null,
2020
+ subscriptions: [],
2021
+ queueRuntime: null,
2022
+ queueConsumers: /* @__PURE__ */ new Map()
2023
+ };
2024
+ g[WORK_KEY] = shared;
2025
+ }
2026
+ return shared;
2027
+ }
2028
+ /**
2029
+ * Where `@rebasepro/server` publishes its client once it has booted (its
2030
+ * `singleton.ts`). Read only to tell the two causes of a missing runtime apart.
2031
+ */
2032
+ var SERVER_INSTANCE_SLOT = Symbol.for("@rebasepro/server:singleton-instance");
2033
+ /**
2034
+ * Why there is no runtime to send through, as far as this process can tell.
2035
+ *
2036
+ * Outside a backend — the CLI evaluating config, a script, a unit test — the
2037
+ * answer is that there is nothing to deliver to. Inside one it is not: the
2038
+ * backend installs a runtime at boot for what `config/resources.ts` declared,
2039
+ * so a missing one there means boot never saw the declaration, or the backend
2040
+ * carries a copy of this package too old to share the slot it installs into.
2041
+ * Saying "outside a backend" to somebody whose backend is running sends them
2042
+ * looking for the wrong thing.
2043
+ */
2044
+ function missingRuntimeCause(kind) {
2045
+ if (!(globalThis[SERVER_INSTANCE_SLOT] != null)) return "This works inside a running Rebase backend; this looks like code running outside one (a build, a script, or a test without a harness).";
2046
+ return `A Rebase backend is running in this process but installed no ${kind} runtime. It installs one at boot when config/resources.ts declares a ${kind}, so either this ${kind} is declared somewhere boot does not read, or the backend's copy of @rebasepro/types predates the shared runtime slot this copy reads — keep @rebasepro/server and @rebasepro/types on the same version.`;
2047
+ }
1702
2048
  /** Install the transport topics publish through. Called by the server at boot. */
1703
2049
  function setTopicRuntime(runtime) {
1704
- runtimeHolder.current = runtime;
2050
+ work().topicRuntime = runtime;
1705
2051
  }
1706
- var subscriptions = [];
1707
2052
  /** Every declared subscription, for the worker to wire and the graph to record. */
1708
2053
  function declaredSubscriptions(topic) {
2054
+ const { subscriptions } = work();
1709
2055
  return topic ? subscriptions.filter((s) => s.topic === topic) : subscriptions.slice();
1710
2056
  }
1711
2057
  /** Forget declared subscriptions. For tests, alongside `resetDeclaredResources`. */
1712
2058
  function resetDeclaredSubscriptions() {
1713
- subscriptions.length = 0;
2059
+ work().subscriptions.length = 0;
1714
2060
  }
1715
2061
  /**
1716
2062
  * Declare a topic.
@@ -1729,12 +2075,13 @@ function topic(key, options = {}) {
1729
2075
  return key;
1730
2076
  },
1731
2077
  async publish(event) {
1732
- const runtime = runtimeHolder.current;
1733
- if (!runtime) throw new Error(`Cannot publish to topic "${key}": no topic runtime is installed. Publishing works inside a running Rebase backend; this looks like config being evaluated outside one (a build, a script, or a test without a harness).`);
2078
+ const runtime = work().topicRuntime;
2079
+ if (!runtime) throw new Error(`Cannot publish to topic "${key}": no topic runtime is installed. ${missingRuntimeCause("topic")}`);
1734
2080
  await runtime.publish(key, event);
1735
2081
  },
1736
2082
  subscription(name, handler, subOptions = {}) {
1737
2083
  if (!name || name.trim() === "") throw new Error(`A subscription on topic "${key}" needs a non-empty name.`);
2084
+ const { subscriptions } = work();
1738
2085
  if (subscriptions.some((s) => s.topic === key && s.name === name)) throw new Error(`Topic "${key}" already has a subscription named "${name}". The name is what a job row records and what a retry counts against, so two cannot share one.`);
1739
2086
  subscriptions.push({
1740
2087
  topic: key,
@@ -1797,19 +2144,17 @@ registerResourceKind({
1797
2144
  optionKeys: ["maxAttempts"],
1798
2145
  implicitDefault: false
1799
2146
  });
1800
- var queueRuntimeHolder = { current: null };
1801
2147
  /** Install the transport queues enqueue through. Called by the server at boot. */
1802
2148
  function setQueueRuntime(runtime) {
1803
- queueRuntimeHolder.current = runtime;
2149
+ work().queueRuntime = runtime;
1804
2150
  }
1805
- var queueConsumers = /* @__PURE__ */ new Map();
1806
2151
  /** Every declared queue handler, for the worker to wire. */
1807
2152
  function declaredQueueConsumers() {
1808
- return [...queueConsumers.values()];
2153
+ return [...work().queueConsumers.values()];
1809
2154
  }
1810
2155
  /** Forget declared queue handlers. For tests, alongside `resetDeclaredResources`. */
1811
2156
  function resetDeclaredQueueConsumers() {
1812
- queueConsumers.clear();
2157
+ work().queueConsumers.clear();
1813
2158
  }
1814
2159
  /**
1815
2160
  * Declare a queue.
@@ -1831,11 +2176,12 @@ function queue(key, options = {}) {
1831
2176
  return key;
1832
2177
  },
1833
2178
  async enqueue(payload, enqueueOptions) {
1834
- const runtime = queueRuntimeHolder.current;
1835
- if (!runtime) throw new Error(`Cannot enqueue on queue "${key}": no queue runtime is installed. Enqueueing works inside a running Rebase backend; this looks like config being evaluated outside one (a build, a script, or a test without a harness).`);
2179
+ const runtime = work().queueRuntime;
2180
+ if (!runtime) throw new Error(`Cannot enqueue on queue "${key}": no queue runtime is installed. ${missingRuntimeCause("queue")}`);
1836
2181
  return runtime.enqueue(key, payload, enqueueOptions);
1837
2182
  },
1838
2183
  handler(fn) {
2184
+ const { queueConsumers } = work();
1839
2185
  if (queueConsumers.has(key)) throw new Error(`Queue "${key}" already has a handler. A queue has exactly one consumer; work that several things react to is a topic with several subscriptions.`);
1840
2186
  queueConsumers.set(key, {
1841
2187
  queue: key,
@@ -1971,6 +2317,426 @@ function isLazyComponentRef(ref) {
1971
2317
  return typeof ref === "object" && ref !== null && "__rebaseLazy" in ref && ref.__rebaseLazy === true;
1972
2318
  }
1973
2319
  //#endregion
2320
+ //#region src/types/auth_adapter.ts
2321
+ /**
2322
+ * The request header that asks for a data-plane request to run as another
2323
+ * user, named by uid.
2324
+ *
2325
+ * The server honours it only on a signed-in administrator's own session —
2326
+ * never on an API key or the service key — and only through an adapter that
2327
+ * implements {@link AuthAdapter.resolveUser}. Everywhere else a request that
2328
+ * carries it is refused, never run as its caller.
2329
+ *
2330
+ * @group Auth
2331
+ */
2332
+ var IMPERSONATE_HEADER = "x-rebase-impersonate";
2333
+ //#endregion
2334
+ //#region src/types/scopes.ts
2335
+ /**
2336
+ * Scopes — what a credential may do, named `resource:action`.
2337
+ *
2338
+ * One vocabulary for every way of reaching a Rebase backend: a person's
2339
+ * session, a service key, a personal key and an OAuth token for `/mcp` all
2340
+ * answer "may this caller do X?" with the same strings, so a grant reads the
2341
+ * same on a key, on a role and on a consent screen.
2342
+ *
2343
+ * A scope may carry a target — `data:read:posts` is `data:read` on the `posts`
2344
+ * collection only. The unqualified scope covers every target. Only the data
2345
+ * plane and app scopes take targets; see {@link SCOPE_TARGETS}.
2346
+ *
2347
+ * Two planes, held differently:
2348
+ *
2349
+ * - **The data plane** (`data:*`, `storage:*`, `functions:invoke`). Every
2350
+ * signed-in person holds all of it, because what a person may do with data
2351
+ * is decided row by row by the collection's `securityRules` and by storage
2352
+ * policies, never by a scope. On a key or a token these scopes narrow: a key
2353
+ * holding `data:read:posts` reaches nothing else, whatever RLS would allow.
2354
+ * - **The admin plane** (everything else). Nobody holds it implicitly. The
2355
+ * `admin` role holds all of it; any other role holds what the app declares
2356
+ * for it under `auth.roles` on the users collection.
2357
+ *
2358
+ * Apps declare their own scopes too (`auth.scopes`), named the same way —
2359
+ * `project:deploy`. Every signed-in person holds every app scope: like the data
2360
+ * plane, the function behind it decides whether the person may act, and the
2361
+ * scope exists so a key can be narrowed to that one action.
2362
+ *
2363
+ * @module
2364
+ */
2365
+ /** @group Auth */
2366
+ var DATA_PLANE_SCOPES = [
2367
+ "data:read",
2368
+ "data:write",
2369
+ "data:delete",
2370
+ "storage:read",
2371
+ "storage:write",
2372
+ "storage:delete",
2373
+ "functions:invoke"
2374
+ ];
2375
+ /** @group Auth */
2376
+ var ADMIN_SCOPES = [
2377
+ "users:read",
2378
+ "users:write",
2379
+ "schema:read",
2380
+ "schema:write",
2381
+ "database:read",
2382
+ "database:write",
2383
+ "backups:read",
2384
+ "cron:read",
2385
+ "cron:write",
2386
+ "logs:read",
2387
+ "keys:read",
2388
+ "keys:write"
2389
+ ];
2390
+ /** Every scope Rebase itself defines, in display order. @group Auth */
2391
+ var BUILT_IN_SCOPES = [...DATA_PLANE_SCOPES, ...ADMIN_SCOPES];
2392
+ /**
2393
+ * Scopes no key may hold.
2394
+ *
2395
+ * A key that could manage keys could mint its own successor, widen itself, or
2396
+ * revoke the keys other integrations run on — and revoking it would undo none
2397
+ * of that. Managing keys is for a person (or the service key).
2398
+ *
2399
+ * @group Auth
2400
+ */
2401
+ var KEY_MANAGEMENT_SCOPES = ["keys:read", "keys:write"];
2402
+ /** The one built-in role. It holds every scope and is the RLS `admin` role. @group Auth */
2403
+ var ADMIN_ROLE = "admin";
2404
+ /**
2405
+ * The built-in scopes that accept a target, and what the target names.
2406
+ *
2407
+ * - `data:*` — a collection slug
2408
+ * - `storage:*` — a storage source id (`(default)` for the default source)
2409
+ * - `functions:invoke` — a custom function's name
2410
+ *
2411
+ * @group Auth
2412
+ */
2413
+ var SCOPE_TARGETS = {
2414
+ "data:read": "collection",
2415
+ "data:write": "collection",
2416
+ "data:delete": "collection",
2417
+ "storage:read": "bucket",
2418
+ "storage:write": "bucket",
2419
+ "storage:delete": "bucket",
2420
+ "functions:invoke": "function"
2421
+ };
2422
+ /** @group Auth */
2423
+ var BUILT_IN_SCOPE_DESCRIPTIONS = {
2424
+ "data:read": {
2425
+ label: "Read data",
2426
+ description: "Read rows from collections, through the caller's row-level security."
2427
+ },
2428
+ "data:write": {
2429
+ label: "Create and update data",
2430
+ description: "Create and update rows in collections, through the caller's row-level security."
2431
+ },
2432
+ "data:delete": {
2433
+ label: "Delete data",
2434
+ description: "Delete rows from collections, through the caller's row-level security."
2435
+ },
2436
+ "storage:read": {
2437
+ label: "Read files",
2438
+ description: "List and download stored files."
2439
+ },
2440
+ "storage:write": {
2441
+ label: "Upload files",
2442
+ description: "Upload files and create folders."
2443
+ },
2444
+ "storage:delete": {
2445
+ label: "Delete files",
2446
+ description: "Delete stored files."
2447
+ },
2448
+ "functions:invoke": {
2449
+ label: "Call functions",
2450
+ description: "Call the backend's custom functions. A function can do anything its code does."
2451
+ },
2452
+ "users:read": {
2453
+ label: "See users",
2454
+ description: "List user accounts and their roles."
2455
+ },
2456
+ "users:write": {
2457
+ label: "Manage users",
2458
+ description: "Create, edit and delete accounts, reset passwords and second factors, and assign roles up to the holder's own."
2459
+ },
2460
+ "schema:read": {
2461
+ label: "See the schema",
2462
+ description: "Read the collection schema, plan schema changes and run the RLS audit."
2463
+ },
2464
+ "schema:write": {
2465
+ label: "Change the schema",
2466
+ description: "Apply schema changes: edits collection files and alters the database."
2467
+ },
2468
+ "database:read": {
2469
+ label: "Inspect the database",
2470
+ description: "List databases, tables, Postgres roles and branches."
2471
+ },
2472
+ "database:write": {
2473
+ label: "Run SQL",
2474
+ description: "Run SQL as the database owner, outside row-level security, and create or delete branches."
2475
+ },
2476
+ "backups:read": {
2477
+ label: "Download backups",
2478
+ description: "List and download database backups — every row, outside row-level security."
2479
+ },
2480
+ "cron:read": {
2481
+ label: "See cron jobs",
2482
+ description: "List cron jobs and read their run history."
2483
+ },
2484
+ "cron:write": {
2485
+ label: "Run cron jobs",
2486
+ description: "Trigger cron jobs and switch them on or off."
2487
+ },
2488
+ "logs:read": {
2489
+ label: "Read server logs",
2490
+ description: "Read the server's request and application logs."
2491
+ },
2492
+ "keys:read": {
2493
+ label: "See service keys",
2494
+ description: "List the project's service keys. Never grantable to a key."
2495
+ },
2496
+ "keys:write": {
2497
+ label: "Manage service keys",
2498
+ description: "Create, change and revoke the project's service keys. Never grantable to a key."
2499
+ }
2500
+ };
2501
+ /** The access model of an app that declares nothing: built-ins only. @group Auth */
2502
+ var EMPTY_ACCESS_MODEL = {
2503
+ roles: {},
2504
+ scopes: {}
2505
+ };
2506
+ var SCOPE_PART = /^[a-z][a-z0-9-]*$/;
2507
+ /**
2508
+ * Split `resource:action[:target]`. Returns null for anything else.
2509
+ *
2510
+ * The target is everything after the second colon, so a target may itself
2511
+ * contain colons. Resource and action are lower-case words.
2512
+ *
2513
+ * @group Auth
2514
+ */
2515
+ function parseScope(value) {
2516
+ if (typeof value !== "string") return null;
2517
+ const first = value.indexOf(":");
2518
+ if (first < 0) return null;
2519
+ const second = value.indexOf(":", first + 1);
2520
+ const resource = value.slice(0, first);
2521
+ const action = second < 0 ? value.slice(first + 1) : value.slice(first + 1, second);
2522
+ if (!SCOPE_PART.test(resource) || !SCOPE_PART.test(action)) return null;
2523
+ if (second < 0) return { scope: `${resource}:${action}` };
2524
+ const target = value.slice(second + 1);
2525
+ if (target.length === 0 || /\s/.test(target)) return null;
2526
+ return {
2527
+ scope: `${resource}:${action}`,
2528
+ target
2529
+ };
2530
+ }
2531
+ /** @group Auth */
2532
+ function isBuiltInScope(scope) {
2533
+ return BUILT_IN_SCOPES.includes(scope);
2534
+ }
2535
+ /** @group Auth */
2536
+ function isDataPlaneScope(scope) {
2537
+ return DATA_PLANE_SCOPES.includes(scope);
2538
+ }
2539
+ /** @group Auth */
2540
+ function isAdminScope(scope) {
2541
+ return ADMIN_SCOPES.includes(scope);
2542
+ }
2543
+ /** Does this list of roles include the built-in `admin` role? @group Auth */
2544
+ function hasAdminRole(roles) {
2545
+ return !!roles?.includes(ADMIN_ROLE);
2546
+ }
2547
+ /**
2548
+ * Does a set of held scopes grant `scope`, optionally on one `target`?
2549
+ *
2550
+ * The unqualified grant covers every target. A qualified grant covers its own
2551
+ * target only, and never answers an unqualified question: a key holding
2552
+ * `data:read:posts` is not asked "may you read data?" and told yes.
2553
+ *
2554
+ * @group Auth
2555
+ */
2556
+ function scopeGrants(held, scope, target) {
2557
+ for (const entry of held) {
2558
+ if (entry === scope) return true;
2559
+ if (target !== void 0 && entry === `${scope}:${target}`) return true;
2560
+ }
2561
+ return false;
2562
+ }
2563
+ /**
2564
+ * Does a set of held scopes grant `scope` on at least one target?
2565
+ *
2566
+ * For a surface that lists what is reachable — the collections an MCP client
2567
+ * may query — and filters per item afterwards.
2568
+ *
2569
+ * @group Auth
2570
+ */
2571
+ function scopeGrantsAny(held, scope) {
2572
+ const prefix = `${scope}:`;
2573
+ return held.some((entry) => entry === scope || entry.startsWith(prefix));
2574
+ }
2575
+ /**
2576
+ * The targets a held set narrows `scope` to: `"all"` when it holds the
2577
+ * unqualified scope, otherwise the listed targets (possibly none).
2578
+ *
2579
+ * @group Auth
2580
+ */
2581
+ function scopeTargets(held, scope) {
2582
+ const prefix = `${scope}:`;
2583
+ const targets = [];
2584
+ for (const entry of held) {
2585
+ if (entry === scope) return "all";
2586
+ if (entry.startsWith(prefix)) targets.push(entry.slice(prefix.length));
2587
+ }
2588
+ return targets;
2589
+ }
2590
+ /**
2591
+ * Every scope a person holding `roles` has.
2592
+ *
2593
+ * The data plane and every app scope always; the whole admin plane with the
2594
+ * `admin` role; otherwise each declared role's scopes. A role nobody declared
2595
+ * adds nothing — it may still mean something to an RLS policy.
2596
+ *
2597
+ * @group Auth
2598
+ */
2599
+ function scopesForRoles(roles, model = EMPTY_ACCESS_MODEL) {
2600
+ const held = /* @__PURE__ */ new Set([...DATA_PLANE_SCOPES, ...Object.keys(model.scopes)]);
2601
+ if (hasAdminRole(roles)) {
2602
+ for (const scope of ADMIN_SCOPES) held.add(scope);
2603
+ return [...held];
2604
+ }
2605
+ for (const role of roles ?? []) {
2606
+ const definition = Object.prototype.hasOwnProperty.call(model.roles, role) ? model.roles[role] : void 0;
2607
+ for (const scope of definition?.scopes ?? []) held.add(scope);
2608
+ }
2609
+ return [...held];
2610
+ }
2611
+ /**
2612
+ * Every scope this access model knows, unqualified: the built-ins, then the
2613
+ * app's own.
2614
+ *
2615
+ * @group Auth
2616
+ */
2617
+ function knownScopes(model = EMPTY_ACCESS_MODEL) {
2618
+ return [...BUILT_IN_SCOPES, ...Object.keys(model.scopes)];
2619
+ }
2620
+ /** Does this scope accept a target? @group Auth */
2621
+ function scopeAcceptsTarget(scope, model = EMPTY_ACCESS_MODEL) {
2622
+ if (isBuiltInScope(scope)) return SCOPE_TARGETS[scope] !== void 0;
2623
+ return (Object.prototype.hasOwnProperty.call(model.scopes, scope) ? model.scopes[scope] : void 0)?.target !== void 0;
2624
+ }
2625
+ /**
2626
+ * Check a list of scope strings against an access model. Returns the
2627
+ * problems, empty when every entry is a known scope with an acceptable target.
2628
+ *
2629
+ * @group Auth
2630
+ */
2631
+ function validateScopes(values, model = EMPTY_ACCESS_MODEL) {
2632
+ const known = new Set(knownScopes(model));
2633
+ const problems = [];
2634
+ for (const value of values) {
2635
+ const text = typeof value === "string" ? value : String(value);
2636
+ const parsed = typeof value === "string" ? parseScope(value) : null;
2637
+ if (!parsed) problems.push({
2638
+ scope: text,
2639
+ reason: "malformed"
2640
+ });
2641
+ else if (!known.has(parsed.scope)) problems.push({
2642
+ scope: text,
2643
+ reason: "unknown"
2644
+ });
2645
+ else if (parsed.target !== void 0 && !scopeAcceptsTarget(parsed.scope, model)) problems.push({
2646
+ scope: text,
2647
+ reason: "target-not-accepted"
2648
+ });
2649
+ }
2650
+ return problems;
2651
+ }
2652
+ /**
2653
+ * The entries of `requested` that `held` does not cover — what a credential
2654
+ * would hold beyond its minter. Empty means the request is within reach.
2655
+ *
2656
+ * `data:read:posts` is covered by `data:read`; `data:read` is not covered by
2657
+ * `data:read:posts`.
2658
+ *
2659
+ * @group Auth
2660
+ */
2661
+ function scopesBeyond(requested, held) {
2662
+ return requested.filter((entry) => {
2663
+ const parsed = parseScope(entry);
2664
+ if (!parsed) return true;
2665
+ return !scopeGrants(held, parsed.scope, parsed.target);
2666
+ });
2667
+ }
2668
+ /**
2669
+ * Narrow `requested` to what `held` covers, entry by entry. What a personal
2670
+ * key holds today when its owner has lost a role since it was minted.
2671
+ *
2672
+ * @group Auth
2673
+ */
2674
+ function intersectScopes(requested, held) {
2675
+ const beyond = new Set(scopesBeyond(requested, held));
2676
+ return requested.filter((entry) => !beyond.has(entry));
2677
+ }
2678
+ /**
2679
+ * How a scope is worded for a person: the built-in description, the app's
2680
+ * declared label, or the raw string when nothing describes it. A target is
2681
+ * named after the label.
2682
+ *
2683
+ * @group Auth
2684
+ */
2685
+ function describeScope(value, model = EMPTY_ACCESS_MODEL) {
2686
+ const parsed = parseScope(value);
2687
+ if (!parsed) return {
2688
+ label: value,
2689
+ description: ""
2690
+ };
2691
+ let base;
2692
+ if (isBuiltInScope(parsed.scope)) base = BUILT_IN_SCOPE_DESCRIPTIONS[parsed.scope];
2693
+ else if (Object.prototype.hasOwnProperty.call(model.scopes, parsed.scope)) {
2694
+ const definition = model.scopes[parsed.scope];
2695
+ base = {
2696
+ label: definition.label,
2697
+ description: definition.description ?? ""
2698
+ };
2699
+ }
2700
+ const label = base?.label ?? parsed.scope;
2701
+ return {
2702
+ label: parsed.target !== void 0 ? `${label}: ${parsed.target}` : label,
2703
+ description: base?.description ?? ""
2704
+ };
2705
+ }
2706
+ /** The roles of an access model, `admin` first. @group Auth */
2707
+ function summarizeRoles(model = EMPTY_ACCESS_MODEL) {
2708
+ return [{
2709
+ id: ADMIN_ROLE,
2710
+ name: "Admin",
2711
+ description: "Holds every scope. Row-level security does not limit it: it reads, changes and deletes every row.",
2712
+ scopes: [...ADMIN_SCOPES],
2713
+ builtIn: true
2714
+ }, ...Object.entries(model.roles).map(([id, definition]) => ({
2715
+ id,
2716
+ name: definition.name ?? id,
2717
+ ...definition.description ? { description: definition.description } : {},
2718
+ scopes: [...definition.scopes],
2719
+ builtIn: false
2720
+ }))];
2721
+ }
2722
+ /** Every scope of an access model, described, in display order. @group Auth */
2723
+ function summarizeScopes(model = EMPTY_ACCESS_MODEL) {
2724
+ const builtIn = BUILT_IN_SCOPES.map((scope) => ({
2725
+ scope,
2726
+ ...BUILT_IN_SCOPE_DESCRIPTIONS[scope],
2727
+ ...SCOPE_TARGETS[scope] ? { target: SCOPE_TARGETS[scope] } : {},
2728
+ plane: isDataPlaneScope(scope) ? "data" : "admin"
2729
+ }));
2730
+ const app = Object.entries(model.scopes).map(([scope, definition]) => ({
2731
+ scope,
2732
+ label: definition.label,
2733
+ description: definition.description ?? "",
2734
+ ...definition.target ? { target: definition.target } : {},
2735
+ plane: "app"
2736
+ }));
2737
+ return [...builtIn, ...app];
2738
+ }
2739
+ //#endregion
1974
2740
  //#region src/types/project_manifest.ts
1975
2741
  /**
1976
2742
  * Path prefixes the backend owns, which no static app may claim.
@@ -2008,6 +2774,91 @@ function reservedPrefixFor(path) {
2008
2774
  return RESERVED_BACKEND_PREFIXES.find((reserved) => normalized === reserved || normalized.startsWith(`${reserved}/`));
2009
2775
  }
2010
2776
  /**
2777
+ * Split a static app's declared `path` into the hostname it claims and the path
2778
+ * under it.
2779
+ *
2780
+ * `"/admin"` is `{ path: "/admin" }`, returned as written: the path rules
2781
+ * (leading slash, no `..`, reserved prefixes) belong to the caller, which
2782
+ * applies them to the path part of either form. `"https://admin.example.com/cms"`
2783
+ * is `{ host: "admin.example.com", path: "/cms" }`.
2784
+ *
2785
+ * Shared because three parties must read the same value the same way: the CLI
2786
+ * while `rebase.json` is edited, the control plane at deploy intake (a bundle can
2787
+ * come from any CLI, or none), and the runtime choosing which app answers.
2788
+ */
2789
+ function parseAppAddress(value) {
2790
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
2791
+ if (!value.startsWith("/") && isValidAppHost(value.toLowerCase().replace(/\/.*$/, ""))) return {
2792
+ ok: false,
2793
+ reason: `looks like a hostname — write it as a URL: "https://${value}"`
2794
+ };
2795
+ return {
2796
+ ok: true,
2797
+ address: { path: value }
2798
+ };
2799
+ }
2800
+ if (/[\s\\\u0000-\u001f\u007f]/.test(value)) return {
2801
+ ok: false,
2802
+ reason: "must not contain spaces, control characters or backslashes"
2803
+ };
2804
+ const authority = value.slice(value.indexOf("//") + 2).split(/[/?#]/)[0];
2805
+ if (/:[^@]*$/.test(authority) && !authority.startsWith("[")) return {
2806
+ ok: false,
2807
+ reason: "must not name a port — a hostname is served on 443"
2808
+ };
2809
+ let url;
2810
+ try {
2811
+ url = new URL(value);
2812
+ } catch {
2813
+ return {
2814
+ ok: false,
2815
+ reason: "is not a valid URL"
2816
+ };
2817
+ }
2818
+ if (url.protocol !== "https:") return {
2819
+ ok: false,
2820
+ reason: `must use https — the platform serves every hostname over TLS: "https://${url.host}${url.pathname === "/" ? "" : url.pathname}"`
2821
+ };
2822
+ if (url.username || url.password) return {
2823
+ ok: false,
2824
+ reason: "must not contain credentials"
2825
+ };
2826
+ if (url.port) return {
2827
+ ok: false,
2828
+ reason: "must not name a port — a hostname is served on 443"
2829
+ };
2830
+ if (url.search || url.hash || /[?#]/.test(value)) return {
2831
+ ok: false,
2832
+ reason: "must not have a query or fragment"
2833
+ };
2834
+ if (!isValidAppHost(url.hostname)) return {
2835
+ ok: false,
2836
+ reason: `names "${url.hostname}", which is not a public hostname`
2837
+ };
2838
+ const authorityAndPath = value.slice(value.indexOf("//") + 2);
2839
+ const slash = authorityAndPath.indexOf("/");
2840
+ return {
2841
+ ok: true,
2842
+ address: {
2843
+ host: url.hostname,
2844
+ path: slash === -1 ? "/" : authorityAndPath.slice(slash)
2845
+ }
2846
+ };
2847
+ }
2848
+ /**
2849
+ * Whether `host` is a public DNS name an app can be given: lowercase labels of
2850
+ * letters, digits and hyphens, at least two of them, not an IP address and not
2851
+ * `localhost`. Internationalised names arrive here already in punycode.
2852
+ */
2853
+ function isValidAppHost(host) {
2854
+ if (host.length === 0 || host.length > 253 || host !== host.toLowerCase()) return false;
2855
+ const labels = host.split(".");
2856
+ if (labels.length < 2) return false;
2857
+ const label = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
2858
+ if (!labels.every((part) => label.test(part))) return false;
2859
+ return !/^[0-9]+$/.test(labels[labels.length - 1]);
2860
+ }
2861
+ /**
2011
2862
  * Version of the bundle *format* itself.
2012
2863
  *
2013
2864
  * Bumped only when the on-disk layout changes in a way an older runtime could
@@ -2458,9 +3309,33 @@ function resolveClientListLimit(rawLimit, opts = {}) {
2458
3309
  */
2459
3310
  var PUBLIC_STORAGE_PREFIX = "public/";
2460
3311
  /**
2461
- * True when a storage key/path points at a public object (lives under
2462
- * {@link PUBLIC_STORAGE_PREFIX}). The check is applied to the key *within the
2463
- * bucket* — strip any `bucket/` and `scheme://` prefixes first.
3312
+ * True when a storage **key** is public: it lies under
3313
+ * {@link PUBLIC_STORAGE_PREFIX}.
3314
+ *
3315
+ * The key is read exactly as it is — the canonical key within its bucket, the
3316
+ * one the server stores, serves, and hands a `storageAuthorize` hook as
3317
+ * `key` — and nothing is parsed out of it. That is the difference from
3318
+ * {@link isPublicStoragePath}, which reads a client-side *path* and so strips
3319
+ * a `scheme://` and a leading `default/` bucket segment first. On a key both are
3320
+ * wrong: `default/public/x` is a key in a folder called `default`, and a
3321
+ * request path `notes://public/x` names the key `notes:/public/x`. Neither is
3322
+ * public, and the server decides on this function so that it never says they
3323
+ * are.
3324
+ *
3325
+ * @group Models
3326
+ */
3327
+ function isPublicStorageKey(key) {
3328
+ if (!key || !key.startsWith("public/")) return false;
3329
+ return !key.split(/[\\/]/).includes("..");
3330
+ }
3331
+ /**
3332
+ * True when a storage key or client-side path points at a public object
3333
+ * (lives under {@link PUBLIC_STORAGE_PREFIX}).
3334
+ *
3335
+ * For a path as a client holds it: a `scheme://` prefix and a single leading
3336
+ * `default/` bucket segment are stripped first. The server and a
3337
+ * `storageAuthorize` hook, which hold the canonical key, use
3338
+ * {@link isPublicStorageKey}.
2464
3339
  *
2465
3340
  * @group Models
2466
3341
  */
@@ -2473,7 +3348,189 @@ function isPublicStoragePath(path) {
2473
3348
  if (p.split("/").some((seg) => seg === "..")) return false;
2474
3349
  return p.startsWith("public/") || p.startsWith(`default/public/`);
2475
3350
  }
3351
+ /**
3352
+ * The path the storage routes address an object by, from a key or from the
3353
+ * `storageUrl` an upload returned (`<scheme>://<bucket>/<key>`, which is what a
3354
+ * property with `includeBucketUrl: true` keeps in its column).
3355
+ *
3356
+ * The routes address a source's objects by key. On S3 and GCS the bucket in a
3357
+ * `storageUrl` is always the source's own — the server refuses any other — so
3358
+ * it is dropped: sent along, `s3://acme-media/products/a.png` reached the
3359
+ * server as the key `acme-media/products/a.png`, the preview found nothing and
3360
+ * a delete reported success while the object stayed. It worked on local disk
3361
+ * only, because `local://default/<key>` happens to carry the one bucket
3362
+ * segment the routes recognise — which is kept, as is any other local bucket.
3363
+ *
3364
+ * A bare key whose first folder is `default` is sent behind that bucket
3365
+ * segment. The routes read one leading `default/` (in any case) as the bucket,
3366
+ * so the key `default/photo.png` sent as it is names the root `photo.png`:
3367
+ * {@link defaultBucketPath} is the spelling that names the key itself.
3368
+ *
3369
+ * Shared by every storage source that talks to those routes — the SDK's and
3370
+ * the admin panel's `useBackendStorageSource` — so they address an object alike.
3371
+ *
3372
+ * @group Models
3373
+ */
3374
+ function storageObjectPath(keyOrUrl, bucket) {
3375
+ let filePath = keyOrUrl;
3376
+ let bucketQualified = false;
3377
+ const scheme = /^(local|s3|gs):\/\//.exec(filePath);
3378
+ if (scheme) {
3379
+ filePath = filePath.substring(scheme[0].length);
3380
+ if (scheme[1] !== "local") {
3381
+ const slash = filePath.indexOf("/");
3382
+ if (slash > 0) filePath = filePath.substring(slash + 1);
3383
+ } else bucketQualified = true;
3384
+ }
3385
+ if (bucket && filePath) {
3386
+ if (!filePath.startsWith(`${bucket}/`)) filePath = `${bucket}/${filePath}`;
3387
+ bucketQualified = true;
3388
+ }
3389
+ return bucketQualified ? filePath : defaultBucketPath(filePath);
3390
+ }
3391
+ /**
3392
+ * The request path of a key in the default bucket: the key itself, unless the
3393
+ * routes would read its first folder as the bucket — a first folder named
3394
+ * `default`, matched as the routes match it (case-insensitively, and only when
3395
+ * something follows it). Then the bucket segment goes in front, so exactly the
3396
+ * one segment the routes strip is the one added.
3397
+ */
3398
+ function defaultBucketPath(key) {
3399
+ const segments = key.split("/");
3400
+ return segments.length > 1 && segments[0].toLowerCase() === "default" ? `default/${key}` : key;
3401
+ }
3402
+ /**
3403
+ * The scheme of a **storage reference**: a stored object named inside text.
3404
+ *
3405
+ * Text that embeds a file — a markdown body above all — cannot hold a private
3406
+ * object's URL: that URL carries a download token which expires in minutes,
3407
+ * after which every reader of the text gets a 401 for an image that is still in
3408
+ * the bucket. It holds a reference instead,
3409
+ * `rebase-storage:posts/cover.png` (`?storageId=media` for a named source),
3410
+ * and whoever renders the text exchanges each reference for a fresh URL with
3411
+ * {@link resolveStorageReferences} — `resolveStorageReferences(text, client)`
3412
+ * from `@rebasepro/client` on a site.
3413
+ *
3414
+ * @group Models
3415
+ */
3416
+ var STORAGE_REFERENCE_SCHEME = "rebase-storage:";
3417
+ /**
3418
+ * `encodeURIComponent`, plus the five characters it leaves alone that end or
3419
+ * break a markdown link destination: `(`, `)`, `'`, `!` and `*`.
3420
+ */
3421
+ var encodeReferencePart = (part) => encodeURIComponent(part).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
3422
+ /**
3423
+ * A reference as it appears in text: the scheme, then the characters
3424
+ * {@link storageReference} writes. Kept beside {@link STORAGE_REFERENCE_SCHEME};
3425
+ * the test round-trips names chosen to break it.
3426
+ */
3427
+ var STORAGE_REFERENCE_PATTERN = /rebase-storage:[A-Za-z0-9\-._~%/?=&]+/g;
3428
+ /**
3429
+ * The reference for an object — what text stores in place of its URL.
3430
+ *
3431
+ * Every part is percent-encoded, so the reference contains no space,
3432
+ * parenthesis or quote and survives as one markdown link destination whatever
3433
+ * the file was called.
3434
+ *
3435
+ * @group Models
3436
+ */
3437
+ function storageReference(key, storageId) {
3438
+ return `${STORAGE_REFERENCE_SCHEME}${key.replace(/^\/+/, "").split("/").map(encodeReferencePart).join("/")}${storageId && storageId !== "(default)" ? `?storageId=${encodeReferencePart(storageId)}` : ""}`;
3439
+ }
3440
+ /**
3441
+ * Whether a string — an image's `src`, a link's `href` — is a storage reference.
3442
+ *
3443
+ * @group Models
3444
+ */
3445
+ function isStorageReference(value) {
3446
+ return typeof value === "string" && value.startsWith("rebase-storage:");
3447
+ }
3448
+ /**
3449
+ * The object a storage reference names, or `null` for anything else — a URL, a
3450
+ * bare key, or a reference whose key would climb out of its prefix.
3451
+ *
3452
+ * @group Models
3453
+ */
3454
+ function parseStorageReference(value) {
3455
+ if (!isStorageReference(value)) return null;
3456
+ const rest = value.slice(15);
3457
+ const queryAt = rest.indexOf("?");
3458
+ const rawPath = queryAt === -1 ? rest : rest.slice(0, queryAt);
3459
+ const query = queryAt === -1 ? "" : rest.slice(queryAt + 1);
3460
+ let key;
3461
+ let storageId;
3462
+ try {
3463
+ key = rawPath.split("/").map(decodeURIComponent).join("/");
3464
+ for (const pair of query.split("&")) {
3465
+ const [name, raw] = pair.split("=");
3466
+ if (name === "storageId" && raw) storageId = decodeURIComponent(raw);
3467
+ }
3468
+ } catch {
3469
+ return null;
3470
+ }
3471
+ if (!key || key.split("/").some((segment) => segment === "..")) return null;
3472
+ return storageId && storageId !== "(default)" ? {
3473
+ key,
3474
+ storageId
3475
+ } : { key };
3476
+ }
3477
+ /**
3478
+ * Exchange every storage reference in `text` for a URL, at the moment the text
3479
+ * is rendered.
3480
+ *
3481
+ * ```ts
3482
+ * const html = markdown.render(await resolveStorageReferences(post.body, client));
3483
+ * ```
3484
+ *
3485
+ * Each distinct object is asked for once — `getSignedUrl(key)` on its source —
3486
+ * so a private object gets a freshly minted download URL every time the text is
3487
+ * rendered. A reference that cannot be resolved (no such object, an unknown
3488
+ * source) is left as it was, so a missing object reads as a missing image
3489
+ * rather than as a failed render.
3490
+ *
3491
+ * @group Models
3492
+ */
3493
+ async function resolveStorageReferences(text, from) {
3494
+ if (!text || !text.includes("rebase-storage:")) return text;
3495
+ const resolve = typeof from === "function" ? from : resolverFromSources(from);
3496
+ const references = new Set(text.match(STORAGE_REFERENCE_PATTERN) ?? []);
3497
+ const urls = /* @__PURE__ */ new Map();
3498
+ await Promise.all([...references].map(async (reference) => {
3499
+ const target = parseStorageReference(reference);
3500
+ if (!target) return;
3501
+ try {
3502
+ const url = await resolve(target);
3503
+ if (url) urls.set(reference, url);
3504
+ } catch {}
3505
+ }));
3506
+ if (urls.size === 0) return text;
3507
+ return text.replace(STORAGE_REFERENCE_PATTERN, (reference) => {
3508
+ const url = urls.get(reference);
3509
+ return url === void 0 ? reference : asLinkDestination(url);
3510
+ });
3511
+ }
3512
+ /**
3513
+ * A URL as one markdown link destination: whitespace, parentheses, angle
3514
+ * brackets and quotes percent-encoded, since any of them ends the destination
3515
+ * and turns the image into literal text. The URL means the same thing either
3516
+ * way.
3517
+ */
3518
+ var asLinkDestination = (url) => url.replace(/[\s()<>"']/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
3519
+ /** A resolver over a client's storage sources, one source per named key. */
3520
+ function resolverFromSources(client) {
3521
+ const named = /* @__PURE__ */ new Map();
3522
+ return async ({ key, storageId }) => {
3523
+ let source = client.storage;
3524
+ if (storageId) {
3525
+ if (!named.has(storageId)) named.set(storageId, client.createStorageSource?.(storageId));
3526
+ source = named.get(storageId);
3527
+ }
3528
+ if (!source) return null;
3529
+ const config = await source.getSignedUrl(key);
3530
+ return config.fileNotFound ? null : config.url;
3531
+ };
3532
+ }
2476
3533
  //#endregion
2477
- export { ADMIN_COLLECTION_KEYS, ADMIN_PROPERTY_KEYS, ALL_WHERE_FILTER_OPS, ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, BATCH_REF_KEY, BUNDLE_FORMAT_VERSION, CANONICAL_TO_REST, DEFAULT_CAPABILITIES, DEFAULT_COMMIT_PATHS, DEFAULT_DATA_SOURCE_KEY, DEFAULT_FILTERABLE_RELATION_KINDS, DEFAULT_FUZZY_THRESHOLD, DEFAULT_LIST_LIMIT, DEFAULT_RESOURCE_KEY, DEFAULT_SEARCH_COLUMN, DEFAULT_SEARCH_LANGUAGE, DEFAULT_SEARCH_MODE, DEFAULT_SEARCH_WEIGHT, DEFAULT_STORAGE_SOURCE_KEY, DEFAULT_TENANT_BYPASS_ROLES, DEFAULT_VECTOR_LIST_LIMIT, EntityReference, EntityRelation, FIELD_OPERATORS, FIREBASE_CAPABILITIES, GeoPoint, JUNCTION_PIVOT_KEY, LEGACY_RLS_JWT_SQL, LEGACY_RLS_ROLES_SQL, LEGACY_RLS_SCHEMA, LEGACY_RLS_UID_SQL, LIST_OPS, ListLimitError, MAX_INCLUDE_DEPTH, MAX_LIST_LIMIT, MONGODB_CAPABILITIES, NULL_OPS, POSTGRES_CAPABILITIES, PUBLIC_STORAGE_PREFIX, REBASE_SCHEMA, RELEVANCE_SORT_FIELD, RESERVED_BACKEND_PREFIXES, RESOURCE_GRAPH_VERSION, REST_TO_CANONICAL, RLS_IS_ANONYMOUS_SQL, RLS_JWT_SQL, RLS_ROLES_SQL, RLS_UID_SQL, RUNTIME_CONTRACT_VERSION, RebaseApiError, RebaseClientError, SCHEMA_VERSION_HEADER, Vector, amendResourceKind, bucket, buildResourceGraph, canonicalSchemaPayload, computeSchemaVersion, database, declareCron, declareFunction, declareResource, declaredDataSources, declaredDatabaseExtensions, declaredQueueConsumers, declaredResources, declaredStorageSources, declaredSubscriptions, deserializeCollections, encodeRelationAggregateSort, envBasesForResource, findEnvSuffixCollision, findStorageSuffixCollision, getCollectionDataPath, getDataSourceCapabilities, getDeclaredSubcollections, hasFieldOperation, hasForeignKeyOnTarget, isAnonymousUid, isBranchAdmin, isChannelBusInstance, isDocumentAdmin, isFieldOperation, isFirebaseCollectionConfig, isLazyComponentRef, isManyToMany, isMongoDBCollectionConfig, isPostgresCollectionConfig, isPublicStoragePath, isRelationAggregateSort, isRelationalCollectionConfig, isResourceHandle, isSQLAdmin, isSchemaAdmin, isSchemaEditingAdmin, isSerializedCollectionRef, isTenantClaimSource, isTenantMembershipSource, isToMany, isUnsupported, isValidEngine, nestAdminCollectionKeys, nestAdminKeysOf, nestAdminPropertyKeys, parseEnvBoolean, parseRelationAggregateSort, policy, queue, registerDataSourceCapabilities, registerResourceKind, reservedPrefixFor, resetDeclaredQueueConsumers, resetDeclaredResources, resetDeclaredSubscriptions, resolveClientListLimit, resolveResourceRefs, resourceEnvSuffix, resourceId, resourceKeyOf, resourceKind, resourceKinds, resourceToDataSource, resourceToStorageSource, rewriteLegacyRlsFunctions, serializeCollections, setQueueRuntime, setTopicRuntime, sortKeyToString, storageEnvSuffix, toCanonicalOp, topic, unsupportedMethod, usesLegacyRlsFunctions };
3534
+ export { ADMIN_COLLECTION_KEYS, ADMIN_PROPERTY_KEYS, ADMIN_ROLE, ADMIN_SCOPES, ALL_WHERE_FILTER_OPS, ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, BATCH_REF_KEY, BUILT_IN_SCOPES, BUILT_IN_SCOPE_DESCRIPTIONS, BUNDLE_FORMAT_VERSION, CANONICAL_TO_REST, DATA_PLANE_SCOPES, DEFAULT_CAPABILITIES, DEFAULT_COMMIT_PATHS, DEFAULT_DATA_SOURCE_KEY, DEFAULT_FILTERABLE_RELATION_KINDS, DEFAULT_FUZZY_THRESHOLD, DEFAULT_LIST_LIMIT, DEFAULT_RESOURCE_KEY, DEFAULT_SEARCH_COLUMN, DEFAULT_SEARCH_LANGUAGE, DEFAULT_SEARCH_MODE, DEFAULT_SEARCH_WEIGHT, DEFAULT_STORAGE_SOURCE_KEY, DEFAULT_TENANT_BYPASS_ROLES, DEFAULT_VECTOR_LIST_LIMIT, EMPTY_ACCESS_MODEL, EntityReference, EntityRelation, FIELD_OPERATORS, FIREBASE_CAPABILITIES, GeoPoint, IMPERSONATE_HEADER, JUNCTION_PIVOT_KEY, KEY_MANAGEMENT_SCOPES, LEGACY_RLS_JWT_SQL, LEGACY_RLS_ROLES_SQL, LEGACY_RLS_SCHEMA, LEGACY_RLS_UID_SQL, LIST_OPS, ListLimitError, MAX_INCLUDE_DEPTH, MAX_LIST_LIMIT, MONGODB_CAPABILITIES, NULL_OPS, POSTGRES_CAPABILITIES, PUBLIC_STORAGE_PREFIX, REBASE_SCHEMA, RELEVANCE_SORT_FIELD, RESERVED_BACKEND_PREFIXES, RESOURCE_GRAPH_VERSION, REST_TO_CANONICAL, RLS_IS_ANONYMOUS_SQL, RLS_JWT_SQL, RLS_ROLES_SQL, RLS_UID_SQL, RUNTIME_CONTRACT_VERSION, RUNTIME_ONLY_COLLECTION_KEYS, RebaseApiError, RebaseClientError, SCHEMA_VERSION_HEADER, SCOPE_TARGETS, STORAGE_REFERENCE_SCHEME, Vector, amendResourceKind, applyCollectionPatch, bucket, buildResourceGraph, canonicalSchemaPayload, collectionPatchProblems, computeSchemaVersion, database, declareCron, declareFunction, declareResource, declaredDataSources, declaredDatabaseExtensions, declaredQueueConsumers, declaredResources, declaredStorageSources, declaredSubscriptions, describeScope, deserializeCollections, diffCollections, diffJsonValues, encodeRelationAggregateSort, envBasesForResource, findEnvSuffixCollision, findStorageSuffixCollision, getCollectionDataPath, getDataSourceCapabilities, getDeclaredSubcollections, hasAdminRole, hasFieldOperation, hasForeignKeyOnTarget, intersectScopes, isAdminScope, isAnonymousUid, isBranchAdmin, isBuiltInScope, isChannelBusInstance, isCollectionPatch, isDataPlaneScope, isDocumentAdmin, isFieldOperation, isFirebaseCollectionConfig, isLazyComponentRef, isManyToMany, isMongoDBCollectionConfig, isPostgresCollectionConfig, isPublicStorageKey, isPublicStoragePath, isRelationAggregateSort, isRelationalCollectionConfig, isResourceHandle, isSQLAdmin, isSchemaAdmin, isSchemaEditingAdmin, isSerializedCollectionRef, isStorageReference, isTenantClaimSource, isTenantMembershipSource, isToMany, isUnsupported, isValidAppHost, isValidEngine, knownScopes, nestAdminCollectionKeys, nestAdminKeysOf, nestAdminPropertyKeys, nestCollectionPatchPaths, parseAppAddress, parseEnvBoolean, parseRelationAggregateSort, parseScope, parseStorageReference, policy, queue, registerDataSourceCapabilities, registerResourceKind, reservedPrefixFor, resetDeclaredQueueConsumers, resetDeclaredResources, resetDeclaredSubscriptions, resolveClientListLimit, resolveResourceRefs, resolveStorageReferences, resourceEnvSuffix, resourceId, resourceKeyOf, resourceKind, resourceKinds, resourceToDataSource, resourceToStorageSource, rewriteLegacyRlsFunctions, runtimeOnlyKeyIn, scopeAcceptsTarget, scopeGrants, scopeGrantsAny, scopeTargets, scopesBeyond, scopesForRoles, serializeCollections, setQueueRuntime, setTopicRuntime, sortKeyToString, storageEnvSuffix, storageObjectPath, storageReference, summarizeRoles, summarizeScopes, toCanonicalOp, toCollectionJson, topic, unsupportedMethod, usesLegacyRlsFunctions, validateScopes };
2478
3535
 
2479
3536
  //# sourceMappingURL=index.es.js.map