@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.
- package/dist/api/rest/api-generator.d.ts +6 -0
- package/dist/api/rest/idempotency.d.ts +20 -0
- package/dist/index.es.js +185 -26
- package/dist/index.es.js.map +1 -1
- package/package.json +5 -5
|
@@ -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
|
-
|
|
16711
|
-
|
|
16712
|
-
|
|
16713
|
-
|
|
16714
|
-
|
|
16715
|
-
|
|
16716
|
-
|
|
16717
|
-
|
|
16718
|
-
|
|
16719
|
-
|
|
16720
|
-
|
|
16721
|
-
|
|
16722
|
-
|
|
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
|
-
|
|
16725
|
-
|
|
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
|
-
|
|
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 ?? [];
|