@rebasepro/server 0.9.1-canary.1d2d8b5 → 0.9.1-canary.58368ce

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.
@@ -5,12 +5,15 @@ import { HonoEnv } from "../types";
5
5
  * Lightweight REST API generator that leverages existing Rebase DataDriver.
6
6
  * Supports `include` query parameter for eager-loading relations via Drizzle.
7
7
  */
8
+ /** Rows accepted by a single POST /<collection>/bulk. See `maxBulkRows`. */
9
+ export declare const DEFAULT_MAX_BULK_ROWS = 1000;
8
10
  export declare class RestApiGenerator {
9
11
  private collections;
10
12
  private router;
11
13
  private driver;
14
+ private maxBulkRows;
12
15
  private authAdapter?;
13
- constructor(collections: CollectionConfig[], driver: DataDriver, authAdapter?: AuthAdapter);
16
+ constructor(collections: CollectionConfig[], driver: DataDriver, authAdapter?: AuthAdapter, maxBulkRows?: number);
14
17
  /**
15
18
  * Generate REST routes using existing DataDriver
16
19
  */
package/dist/index.es.js CHANGED
@@ -1,10 +1,9 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
- import { a as __require, i as __exportAll, n as __commonJSMin, s as __toESM } from "./ms-DnYXB-Wd.js";
5
- import { _ as RebaseClientError, a as getCollectionDataPath, c as CANONICAL_TO_REST, d as toCanonicalOp, f as EntityReference, g as RebaseApiError, h as Vector, i as isSQLAdmin, l as NULL_OPS, m as GeoPoint, o as getDeclaredSubcollections, p as EntityRelation, r as getDataSourceCapabilities, s as isPostgresCollectionConfig, t as DEFAULT_STORAGE_SOURCE_KEY, u as REST_TO_CANONICAL } from "./src-B4OLmNVa.js";
4
+ import { a as getAccessTokenExpiry, d as require_jsonwebtoken, f as __commonJSMin, h as __toESM, i as generateRefreshToken, l as verifyAccessToken, m as __require, n as generateAccessToken, o as getRefreshTokenExpiry, p as __exportAll, r as generateDownloadToken, s as hashRefreshToken, t as configureJwt, u as verifyDownloadToken } from "./jwt-C6pbGtvf.js";
5
+ import { _ as RebaseApiError, a as policy, c as isPostgresCollectionConfig, d as REST_TO_CANONICAL, f as toCanonicalOp, g as Vector, h as GeoPoint, i as isSQLAdmin, l as CANONICAL_TO_REST, m as EntityRelation, o as getCollectionDataPath, p as EntityReference, r as getDataSourceCapabilities, s as getDeclaredSubcollections, t as DEFAULT_STORAGE_SOURCE_KEY, u as NULL_OPS, v as RebaseClientError } from "./src-CsHhSKbi.js";
6
6
  import { t as logger } from "./logger-BYU66ENZ.js";
7
- import { a as getAccessTokenExpiry, d as require_jsonwebtoken, i as generateRefreshToken, l as verifyAccessToken, n as generateAccessToken, o as getRefreshTokenExpiry, r as generateDownloadToken, s as hashRefreshToken, t as configureJwt, u as verifyDownloadToken } from "./jwt-BwIn8xmk.js";
8
7
  import { t as nativeDynamicImport } from "./dynamic-import-Dvh-K5fl.js";
9
8
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
10
9
  import * as fs$2 from "fs";
@@ -1109,6 +1108,76 @@ function normalizeToEntityRelation(value, propertyType) {
1109
1108
  if (!(obj.__type === "relation" || obj.__type === "reference" || typeof obj.isEntityRelation === "function" && obj.isEntityRelation() || typeof obj.isEntityReference === "function" && obj.isEntityReference() || propertyType === "relation" && typeof obj.id !== "undefined" && typeof obj.path === "string")) return null;
1110
1109
  return new EntityRelation(obj.id, obj.path, obj.data);
1111
1110
  }
1111
+ /**
1112
+ * Derive a row's address from its key columns.
1113
+ *
1114
+ * Single key → the value as a string. Composite → each part joined by
1115
+ * {@link COMPOSITE_ID_SEPARATOR}, in primary-key order, which is what
1116
+ * {@link parseIdValues} expects to invert.
1117
+ */
1118
+ function buildCompositeId(values, primaryKeys) {
1119
+ if (primaryKeys.length === 0) return "";
1120
+ if (primaryKeys.length === 1) return String(values[primaryKeys[0].fieldName] ?? "");
1121
+ return primaryKeys.map((pk) => String(values[pk.fieldName] ?? "")).join(":::");
1122
+ }
1123
+ /**
1124
+ * The primary keys of a collection, as declared by its properties.
1125
+ *
1126
+ * This is the only tier both sides can read, because it is the only one written
1127
+ * in the config: the postgres driver can also infer keys from the Drizzle
1128
+ * schema, which the browser never sees and is never sent — the admin compiles
1129
+ * the collection files into its own bundle rather than being served them. A key
1130
+ * that lives only in the Drizzle schema is therefore invisible here, and the
1131
+ * server says so at boot (`warnOnKeysTheAdminCannotResolve`) naming the `isId`
1132
+ * to add.
1133
+ *
1134
+ * Returns an empty array when a collection declares none, which callers must
1135
+ * treat as "not addressable" rather than defaulting to `id`: guessing a key
1136
+ * that is not the real one produces confidently wrong addresses.
1137
+ */
1138
+ function getDeclaredPrimaryKeys(collection) {
1139
+ const properties = collection.properties;
1140
+ if (!properties) return [];
1141
+ const keys = [];
1142
+ for (const [fieldName, propRaw] of Object.entries(properties)) {
1143
+ const prop = propRaw;
1144
+ if (!prop || typeof prop !== "object") continue;
1145
+ if (!("isId" in prop) || !prop.isId) continue;
1146
+ keys.push({
1147
+ fieldName,
1148
+ type: prop.type === "number" ? "number" : "string",
1149
+ isUUID: prop.isId === "uuid"
1150
+ });
1151
+ }
1152
+ return keys;
1153
+ }
1154
+ /**
1155
+ * The keys to address a collection's rows with, resolved the way the driver
1156
+ * resolves them — minus the tier the browser cannot reach.
1157
+ *
1158
+ * The postgres driver tries, in order: properties marked `isId`; the primary
1159
+ * keys of the Drizzle schema; and finally a column literally named `id`. Only
1160
+ * the first and last are visible in a `CollectionConfig`, which is what both
1161
+ * sides share.
1162
+ *
1163
+ * So the two agree except on a collection that declares no `isId` and whose key
1164
+ * is known only to Drizzle. There, the driver reads the real key, and this
1165
+ * either resolves nothing (reported to the console by the caller) or — if the
1166
+ * table happens to have an unrelated `id` property — resolves `id`, which is
1167
+ * the wrong key and cannot be detected from here: the addresses look right and
1168
+ * route wrong. Only the config can settle it, so the server names both cases
1169
+ * at boot (`warnOnKeysTheAdminCannotResolve`) with the `isId` to add.
1170
+ */
1171
+ function resolvePrimaryKeys(collection) {
1172
+ const declared = getDeclaredPrimaryKeys(collection);
1173
+ if (declared.length > 0) return declared;
1174
+ const idProp = collection.properties?.id;
1175
+ if (idProp && typeof idProp === "object") return [{
1176
+ fieldName: "id",
1177
+ type: idProp.type === "number" ? "number" : "string"
1178
+ }];
1179
+ return [];
1180
+ }
1112
1181
  //#endregion
1113
1182
  //#region ../common/src/util/enums.ts
1114
1183
  function enumToObjectEntries(enumValues) {
@@ -1321,6 +1390,8 @@ function getSubcollections(collection) {
1321
1390
  }
1322
1391
  return [];
1323
1392
  }
1393
+ policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
1394
+ policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
1324
1395
  (/* @__PURE__ */ __commonJSMin(((exports, module) => {
1325
1396
  (function(root, factory) {
1326
1397
  if (typeof define === "function" && define.amd) define(factory);
@@ -2753,18 +2824,45 @@ function deserializeLogicalCondition(str) {
2753
2824
  }
2754
2825
  //#endregion
2755
2826
  //#region ../common/src/data/buildRebaseData.ts
2827
+ function createPrimaryKeyResolver(options) {
2828
+ const cache = /* @__PURE__ */ new Map();
2829
+ const warned = /* @__PURE__ */ new Set();
2830
+ return function primaryKeysFor(slug) {
2831
+ const cached = cache.get(slug);
2832
+ if (cached) return cached;
2833
+ const collection = options?.resolveCollection?.(slug);
2834
+ if (!collection) return [];
2835
+ const keys = resolvePrimaryKeys(collection);
2836
+ if (keys.length > 0) {
2837
+ cache.set(slug, keys);
2838
+ return keys;
2839
+ }
2840
+ if (!warned.has(slug)) {
2841
+ warned.add(slug);
2842
+ console.warn(`[rebase] Collection '${slug}' declares no primary key, so its rows have no address: detail links, caching and relations will not work for it. Mark the key property with \`isId\` in its collection config — the server logs which column to mark at boot, if its schema knows the key.`);
2843
+ }
2844
+ return keys;
2845
+ };
2846
+ }
2756
2847
  /**
2757
- * Convert a flat REST record (e.g. from RestFetchService) to Entity<M> format.
2758
- * Mirrors the client SDK's rowToEntity conversion.
2848
+ * Give a flat row the Entity view-model the admin renders.
2849
+ *
2850
+ * The address is *derived here* — it is not a column, and the row it came from
2851
+ * does not contain one. Rows carry exactly what the table has, with the types
2852
+ * Postgres returned; the id is this layer's invention, and this is the only
2853
+ * place it is minted.
2854
+ *
2855
+ * `primaryKeys` empty falls back to a literal `id` on the row: drivers other
2856
+ * than postgres still serve rows with one, and this keeps them working.
2759
2857
  */
2760
- function rowToEntity(row, slug) {
2858
+ function rowToEntity(row, slug, primaryKeys = []) {
2761
2859
  return {
2762
- id: row.id,
2860
+ id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
2763
2861
  path: slug,
2764
2862
  values: row
2765
2863
  };
2766
2864
  }
2767
- function createDriverAccessor(driver, slug) {
2865
+ function createDriverAccessor(driver, slug, getPks = () => []) {
2768
2866
  const accessor = {
2769
2867
  async find(params) {
2770
2868
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
@@ -2797,7 +2895,7 @@ function createDriverAccessor(driver, slug) {
2797
2895
  hasMore = offset + rows.length < total;
2798
2896
  }
2799
2897
  return {
2800
- data: rows.map((row) => rowToEntity(row, slug)),
2898
+ data: rows.map((row) => rowToEntity(row, slug, getPks())),
2801
2899
  meta: {
2802
2900
  total,
2803
2901
  limit,
@@ -2811,7 +2909,7 @@ function createDriverAccessor(driver, slug) {
2811
2909
  path: slug,
2812
2910
  id
2813
2911
  });
2814
- return row ? rowToEntity(row, slug) : void 0;
2912
+ return row ? rowToEntity(row, slug, getPks()) : void 0;
2815
2913
  },
2816
2914
  async create(data, id) {
2817
2915
  return rowToEntity(await driver.save({
@@ -2819,15 +2917,22 @@ function createDriverAccessor(driver, slug) {
2819
2917
  values: data,
2820
2918
  id,
2821
2919
  status: "new"
2822
- }), slug);
2920
+ }), slug, getPks());
2823
2921
  },
2922
+ createMany: driver.saveMany ? async (data, options) => {
2923
+ return (await driver.saveMany({
2924
+ path: slug,
2925
+ rows: data,
2926
+ upsert: options?.upsert
2927
+ })).map((row) => rowToEntity(row, slug, getPks()));
2928
+ } : void 0,
2824
2929
  async update(id, data) {
2825
2930
  return rowToEntity(await driver.save({
2826
2931
  path: slug,
2827
2932
  values: data,
2828
2933
  id,
2829
2934
  status: "existing"
2830
- }), slug);
2935
+ }), slug, getPks());
2831
2936
  },
2832
2937
  async delete(id) {
2833
2938
  return driver.delete({ row: {
@@ -2856,7 +2961,7 @@ function createDriverAccessor(driver, slug) {
2856
2961
  searchString: params?.searchString,
2857
2962
  onUpdate: (entities) => {
2858
2963
  onUpdate({
2859
- data: entities.map((row) => rowToEntity(row, slug)),
2964
+ data: entities.map((row) => rowToEntity(row, slug, getPks())),
2860
2965
  meta: {
2861
2966
  total: entities.length,
2862
2967
  limit,
@@ -2872,7 +2977,7 @@ function createDriverAccessor(driver, slug) {
2872
2977
  return driver.listenOne({
2873
2978
  path: slug,
2874
2979
  id,
2875
- onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
2980
+ onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug, getPks()) : void 0),
2876
2981
  onError
2877
2982
  });
2878
2983
  } : void 0,
@@ -2911,12 +3016,13 @@ function createDriverAccessor(driver, slug) {
2911
3016
  * await data.products.create({ name: "Camera", price: 299 });
2912
3017
  * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
2913
3018
  */
2914
- function buildRebaseData(driver) {
3019
+ function buildRebaseData(driver, options) {
2915
3020
  const cache = /* @__PURE__ */ new Map();
3021
+ const primaryKeysFor = createPrimaryKeyResolver(options);
2916
3022
  function getAccessor(slug) {
2917
3023
  let accessor = cache.get(slug);
2918
3024
  if (!accessor) {
2919
- accessor = createDriverAccessor(driver, slug);
3025
+ accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
2920
3026
  cache.set(slug, accessor);
2921
3027
  }
2922
3028
  return accessor;
@@ -2929,8 +3035,9 @@ function buildRebaseData(driver) {
2929
3035
  } });
2930
3036
  }
2931
3037
  /**
2932
- * Unwrap a Entity into a flat row. `rowToEntity` stores the whole flat row
2933
- * (id included) under `.values`, so this is just that payload.
3038
+ * Unwrap a Entity back into the flat row it was built from. `rowToEntity` keeps
3039
+ * the row untouched under `.values` and derives `.id` alongside it, so dropping
3040
+ * the wrapper is the whole operation — the address was never part of the row.
2934
3041
  */
2935
3042
  function entityToRow(entity) {
2936
3043
  return entity.values;
@@ -3017,6 +3124,12 @@ function toSdkCollectionClient(snap) {
3017
3124
  async create(data, id) {
3018
3125
  return entityToRow(await snap.create(data, id));
3019
3126
  },
3127
+ async createMany(data, options) {
3128
+ if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
3129
+ if (data.length === 0) return [];
3130
+ if (!snap.createMany) throw new Error("Bulk writes are not supported by this collection's data source. Fall back to create() per record.");
3131
+ return (await snap.createMany(data, options)).map(entityToRow);
3132
+ },
3020
3133
  async update(id, data) {
3021
3134
  return entityToRow(await snap.update(id, data));
3022
3135
  },
@@ -3725,15 +3838,19 @@ async function parseJsonBody(c) {
3725
3838
  * Lightweight REST API generator that leverages existing Rebase DataDriver.
3726
3839
  * Supports `include` query parameter for eager-loading relations via Drizzle.
3727
3840
  */
3841
+ /** Rows accepted by a single POST /<collection>/bulk. See `maxBulkRows`. */
3842
+ var DEFAULT_MAX_BULK_ROWS = 1e3;
3728
3843
  var RestApiGenerator = class {
3729
3844
  collections;
3730
3845
  router;
3731
3846
  driver;
3847
+ maxBulkRows;
3732
3848
  authAdapter;
3733
- constructor(collections, driver, authAdapter) {
3849
+ constructor(collections, driver, authAdapter, maxBulkRows = DEFAULT_MAX_BULK_ROWS) {
3734
3850
  this.collections = collections;
3735
3851
  this.driver = driver;
3736
3852
  this.authAdapter = authAdapter;
3853
+ this.maxBulkRows = maxBulkRows;
3737
3854
  this.router = new Hono();
3738
3855
  }
3739
3856
  /**
@@ -3818,6 +3935,32 @@ var RestApiGenerator = class {
3818
3935
  if (!entity) throw ApiError.notFound("Entity not found");
3819
3936
  return c.json(entity);
3820
3937
  });
3938
+ this.router.post(`${basePath}/bulk`, async (c) => {
3939
+ this.enforceApiKeyPermission(c, collection.slug);
3940
+ const driver = this.getScopedDriver(c);
3941
+ const path = collection.slug;
3942
+ const body = await parseJsonBody(c);
3943
+ if (!Array.isArray(body?.rows)) throw ApiError.badRequest("Expected a JSON body of { rows: [...] }.", "INVALID_BULK_BODY");
3944
+ if (body.rows.length === 0) return c.json({
3945
+ data: [],
3946
+ meta: { written: 0 }
3947
+ });
3948
+ if (body.rows.some((row) => typeof row !== "object" || row === null || Array.isArray(row))) throw ApiError.badRequest("Every entry in `rows` must be an object.", "INVALID_BULK_BODY");
3949
+ if (body.upsert !== void 0 && typeof body.upsert !== "boolean") throw ApiError.badRequest("`upsert` must be a boolean.", "INVALID_BULK_BODY");
3950
+ const maxRows = this.maxBulkRows;
3951
+ if (body.rows.length > maxRows) throw ApiError.badRequest(`Too many rows: ${body.rows.length} exceeds the ${maxRows}-row limit for a single bulk write. Send it in chunks of ${maxRows} or fewer.`, "BULK_TOO_LARGE");
3952
+ if (!driver.saveMany) throw ApiError.badRequest("This collection's data source does not support bulk writes.", "BULK_UNSUPPORTED");
3953
+ const rows = await driver.saveMany({
3954
+ path,
3955
+ rows: body.rows,
3956
+ collection: resolvedCollection,
3957
+ upsert: body.upsert === true
3958
+ });
3959
+ return c.json({
3960
+ data: rows.map((row) => this.formatResponse(row)),
3961
+ meta: { written: rows.length }
3962
+ });
3963
+ });
3821
3964
  this.router.post(basePath, async (c) => {
3822
3965
  try {
3823
3966
  this.enforceApiKeyPermission(c, collection.slug);
@@ -9549,7 +9692,7 @@ function mountSessionRoutes(opts) {
9549
9692
  clearRefreshCookie(c, config.cookieAuth);
9550
9693
  const authHeader = c.req.header("authorization");
9551
9694
  if (ops.afterLogout && authHeader?.startsWith("Bearer ")) {
9552
- const { verifyAccessToken } = await import("./jwt-BwIn8xmk.js").then((n) => n.c);
9695
+ const { verifyAccessToken } = await import("./jwt-C6pbGtvf.js").then((n) => n.c);
9553
9696
  const payload = verifyAccessToken(authHeader.substring(7));
9554
9697
  if (payload) ops.afterLogout(payload.userId).catch((err) => {
9555
9698
  logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
@@ -10953,6 +11096,98 @@ function requestLogger(options) {
10953
11096
  };
10954
11097
  }
10955
11098
  //#endregion
11099
+ //#region src/api/logs-routes.ts
11100
+ var logs_routes_exports = /* @__PURE__ */ __exportAll({
11101
+ addLog: () => addLog,
11102
+ default: () => app,
11103
+ logBuffer: () => logBuffer,
11104
+ logMiddleware: () => logMiddleware
11105
+ });
11106
+ var LogRingBuffer = class {
11107
+ buffer = [];
11108
+ maxSize;
11109
+ idCounter = 0;
11110
+ constructor(maxSize = 1e4) {
11111
+ this.maxSize = maxSize;
11112
+ }
11113
+ push(entry) {
11114
+ const id = `log_${++this.idCounter}`;
11115
+ this.buffer.push({
11116
+ ...entry,
11117
+ id
11118
+ });
11119
+ if (this.buffer.length > this.maxSize) this.buffer.shift();
11120
+ }
11121
+ query(options) {
11122
+ let filtered = this.buffer;
11123
+ if (options.level) filtered = filtered.filter((e) => e.level === options.level);
11124
+ if (options.source) filtered = filtered.filter((e) => e.source === options.source);
11125
+ if (options.search) {
11126
+ const searchLower = options.search.toLowerCase();
11127
+ filtered = filtered.filter((e) => e.message.toLowerCase().includes(searchLower));
11128
+ }
11129
+ if (options.since) {
11130
+ const sinceValue = options.since;
11131
+ filtered = filtered.filter((e) => e.timestamp >= sinceValue);
11132
+ }
11133
+ const sorted = [...filtered].reverse();
11134
+ const total = sorted.length;
11135
+ const limit = options.limit || 100;
11136
+ const offset = options.offset || 0;
11137
+ return {
11138
+ entries: sorted.slice(offset, offset + limit),
11139
+ total
11140
+ };
11141
+ }
11142
+ getLatest(count = 50) {
11143
+ return this.buffer.slice(-count).reverse();
11144
+ }
11145
+ };
11146
+ var logBuffer = new LogRingBuffer();
11147
+ /** Add a log entry */
11148
+ function addLog(level, source, message, metadata) {
11149
+ logBuffer.push({
11150
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
11151
+ level,
11152
+ source,
11153
+ message,
11154
+ metadata
11155
+ });
11156
+ }
11157
+ /** Hono middleware to log API requests */
11158
+ function logMiddleware() {
11159
+ return async (c, next) => {
11160
+ const start = Date.now();
11161
+ await next();
11162
+ const duration = Date.now() - start;
11163
+ const reqId = c.get("requestId");
11164
+ addLog("info", "api", `${c.req.method} ${c.req.path} ${c.res.status} ${duration}ms`, {
11165
+ method: c.req.method,
11166
+ path: c.req.path,
11167
+ status: c.res.status,
11168
+ duration,
11169
+ ...reqId && { requestId: reqId }
11170
+ });
11171
+ };
11172
+ }
11173
+ var app = new Hono();
11174
+ app.get("/", (c) => {
11175
+ const query = c.req.query();
11176
+ const result = logBuffer.query({
11177
+ level: query.level,
11178
+ source: query.source,
11179
+ search: query.search,
11180
+ limit: query.limit ? parseInt(query.limit) : void 0,
11181
+ offset: query.offset ? parseInt(query.offset) : void 0,
11182
+ since: query.since
11183
+ });
11184
+ return c.json(result);
11185
+ });
11186
+ app.get("/latest", (c) => {
11187
+ const count = parseInt(c.req.query("count") || "50");
11188
+ return c.json({ entries: logBuffer.getLatest(count) });
11189
+ });
11190
+ //#endregion
10956
11191
  //#region src/init/middlewares.ts
10957
11192
  function configureMiddlewares(app, basePath, isProduction, config) {
10958
11193
  app.use(`${basePath}/*`, requestId());
@@ -10979,6 +11214,7 @@ function configureMiddlewares(app, basePath, isProduction, config) {
10979
11214
  }
10980
11215
  if (!isProduction && !process.env.CORS_ORIGINS && !process.env.FRONTEND_URL) logger.warn("No CORS configuration detected (CORS_ORIGINS / FRONTEND_URL not set). The API will accept requests from any origin. Set CORS_ORIGINS in your .env file to restrict access.");
10981
11216
  app.use(`${basePath}/*`, requestLogger());
11217
+ app.use(`${basePath}/*`, logMiddleware());
10982
11218
  }
10983
11219
  //#endregion
10984
11220
  //#region src/storage/types.ts
@@ -11518,7 +11754,7 @@ var gcsModule = null;
11518
11754
  async function loadGCS() {
11519
11755
  if (gcsModule) return gcsModule;
11520
11756
  try {
11521
- gcsModule = await import("./src-B9tjYmqP.js");
11757
+ gcsModule = await import("@google-cloud/storage");
11522
11758
  return gcsModule;
11523
11759
  } catch {
11524
11760
  throw new Error("Google Cloud Storage is required for the GCS storage controller. Install it with: pnpm add @google-cloud/storage");
@@ -14965,7 +15201,7 @@ var SDKQueryBuilder = class {
14965
15201
  * Listen to realtime updates matching this query.
14966
15202
  */
14967
15203
  listen(onUpdate, onError) {
14968
- if (!this.collection.listen) throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl.");
15204
+ if (!this.collection.listen) throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl, and not when it was created with realtime: false.");
14969
15205
  return this.collection.listen(this.params, onUpdate, onError);
14970
15206
  }
14971
15207
  };
@@ -14998,6 +15234,17 @@ function createCollectionClient(transport, slug, ws) {
14998
15234
  body: JSON.stringify(body)
14999
15235
  });
15000
15236
  },
15237
+ async createMany(data, options) {
15238
+ if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
15239
+ if (data.length === 0) return [];
15240
+ return (await transport.request(`${basePath}/bulk`, {
15241
+ method: "POST",
15242
+ body: JSON.stringify({
15243
+ rows: data,
15244
+ ...options?.upsert ? { upsert: true } : {}
15245
+ })
15246
+ })).data || [];
15247
+ },
15001
15248
  async update(id, data) {
15002
15249
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
15003
15250
  method: "PUT",
@@ -15605,7 +15852,9 @@ var RebaseWebSocketClient = class {
15605
15852
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
15606
15853
  if (collectionSub) {
15607
15854
  const incomingRows = message.rows || [];
15608
- const rows = this.mergeRows(collectionSub.latestData, incomingRows);
15855
+ const updatePks = message.pks;
15856
+ if (updatePks) collectionSub.pks = updatePks;
15857
+ const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
15609
15858
  collectionSub.latestData = rows;
15610
15859
  collectionSub.lastUpdated = Date.now();
15611
15860
  collectionSub.isInitialDataReceived = true;
@@ -15627,12 +15876,14 @@ var RebaseWebSocketClient = class {
15627
15876
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
15628
15877
  if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
15629
15878
  const patchWireEntity = message.row ?? null;
15630
- const patchEntityId = message.id;
15879
+ const patchMessage = message;
15880
+ const patchEntityId = patchMessage.id;
15881
+ if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
15631
15882
  const patchRow = patchWireEntity ? patchWireEntity : null;
15632
15883
  let updated;
15633
- if (patchRow === null) updated = collectionSub.latestData.filter((e) => String(e.id) !== String(patchEntityId));
15884
+ if (patchRow === null) updated = collectionSub.latestData.filter((e) => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId));
15634
15885
  else {
15635
- const idx = collectionSub.latestData.findIndex((e) => String(e.id) === String(patchRow.id));
15886
+ const idx = collectionSub.latestData.findIndex((e) => this.rowAddress(e, collectionSub.pks) === String(patchEntityId));
15636
15887
  if (idx >= 0) {
15637
15888
  updated = [...collectionSub.latestData];
15638
15889
  updated[idx] = patchRow;
@@ -15982,17 +16233,39 @@ var RebaseWebSocketClient = class {
15982
16233
  return val;
15983
16234
  }
15984
16235
  /**
16236
+ * The address of a row, for matching it against another copy of itself.
16237
+ *
16238
+ * A row is exactly its columns and carries no address, so it is derived
16239
+ * from the key columns the server named — including the ordinary case where
16240
+ * that key is `id`, which the server reports like any other.
16241
+ *
16242
+ * Undefined when there are no keys, which means the server could not
16243
+ * resolve any: such rows genuinely cannot be recognised, and guessing at a
16244
+ * column called `id` would be inventing an identity for a table that has
16245
+ * none.
16246
+ */
16247
+ rowAddress(row, pks) {
16248
+ if (!pks || pks.length === 0) return void 0;
16249
+ const address = buildCompositeId(row, pks);
16250
+ if (!address || address.split(":::").every((part) => part === "")) return void 0;
16251
+ return address;
16252
+ }
16253
+ /**
15985
16254
  * Merge incoming rows with cached data, preserving cached references
15986
16255
  * for rows whose values haven't changed. This avoids unnecessary
15987
16256
  * React re-renders when the server refetches all rows but most
15988
16257
  * haven't actually changed.
15989
16258
  */
15990
- mergeRows(cached, incoming) {
16259
+ mergeRows(cached, incoming, pks) {
15991
16260
  if (!cached || cached.length === 0) return incoming;
15992
16261
  const cachedById = /* @__PURE__ */ new Map();
15993
- for (const row of cached) cachedById.set(row.id, row);
16262
+ for (const row of cached) {
16263
+ const address = this.rowAddress(row, pks);
16264
+ if (address !== void 0) cachedById.set(address, row);
16265
+ }
15994
16266
  return incoming.map((incomingRow) => {
15995
- const cachedRow = cachedById.get(incomingRow.id);
16267
+ const address = this.rowAddress(incomingRow, pks);
16268
+ const cachedRow = address === void 0 ? void 0 : cachedById.get(address);
15996
16269
  if (!cachedRow) return incomingRow;
15997
16270
  const normCached = this.normalizeForComparison(cachedRow);
15998
16271
  const normIncoming = this.normalizeForComparison(incomingRow);
@@ -16004,7 +16277,7 @@ var RebaseWebSocketClient = class {
16004
16277
  cached: normCached[key],
16005
16278
  incoming: normIncoming[key]
16006
16279
  };
16007
- console.debug(`[RebaseWS] Row ${incomingRow.id} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
16280
+ console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
16008
16281
  }
16009
16282
  return incomingRow;
16010
16283
  });
@@ -16249,7 +16522,7 @@ function createRebaseClient(options) {
16249
16522
  });
16250
16523
  return storageSourcesPromise;
16251
16524
  };
16252
- const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
16525
+ const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
16253
16526
  let ws;
16254
16527
  if (resolvedWsUrl) {
16255
16528
  ws = new RebaseWebSocketClient({
@@ -16361,6 +16634,16 @@ function createRebaseClient(options) {
16361
16634
  createStorageSource,
16362
16635
  fetchStorageSources,
16363
16636
  ws,
16637
+ /**
16638
+ * Release the realtime socket and its reconnect timer.
16639
+ *
16640
+ * Until this returns, the open socket keeps the Node event loop alive
16641
+ * and the process will not exit on its own. Safe to call when realtime
16642
+ * was never started, and safe to call twice.
16643
+ */
16644
+ close: () => {
16645
+ ws?.disconnect();
16646
+ },
16364
16647
  setToken: transport.setToken,
16365
16648
  setAuthTokenGetter: transport.setAuthTokenGetter,
16366
16649
  setOnUnauthorized: transport.setOnUnauthorized,
@@ -17069,6 +17352,10 @@ async function _initializeRebaseBackend(config) {
17069
17352
  dataAsAdmin: serverData
17070
17353
  });
17071
17354
  logger.info("Native data plane attached to singleton (bypasses HTTP loop)");
17355
+ if (storageController) {
17356
+ Object.assign(serverClient, { storage: storageController });
17357
+ logger.info("Native storage attached to singleton (bypasses HTTP loop)");
17358
+ }
17072
17359
  let emailService;
17073
17360
  if (authConfigResult?.emailService) emailService = authConfigResult.emailService;
17074
17361
  else if (config.auth && !isAuthAdapter(config.auth) && config.auth.email) emailService = createEmailService(config.auth.email);
@@ -17169,6 +17456,17 @@ async function _initializeRebaseBackend(config) {
17169
17456
  config.app.route(`${basePath}/admin/backups`, backupRouter);
17170
17457
  logger.info("Backup admin routes mounted", { path: `${basePath}/admin/backups` });
17171
17458
  }
17459
+ {
17460
+ const { default: logsRoutes } = await Promise.resolve().then(() => logs_routes_exports);
17461
+ const logsRouter = new Hono();
17462
+ if (authAdapter && !isAuthAdapter(config.auth)) {
17463
+ const safeAuth = config.auth;
17464
+ if (safeAuth.requireAuth !== false && !!safeAuth.jwtSecret) logsRouter.use("/*", requireAuth, requireAdmin);
17465
+ } else if (authAdapter) logsRouter.use("/*", requireAuth, requireAdmin);
17466
+ logsRouter.route("/", logsRoutes);
17467
+ config.app.route(`${basePath}/logs`, logsRouter);
17468
+ logger.info("Logs routes mounted", { path: `${basePath}/logs` });
17469
+ }
17172
17470
  const effectiveRealtimeService = Object.keys(realtimeServices).length > 1 ? createRoutedRealtimeService({
17173
17471
  providers: realtimeServices,
17174
17472
  defaultKey: defaultDriverId,
@@ -17182,9 +17480,34 @@ async function _initializeRebaseBackend(config) {
17182
17480
  cronScheduler,
17183
17481
  realtimeServices
17184
17482
  });
17483
+ /**
17484
+ * Every registry a driver might resolve callbacks from, plus the backend's
17485
+ * own. Each holds its own normalized copy of a collection, so callbacks have
17486
+ * to be written to all of them.
17487
+ */
17488
+ const callbackTargets = () => {
17489
+ const targets = [collectionRegistry];
17490
+ for (const key of [DEFAULT_DRIVER_ID, ...driverRegistry.list()]) {
17491
+ const d = driverRegistry.get(key);
17492
+ if (d?.registry && typeof d.registry.get === "function" && !targets.includes(d.registry)) targets.push(d.registry);
17493
+ }
17494
+ return targets;
17495
+ };
17496
+ const setCollectionCallbacks = (slug, callbacks) => {
17497
+ let attached = 0;
17498
+ for (const registry of callbackTargets()) {
17499
+ const collection = registry.get(slug);
17500
+ if (collection) {
17501
+ collection.callbacks = callbacks;
17502
+ attached++;
17503
+ }
17504
+ }
17505
+ if (attached === 0) logger.warn(`[callbacks] Collection "${slug}" not found in any registry — callbacks not attached.`);
17506
+ };
17185
17507
  return {
17186
17508
  driverRegistry,
17187
17509
  driver: defaultDriver,
17510
+ setCollectionCallbacks,
17188
17511
  realtimeServices,
17189
17512
  realtimeService: effectiveRealtimeService,
17190
17513
  auth: authConfigResult,