@rebasepro/server 0.10.1-canary.gdaf6ba4 → 0.11.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.
@@ -16,6 +16,12 @@ export declare class RestApiGenerator {
16
16
  private listLimits;
17
17
  private authAdapter?;
18
18
  constructor(collections: CollectionConfig[], driver: DataDriver, authAdapter?: AuthAdapter, maxBulkRows?: number, listLimits?: ListLimitOptions);
19
+ /**
20
+ * Built on first use rather than in the constructor: it probes the driver
21
+ * for SQL support and creates a table, and most requests never send a key.
22
+ */
23
+ private idempotencyStore?;
24
+ private idempotency;
19
25
  /**
20
26
  * Parse request query params into QueryOptions, applying this generator's
21
27
  * list-pagination bounds (default page size + hard max limit) so no read
@@ -0,0 +1,20 @@
1
+ import { DataDriver } from "@rebasepro/types";
2
+ export interface IdempotencyStore {
3
+ /** What this key answered before, or `undefined` if it is new. */
4
+ recall(key: string, uid: string | undefined): Promise<unknown | undefined>;
5
+ /** Record what this key answered. Never throws — see {@link createIdempotencyStore}. */
6
+ remember(key: string, uid: string | undefined, response: unknown): Promise<void>;
7
+ }
8
+ /**
9
+ * Returns `undefined` when the driver cannot run SQL, which disables the whole
10
+ * mechanism rather than failing writes: a document backend has no table to put
11
+ * this in, and refusing to serve is far worse than the duplicate this prevents.
12
+ *
13
+ * Every method swallows its own errors for the same reason. A write must not
14
+ * fail because the bookkeeping around it did — the worst case of a failed
15
+ * `remember` is the duplicate we already have today, while a thrown error would
16
+ * reject a write the database has already accepted.
17
+ */
18
+ export declare function createIdempotencyStore(driver: DataDriver): IdempotencyStore | undefined;
19
+ /** The header the client sends. Matches the widely used Stripe/IETF spelling. */
20
+ export declare const IDEMPOTENCY_HEADER = "Idempotency-Key";
package/dist/index.es.js CHANGED
@@ -739,6 +739,101 @@ function isFunctionAllowed(permissions, functionName, operation) {
739
739
  return false;
740
740
  }
741
741
  //#endregion
742
+ //#region src/api/rest/idempotency.ts
743
+ /**
744
+ * Remembering what a write already answered, so replaying it does not do it twice.
745
+ *
746
+ * The offline queue replays a mutation whenever it did not see the response —
747
+ * which includes every case where the write *committed* and the ACK was lost to
748
+ * a dropped connection. For a collection whose id the client chooses, the replay
749
+ * collides on that id and the client can recognise its own earlier attempt. For
750
+ * a collection with a serial id it cannot: the server ignored the id the client
751
+ * invented and assigned its own, so the replay is indistinguishable from a new
752
+ * row and inserts a second one. The scaffold's own collections use
753
+ * `isId: "increment"`, so that is the default case, not an exotic one.
754
+ *
755
+ * A key is honoured only for the principal that created it. Mutation ids are
756
+ * generated on the client, so keying on the id alone would let anyone who
757
+ * learned (or guessed) another user's id replay their key and be handed that
758
+ * user's row back — a read of someone else's data through a write endpoint.
759
+ */
760
+ var TABLE$2 = "\"rebase\".\"idempotency_keys\"";
761
+ /**
762
+ * How long a replay is recognised. Long enough to cover an offline stretch and
763
+ * a retry schedule; short enough that the table stays small and a key cannot be
764
+ * replayed indefinitely. Rows past this are pruned opportunistically rather than
765
+ * by a scheduled job — there is no cron guaranteed to be running.
766
+ */
767
+ var TTL_HOURS = 24;
768
+ /** The principal a key belongs to; anonymous and service writes share a sentinel. */
769
+ function principal(uid) {
770
+ return uid && uid.length > 0 ? uid : "\0anon";
771
+ }
772
+ /**
773
+ * Returns `undefined` when the driver cannot run SQL, which disables the whole
774
+ * mechanism rather than failing writes: a document backend has no table to put
775
+ * this in, and refusing to serve is far worse than the duplicate this prevents.
776
+ *
777
+ * Every method swallows its own errors for the same reason. A write must not
778
+ * fail because the bookkeeping around it did — the worst case of a failed
779
+ * `remember` is the duplicate we already have today, while a thrown error would
780
+ * reject a write the database has already accepted.
781
+ */
782
+ function createIdempotencyStore(driver) {
783
+ const admin = driver.admin;
784
+ if (!isSQLAdmin(admin)) return void 0;
785
+ const exec = (sql, params) => admin.executeSql(sql, params ? { params } : void 0);
786
+ let ready;
787
+ /** Created on first use: most deployments never send a key at all. */
788
+ const ensure = () => {
789
+ ready ??= (async () => {
790
+ try {
791
+ await exec("CREATE SCHEMA IF NOT EXISTS rebase");
792
+ await exec(`
793
+ CREATE TABLE IF NOT EXISTS ${TABLE$2} (
794
+ key TEXT NOT NULL,
795
+ uid TEXT NOT NULL,
796
+ response JSONB,
797
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
798
+ PRIMARY KEY (uid, key)
799
+ )
800
+ `);
801
+ await exec(`CREATE INDEX IF NOT EXISTS idx_idempotency_created ON ${TABLE$2}(created_at)`);
802
+ return true;
803
+ } catch (error) {
804
+ logger.warn("Idempotency keys unavailable — a replayed offline write may insert a duplicate row.", { detail: error instanceof Error ? error.message : String(error) });
805
+ return false;
806
+ }
807
+ })();
808
+ return ready;
809
+ };
810
+ return {
811
+ async recall(key, uid) {
812
+ if (!key || !await ensure()) return void 0;
813
+ try {
814
+ return (await exec(`SELECT response FROM ${TABLE$2}
815
+ WHERE uid = $1 AND key = $2 AND created_at > NOW() - INTERVAL '${TTL_HOURS} hours'`, [principal(uid), key]))[0]?.response;
816
+ } catch {
817
+ return;
818
+ }
819
+ },
820
+ async remember(key, uid, response) {
821
+ if (!key || !await ensure()) return;
822
+ try {
823
+ await exec(`INSERT INTO ${TABLE$2} (key, uid, response) VALUES ($1, $2, $3::jsonb)
824
+ ON CONFLICT (uid, key) DO NOTHING`, [
825
+ key,
826
+ principal(uid),
827
+ JSON.stringify(response ?? null)
828
+ ]);
829
+ if (Math.random() < .01) await exec(`DELETE FROM ${TABLE$2} WHERE created_at < NOW() - INTERVAL '${TTL_HOURS} hours'`);
830
+ } catch {}
831
+ }
832
+ };
833
+ }
834
+ /** The header the client sends. Matches the widely used Stripe/IETF spelling. */
835
+ var IDEMPOTENCY_HEADER = "Idempotency-Key";
836
+ //#endregion
742
837
  //#region src/api/rest/api-generator.ts
743
838
  /**
744
839
  * Parse a JSON request body for a create/update. An empty body yields `{}`
@@ -780,6 +875,15 @@ var RestApiGenerator = class {
780
875
  this.router = new Hono();
781
876
  }
782
877
  /**
878
+ * Built on first use rather than in the constructor: it probes the driver
879
+ * for SQL support and creates a table, and most requests never send a key.
880
+ */
881
+ idempotencyStore;
882
+ idempotency() {
883
+ this.idempotencyStore ??= createIdempotencyStore(this.driver) ?? null;
884
+ return this.idempotencyStore ?? void 0;
885
+ }
886
+ /**
783
887
  * Parse request query params into QueryOptions, applying this generator's
784
888
  * list-pagination bounds (default page size + hard max limit) so no read
785
889
  * path can be tricked into buffering an entire table into memory.
@@ -973,6 +1077,13 @@ var RestApiGenerator = class {
973
1077
  ..."emailDeliveryFailed" in result && result.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
974
1078
  }, 201);
975
1079
  }
1080
+ const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1081
+ const uid = c.get("user")?.uid;
1082
+ const store = this.idempotency();
1083
+ if (idempotencyKey && store) {
1084
+ const already = await store.recall(idempotencyKey, uid);
1085
+ if (already !== void 0) return c.json(already, 201);
1086
+ }
976
1087
  const entity = await driver.save({
977
1088
  path,
978
1089
  values: body,
@@ -980,6 +1091,7 @@ var RestApiGenerator = class {
980
1091
  status: "new"
981
1092
  });
982
1093
  const response = this.formatResponse(entity);
1094
+ if (idempotencyKey && store) await store.remember(idempotencyKey, uid, response);
983
1095
  return c.json(response, 201);
984
1096
  } catch (error) {
985
1097
  if (isRebaseApiError(error) && !error.code) {
@@ -13207,12 +13319,13 @@ function createCollectionClient(transport, slug, ws) {
13207
13319
  throw err;
13208
13320
  }
13209
13321
  },
13210
- async create(data, id) {
13322
+ async create(data, id, options) {
13211
13323
  const body = { ...data };
13212
13324
  if (id !== void 0) body.id = id;
13213
13325
  return await transport.request(basePath, {
13214
13326
  method: "POST",
13215
- body: JSON.stringify(body)
13327
+ body: JSON.stringify(body),
13328
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
13216
13329
  });
13217
13330
  },
13218
13331
  async createMany(data, options) {
@@ -15195,6 +15308,21 @@ function isRetryableError(error) {
15195
15308
  if (error instanceof RebaseApiError) return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
15196
15309
  return false;
15197
15310
  }
15311
+ /**
15312
+ * Did this write fail because the row is already there?
15313
+ *
15314
+ * Matched on the SQLSTATE the server passes through (`23505`, unique_violation)
15315
+ * and on 409, never on the message — a duplicate-key message names the
15316
+ * constraint and the values, so it is neither stable nor safe to parse.
15317
+ *
15318
+ * The queue uses this to recognise its own earlier attempt. A create whose
15319
+ * response was lost is replayed, and for a row carrying an id the SDK generated
15320
+ * the server can only be rejecting it because the first attempt actually landed.
15321
+ */
15322
+ function isDuplicateKeyError(error) {
15323
+ if (!(error instanceof RebaseApiError)) return false;
15324
+ return error.code === "23505" || error.status === 409;
15325
+ }
15198
15326
  var ConnectivityMonitor = class {
15199
15327
  state = "online";
15200
15328
  backoffMs;
@@ -15801,6 +15929,25 @@ var OfflineManager = class {
15801
15929
  collections = /* @__PURE__ */ new Map();
15802
15930
  /** In-memory mirror of the current scope's queue, in replay order. */
15803
15931
  queue = [];
15932
+ /**
15933
+ * The mutation currently on the wire, if any.
15934
+ *
15935
+ * `flush` awaits `replay(op)` with `op` still at the head of `queue`, so for
15936
+ * the whole duration of that request the in-flight op is also the queue's
15937
+ * *tail* whenever it is the only entry. Both shortcuts in `enqueue` reach
15938
+ * for the tail, and neither may touch an op the server is already reading:
15939
+ *
15940
+ * - Coalescing an update into it mutates a payload that has already been
15941
+ * serialized and sent, and `drop` then removes the whole entry on ACK —
15942
+ * so the second edit is neither sent nor kept. A silently lost write.
15943
+ * - Cancelling it out against a delete assumes the server never saw the
15944
+ * create. It is seeing it right now, so the row would be created and the
15945
+ * delete never queued — an orphan row nothing will ever remove.
15946
+ *
15947
+ * Guarding on the id rather than on a boolean keeps this correct if the
15948
+ * flush loop ever sends more than one op at a time.
15949
+ */
15950
+ inFlightId = null;
15804
15951
  queueLoad;
15805
15952
  /** Serializes enqueues so concurrent writes keep the order the app made them. */
15806
15953
  enqueueChain = Promise.resolve();
@@ -16048,7 +16195,7 @@ var OfflineManager = class {
16048
16195
  },
16049
16196
  update: async (id, data) => {
16050
16197
  await this.ensureCollection(slug);
16051
- if (this.connectivity.shouldAttempt()) try {
16198
+ if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
16052
16199
  const row = await inner.update(id, data);
16053
16200
  this.connectivity.markSuccess();
16054
16201
  await this.ingest(slug, [row]);
@@ -16077,7 +16224,7 @@ var OfflineManager = class {
16077
16224
  },
16078
16225
  delete: async (id) => {
16079
16226
  await this.ensureCollection(slug);
16080
- if (this.connectivity.shouldAttempt()) try {
16227
+ if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
16081
16228
  await inner.delete(id);
16082
16229
  this.connectivity.markSuccess();
16083
16230
  this.removeLocalRow(slug, id, true);
@@ -16619,7 +16766,7 @@ var OfflineManager = class {
16619
16766
  await this.ensureQueueLoaded();
16620
16767
  if (mutation.type === "update") {
16621
16768
  const tail = this.queue[this.queue.length - 1];
16622
- if (tail && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id) {
16769
+ if (tail && tail.mutationId !== this.inFlightId && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id) {
16623
16770
  tail.data = {
16624
16771
  ...tail.data,
16625
16772
  ...mutation.data,
@@ -16630,8 +16777,8 @@ var OfflineManager = class {
16630
16777
  }
16631
16778
  }
16632
16779
  if (mutation.type === "delete") {
16633
- if (this.queue.some((m) => m.collection === mutation.collection && m.type === "create" && m.id === mutation.id && m.generatedId === true)) {
16634
- const doomed = this.queue.filter((m) => m.collection === mutation.collection && m.id === mutation.id && (m.type === "create" || m.type === "update"));
16780
+ if (this.queue.some((m) => m.collection === mutation.collection && m.type === "create" && m.id === mutation.id && m.generatedId === true && m.mutationId !== this.inFlightId)) {
16781
+ const doomed = this.queue.filter((m) => m.collection === mutation.collection && m.id === mutation.id && (m.type === "create" || m.type === "update") && m.mutationId !== this.inFlightId);
16635
16782
  for (const op of doomed) await this.store.dequeue(this.queueKey(op));
16636
16783
  this.queue = this.queue.filter((m) => !doomed.includes(m));
16637
16784
  this.afterQueueChange();
@@ -16706,27 +16853,32 @@ var OfflineManager = class {
16706
16853
  while (this.queue.length > 0 && !this.disposed) {
16707
16854
  const op = this.queue[0];
16708
16855
  touched.add(op.collection);
16856
+ this.inFlightId = op.mutationId;
16709
16857
  try {
16710
- await this.replay(op);
16711
- } catch (error) {
16712
- if (isNetworkError(error)) {
16713
- this.connectivity.markFailure();
16714
- break;
16715
- }
16716
- op.attempts = (op.attempts ?? 0) + 1;
16717
- op.lastError = error?.message ?? String(error);
16718
- if (isRetryableError(error) && op.attempts < this.maxRetries) {
16719
- await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
16720
- this.connectivity.deferRetry();
16721
- this.patchStatus({ lastError: op.lastError });
16722
- break;
16858
+ try {
16859
+ await this.replay(op);
16860
+ } catch (error) {
16861
+ if (isNetworkError(error)) {
16862
+ this.connectivity.markFailure();
16863
+ break;
16864
+ }
16865
+ op.attempts = (op.attempts ?? 0) + 1;
16866
+ op.lastError = error?.message ?? String(error);
16867
+ if (isRetryableError(error) && op.attempts < this.maxRetries) {
16868
+ await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
16869
+ this.connectivity.deferRetry();
16870
+ this.patchStatus({ lastError: op.lastError });
16871
+ break;
16872
+ }
16873
+ await this.rejectMutation(op, error);
16874
+ continue;
16723
16875
  }
16724
- await this.rejectMutation(op, error);
16725
- continue;
16876
+ this.connectivity.markSuccess();
16877
+ await this.drop(op);
16878
+ flushed++;
16879
+ } finally {
16880
+ this.inFlightId = null;
16726
16881
  }
16727
- this.connectivity.markSuccess();
16728
- await this.drop(op);
16729
- flushed++;
16730
16882
  }
16731
16883
  } finally {
16732
16884
  this.patchStatus({ syncing: false });
@@ -16747,7 +16899,14 @@ var OfflineManager = class {
16747
16899
  async replay(op) {
16748
16900
  const inner = this.innerFor(op.collection);
16749
16901
  if (op.type === "create") {
16750
- const row = await inner.create(op.data);
16902
+ let row;
16903
+ try {
16904
+ row = await inner.create(op.data, void 0, { idempotencyKey: op.mutationId });
16905
+ } catch (error) {
16906
+ if (!(op.generatedId === true && isDuplicateKeyError(error))) throw error;
16907
+ row = await inner.findById(op.id).catch(() => void 0);
16908
+ if (!row) return;
16909
+ }
16751
16910
  await this.adoptServerRow(op, op.id, row);
16752
16911
  } else if (op.type === "createMany") {
16753
16912
  const queued = op.data ?? [];