@syncular/server 0.15.44 → 0.15.46
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/README.md +135 -5
- package/dist/admin.d.ts +12 -6
- package/dist/admin.js +11 -1
- package/dist/authoritative-query.d.ts +20 -0
- package/dist/authoritative-query.js +184 -0
- package/dist/blob-store.d.ts +2 -2
- package/dist/context.d.ts +11 -2
- package/dist/context.js +2 -0
- package/dist/d1-storage.d.ts +10 -1
- package/dist/d1-storage.js +219 -3
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +43 -1
- package/dist/events-ring.d.ts +1 -1
- package/dist/events-ring.js +1 -1
- package/dist/events.d.ts +52 -3
- package/dist/handler.js +6 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/operations-realtime.d.ts +16 -0
- package/dist/operations-realtime.js +196 -0
- package/dist/operations.d.ts +97 -0
- package/dist/operations.js +392 -0
- package/dist/pg-executor.d.ts +1 -1
- package/dist/pg-executor.js +1 -1
- package/dist/postgres-fanout.d.ts +1 -1
- package/dist/postgres-storage.d.ts +11 -2
- package/dist/postgres-storage.js +223 -3
- package/dist/pull.js +1 -1
- package/dist/push.d.ts +8 -2
- package/dist/push.js +75 -21
- package/dist/reactions.d.ts +167 -0
- package/dist/reactions.js +442 -0
- package/dist/realtime.js +4 -1
- package/dist/relational-rows.d.ts +9 -9
- package/dist/relational-rows.js +9 -9
- package/dist/s3-blob-store.js +1 -1
- package/dist/s3-segment-store.js +1 -1
- package/dist/schema.d.ts +4 -5
- package/dist/schema.js +3 -3
- package/dist/seed.js +1 -1
- package/dist/segment-store.d.ts +3 -3
- package/dist/signed-url.js +2 -2
- package/dist/sigv4.d.ts +2 -4
- package/dist/sigv4.js +3 -3
- package/dist/sqlite-blob-store.d.ts +1 -1
- package/dist/sqlite-blob-store.js +1 -1
- package/dist/sqlite-dialect.d.ts +3 -4
- package/dist/sqlite-dialect.js +21 -1
- package/dist/sqlite-image.d.ts +1 -1
- package/dist/sqlite-lease-store.d.ts +1 -1
- package/dist/sqlite-lease-store.js +1 -1
- package/dist/sqlite-segment-store.d.ts +2 -2
- package/dist/sqlite-segment-store.js +2 -2
- package/dist/sqlite-storage.d.ts +11 -2
- package/dist/sqlite-storage.js +219 -4
- package/dist/storage.d.ts +112 -3
- package/dist/validate.js +1 -0
- package/package.json +2 -2
- package/src/admin.ts +30 -6
- package/src/authoritative-query.ts +218 -0
- package/src/blob-store.ts +0 -0
- package/src/context.ts +12 -2
- package/src/d1-storage.ts +355 -3
- package/src/errors.ts +43 -1
- package/src/events-ring.ts +1 -1
- package/src/events.ts +64 -2
- package/src/handler.ts +15 -3
- package/src/index.ts +33 -1
- package/src/operations-realtime.ts +272 -0
- package/src/operations.ts +720 -0
- package/src/pg-executor.ts +1 -1
- package/src/postgres-fanout.ts +1 -1
- package/src/postgres-storage.ts +355 -4
- package/src/pull.ts +1 -1
- package/src/push.ts +97 -29
- package/src/reactions.ts +741 -0
- package/src/realtime.ts +7 -1
- package/src/relational-rows.ts +9 -9
- package/src/s3-blob-store.ts +1 -1
- package/src/s3-segment-store.ts +1 -1
- package/src/schema.ts +6 -7
- package/src/seed.ts +1 -1
- package/src/segment-store.ts +3 -3
- package/src/signed-url.ts +2 -2
- package/src/sigv4.ts +3 -5
- package/src/sqlite-blob-store.ts +1 -1
- package/src/sqlite-dialect.ts +22 -3
- package/src/sqlite-image.ts +1 -1
- package/src/sqlite-lease-store.ts +1 -1
- package/src/sqlite-segment-store.ts +2 -2
- package/src/sqlite-storage.ts +369 -4
- package/src/storage.ts +168 -3
- package/src/validate.ts +1 -0
package/src/pg-executor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The minimal Postgres executor seam
|
|
2
|
+
* The minimal Postgres executor seam has zero runtime dependencies.
|
|
3
3
|
*
|
|
4
4
|
* `PostgresServerStorage` is written entirely against this interface so the
|
|
5
5
|
* server library never imports a specific driver. Production wires one of:
|
package/src/postgres-fanout.ts
CHANGED
package/src/postgres-storage.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Postgres server storage
|
|
2
|
+
* Postgres server storage: the production database path.
|
|
3
3
|
*
|
|
4
4
|
* Semantics mirror `SqliteServerStorage` exactly (both run the shared
|
|
5
5
|
* storage contract in `test/storage-contract.ts`); the difference is that
|
|
@@ -36,6 +36,11 @@
|
|
|
36
36
|
* does not tolerate). Cross-partition pushes never contend.
|
|
37
37
|
*/
|
|
38
38
|
import type { PushOperationResult } from '@syncular/core';
|
|
39
|
+
import {
|
|
40
|
+
bindAuthoritativePartition,
|
|
41
|
+
postgresPlaceholders,
|
|
42
|
+
prepareAuthoritativeQuery,
|
|
43
|
+
} from './authoritative-query';
|
|
39
44
|
import { syncError } from './errors';
|
|
40
45
|
import {
|
|
41
46
|
asBytes,
|
|
@@ -68,14 +73,24 @@ import {
|
|
|
68
73
|
import type { CompiledSchema, CompiledTable } from './schema';
|
|
69
74
|
import { matchesEffective } from './scopes';
|
|
70
75
|
import type {
|
|
76
|
+
AuthoritativeQueryRequest,
|
|
77
|
+
AuthoritativeQueryResult,
|
|
71
78
|
ClientCursorInfo,
|
|
72
79
|
ClientRecord,
|
|
73
80
|
ClientSubscription,
|
|
74
81
|
CommitMetadata,
|
|
75
82
|
CommitMetadataQuery,
|
|
76
83
|
CommitWindowQuery,
|
|
84
|
+
DurableJsonValue,
|
|
77
85
|
IndexRowScanQuery,
|
|
78
86
|
NewCommit,
|
|
87
|
+
NewReaction,
|
|
88
|
+
PrunedReactionCounts,
|
|
89
|
+
ReactionClaimQuery,
|
|
90
|
+
ReactionFailure,
|
|
91
|
+
ReactionFailureUpdate,
|
|
92
|
+
ReactionListQuery,
|
|
93
|
+
ReactionPruneQuery,
|
|
79
94
|
RowScanQuery,
|
|
80
95
|
ScopeActivityQuery,
|
|
81
96
|
ScopeCommitActivity,
|
|
@@ -84,6 +99,7 @@ import type {
|
|
|
84
99
|
StoredChange,
|
|
85
100
|
StoredCommit,
|
|
86
101
|
StoredPushResult,
|
|
102
|
+
StoredReaction,
|
|
87
103
|
StoredRow,
|
|
88
104
|
} from './storage';
|
|
89
105
|
import {
|
|
@@ -151,6 +167,26 @@ CREATE TABLE IF NOT EXISTS sync_push_results(
|
|
|
151
167
|
client_commit_id TEXT NOT NULL, result JSONB NOT NULL,
|
|
152
168
|
PRIMARY KEY(partition, client_id, client_commit_id)
|
|
153
169
|
);
|
|
170
|
+
CREATE TABLE IF NOT EXISTS sync_reactions(
|
|
171
|
+
partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,
|
|
172
|
+
type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,
|
|
173
|
+
source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,
|
|
174
|
+
source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,
|
|
175
|
+
available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,
|
|
176
|
+
attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,
|
|
177
|
+
lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,
|
|
178
|
+
last_failure JSONB,
|
|
179
|
+
PRIMARY KEY(partition, idempotency_key),
|
|
180
|
+
CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))
|
|
181
|
+
);
|
|
182
|
+
CREATE INDEX IF NOT EXISTS sync_reactions_due
|
|
183
|
+
ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);
|
|
184
|
+
CREATE INDEX IF NOT EXISTS sync_reactions_lease
|
|
185
|
+
ON sync_reactions(partition, status, lease_expires_at_ms);
|
|
186
|
+
CREATE INDEX IF NOT EXISTS sync_reactions_completed
|
|
187
|
+
ON sync_reactions(partition, status, completed_at_ms, idempotency_key);
|
|
188
|
+
CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
|
|
189
|
+
ON sync_reactions(partition, status, available_at_ms, idempotency_key);
|
|
154
190
|
CREATE TABLE IF NOT EXISTS sync_clients(
|
|
155
191
|
partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
|
|
156
192
|
cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,
|
|
@@ -185,6 +221,52 @@ function fromBase64(text: string): Uint8Array {
|
|
|
185
221
|
return new Uint8Array(Buffer.from(text, 'base64'));
|
|
186
222
|
}
|
|
187
223
|
|
|
224
|
+
interface PostgresReactionRecord {
|
|
225
|
+
idempotency_key: string;
|
|
226
|
+
type: string;
|
|
227
|
+
version: unknown;
|
|
228
|
+
payload: unknown;
|
|
229
|
+
source_client_id: string;
|
|
230
|
+
source_client_commit_id: string;
|
|
231
|
+
source_commit_seq: unknown;
|
|
232
|
+
created_at_ms: unknown;
|
|
233
|
+
available_at_ms: unknown;
|
|
234
|
+
status: StoredReaction['status'];
|
|
235
|
+
attempts: unknown;
|
|
236
|
+
max_attempts: unknown;
|
|
237
|
+
lease_owner: string | null;
|
|
238
|
+
lease_expires_at_ms: unknown | null;
|
|
239
|
+
completed_at_ms: unknown | null;
|
|
240
|
+
last_failure: unknown | null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function toStoredReaction(record: PostgresReactionRecord): StoredReaction {
|
|
244
|
+
return {
|
|
245
|
+
idempotencyKey: record.idempotency_key,
|
|
246
|
+
type: record.type,
|
|
247
|
+
version: asNumber(record.version),
|
|
248
|
+
payload: asJson(record.payload) as DurableJsonValue,
|
|
249
|
+
sourceClientId: record.source_client_id,
|
|
250
|
+
sourceClientCommitId: record.source_client_commit_id,
|
|
251
|
+
sourceCommitSeq: asNumber(record.source_commit_seq),
|
|
252
|
+
createdAtMs: asNumber(record.created_at_ms),
|
|
253
|
+
maxAttempts: asNumber(record.max_attempts),
|
|
254
|
+
status: record.status,
|
|
255
|
+
attempts: asNumber(record.attempts),
|
|
256
|
+
availableAtMs: asNumber(record.available_at_ms),
|
|
257
|
+
...(record.lease_owner !== null ? { leaseOwner: record.lease_owner } : {}),
|
|
258
|
+
...(record.lease_expires_at_ms !== null
|
|
259
|
+
? { leaseExpiresAtMs: asNumber(record.lease_expires_at_ms) }
|
|
260
|
+
: {}),
|
|
261
|
+
...(record.completed_at_ms !== null
|
|
262
|
+
? { completedAtMs: asNumber(record.completed_at_ms) }
|
|
263
|
+
: {}),
|
|
264
|
+
...(record.last_failure !== null
|
|
265
|
+
? { lastFailure: asJson(record.last_failure) as ReactionFailure }
|
|
266
|
+
: {}),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
188
270
|
/**
|
|
189
271
|
* Serialize a push result to a JSON-able object (stored in a JSONB column).
|
|
190
272
|
* `serverRow` bytes are base64-encoded — JSONB cannot hold raw bytes.
|
|
@@ -336,8 +418,8 @@ function toStoredChange(record: ChangeRecord): StoredChange {
|
|
|
336
418
|
}
|
|
337
419
|
|
|
338
420
|
/**
|
|
339
|
-
* Migration rewrite
|
|
340
|
-
*
|
|
421
|
+
* Migration rewrite: keyset-paged walk of a row table inside the migration
|
|
422
|
+
* transaction. When `oldLayout` is
|
|
341
423
|
* given every payload re-encodes under the current columns, and the
|
|
342
424
|
* projection (when materialized) refreshes from the payload either way.
|
|
343
425
|
*/
|
|
@@ -719,6 +801,31 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
719
801
|
);
|
|
720
802
|
}
|
|
721
803
|
|
|
804
|
+
async enqueueReactions(reactions: readonly NewReaction[]): Promise<void> {
|
|
805
|
+
this.#assertOpen();
|
|
806
|
+
for (const reaction of reactions) {
|
|
807
|
+
await this.#client.query(
|
|
808
|
+
`INSERT INTO sync_reactions(
|
|
809
|
+
partition, idempotency_key, type, version, payload,
|
|
810
|
+
source_client_id, source_client_commit_id, source_commit_seq,
|
|
811
|
+
created_at_ms, available_at_ms, status, attempts, max_attempts
|
|
812
|
+
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9,'pending',0,$10)`,
|
|
813
|
+
[
|
|
814
|
+
this.#partition,
|
|
815
|
+
reaction.idempotencyKey,
|
|
816
|
+
reaction.type,
|
|
817
|
+
reaction.version,
|
|
818
|
+
JSON.stringify(reaction.payload),
|
|
819
|
+
reaction.sourceClientId,
|
|
820
|
+
reaction.sourceClientCommitId,
|
|
821
|
+
reaction.sourceCommitSeq,
|
|
822
|
+
reaction.createdAtMs,
|
|
823
|
+
reaction.maxAttempts,
|
|
824
|
+
],
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
722
829
|
async commit(): Promise<void> {
|
|
723
830
|
this.#assertOpen();
|
|
724
831
|
this.#open = false;
|
|
@@ -940,6 +1047,46 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
940
1047
|
return rows[0] === undefined ? 0 : asNumber(rows[0].max_commit_seq);
|
|
941
1048
|
}
|
|
942
1049
|
|
|
1050
|
+
async queryAuthoritative(
|
|
1051
|
+
partition: string,
|
|
1052
|
+
query: AuthoritativeQueryRequest,
|
|
1053
|
+
): Promise<AuthoritativeQueryResult> {
|
|
1054
|
+
if (this.#tables === undefined) {
|
|
1055
|
+
throw new Error(
|
|
1056
|
+
'ensureSchema(schema) must run before registered queries',
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
const prepared = bindAuthoritativePartition(
|
|
1060
|
+
prepareAuthoritativeQuery(
|
|
1061
|
+
query.sql,
|
|
1062
|
+
query.params,
|
|
1063
|
+
query.tables,
|
|
1064
|
+
this.#tables,
|
|
1065
|
+
),
|
|
1066
|
+
partition,
|
|
1067
|
+
);
|
|
1068
|
+
return this.#exec.transaction(async (client) => {
|
|
1069
|
+
await client.query(
|
|
1070
|
+
'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY',
|
|
1071
|
+
);
|
|
1072
|
+
const result = await client.query<Readonly<Record<string, unknown>>>(
|
|
1073
|
+
postgresPlaceholders(prepared.sql),
|
|
1074
|
+
prepared.params,
|
|
1075
|
+
);
|
|
1076
|
+
const cursor = await client.query<{ max_commit_seq: unknown }>(
|
|
1077
|
+
'SELECT max_commit_seq FROM sync_partitions WHERE partition=$1',
|
|
1078
|
+
[partition],
|
|
1079
|
+
);
|
|
1080
|
+
return {
|
|
1081
|
+
rows: result.rows,
|
|
1082
|
+
maxCommitSeq:
|
|
1083
|
+
cursor.rows[0] === undefined
|
|
1084
|
+
? 0
|
|
1085
|
+
: asNumber(cursor.rows[0].max_commit_seq),
|
|
1086
|
+
};
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
|
|
943
1090
|
async getHorizonSeq(partition: string): Promise<number> {
|
|
944
1091
|
const { rows } = await this.#exec.query<{ horizon_seq: unknown }>(
|
|
945
1092
|
'SELECT horizon_seq FROM sync_partitions WHERE partition=$1',
|
|
@@ -1000,6 +1147,210 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1000
1147
|
return getPushResultOn(this.#exec, partition, clientId, clientCommitId);
|
|
1001
1148
|
}
|
|
1002
1149
|
|
|
1150
|
+
async claimReactions(
|
|
1151
|
+
partition: string,
|
|
1152
|
+
query: ReactionClaimQuery,
|
|
1153
|
+
): Promise<StoredReaction[]> {
|
|
1154
|
+
if (query.types.length === 0 || query.limit <= 0) return [];
|
|
1155
|
+
const typeParams = query.types.map((_, index) => `$${index + 2}`).join(',');
|
|
1156
|
+
const nowParam = query.types.length + 2;
|
|
1157
|
+
const limitParam = nowParam + 1;
|
|
1158
|
+
const workerParam = limitParam + 1;
|
|
1159
|
+
const expiresParam = workerParam + 1;
|
|
1160
|
+
const { rows } = await this.#exec.query<PostgresReactionRecord>(
|
|
1161
|
+
`WITH due AS (
|
|
1162
|
+
SELECT partition, idempotency_key
|
|
1163
|
+
FROM sync_reactions
|
|
1164
|
+
WHERE partition=$1 AND type IN (${typeParams})
|
|
1165
|
+
AND ((status='pending' AND available_at_ms<=$${nowParam})
|
|
1166
|
+
OR (status='leased' AND lease_expires_at_ms<=$${nowParam}))
|
|
1167
|
+
ORDER BY CASE WHEN status='leased' THEN lease_expires_at_ms
|
|
1168
|
+
ELSE available_at_ms END,
|
|
1169
|
+
created_at_ms, idempotency_key
|
|
1170
|
+
FOR UPDATE SKIP LOCKED
|
|
1171
|
+
LIMIT $${limitParam}
|
|
1172
|
+
)
|
|
1173
|
+
UPDATE sync_reactions AS reaction
|
|
1174
|
+
SET status='leased', attempts=reaction.attempts+1,
|
|
1175
|
+
lease_owner=$${workerParam}, lease_expires_at_ms=$${expiresParam},
|
|
1176
|
+
completed_at_ms=NULL
|
|
1177
|
+
FROM due
|
|
1178
|
+
WHERE reaction.partition=due.partition
|
|
1179
|
+
AND reaction.idempotency_key=due.idempotency_key
|
|
1180
|
+
RETURNING reaction.*`,
|
|
1181
|
+
[
|
|
1182
|
+
partition,
|
|
1183
|
+
...query.types,
|
|
1184
|
+
query.nowMs,
|
|
1185
|
+
query.limit,
|
|
1186
|
+
query.leaseOwner,
|
|
1187
|
+
Math.min(Number.MAX_SAFE_INTEGER, query.nowMs + query.leaseDurationMs),
|
|
1188
|
+
],
|
|
1189
|
+
);
|
|
1190
|
+
return rows
|
|
1191
|
+
.map(toStoredReaction)
|
|
1192
|
+
.sort(
|
|
1193
|
+
(a, b) =>
|
|
1194
|
+
a.createdAtMs - b.createdAtMs ||
|
|
1195
|
+
a.idempotencyKey.localeCompare(b.idempotencyKey),
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
async completeReaction(
|
|
1200
|
+
partition: string,
|
|
1201
|
+
idempotencyKey: string,
|
|
1202
|
+
leaseOwner: string,
|
|
1203
|
+
completedAtMs: number,
|
|
1204
|
+
): Promise<boolean> {
|
|
1205
|
+
const result = await this.#exec.query(
|
|
1206
|
+
`UPDATE sync_reactions
|
|
1207
|
+
SET status='completed', completed_at_ms=$4,
|
|
1208
|
+
lease_owner=NULL, lease_expires_at_ms=NULL
|
|
1209
|
+
WHERE partition=$1 AND idempotency_key=$2
|
|
1210
|
+
AND status='leased' AND lease_owner=$3`,
|
|
1211
|
+
[partition, idempotencyKey, leaseOwner, completedAtMs],
|
|
1212
|
+
);
|
|
1213
|
+
return result.rowCount === 1;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
async extendReactionLease(
|
|
1217
|
+
partition: string,
|
|
1218
|
+
idempotencyKey: string,
|
|
1219
|
+
leaseOwner: string,
|
|
1220
|
+
leaseExpiresAtMs: number,
|
|
1221
|
+
): Promise<boolean> {
|
|
1222
|
+
const result = await this.#exec.query(
|
|
1223
|
+
`UPDATE sync_reactions SET lease_expires_at_ms=$4
|
|
1224
|
+
WHERE partition=$1 AND idempotency_key=$2
|
|
1225
|
+
AND status='leased' AND lease_owner=$3`,
|
|
1226
|
+
[partition, idempotencyKey, leaseOwner, leaseExpiresAtMs],
|
|
1227
|
+
);
|
|
1228
|
+
return result.rowCount === 1;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
async failReaction(
|
|
1232
|
+
partition: string,
|
|
1233
|
+
idempotencyKey: string,
|
|
1234
|
+
update: ReactionFailureUpdate,
|
|
1235
|
+
): Promise<boolean> {
|
|
1236
|
+
const result = await this.#exec.query(
|
|
1237
|
+
`UPDATE sync_reactions
|
|
1238
|
+
SET status=$4, available_at_ms=$5, last_failure=$6,
|
|
1239
|
+
lease_owner=NULL, lease_expires_at_ms=NULL
|
|
1240
|
+
WHERE partition=$1 AND idempotency_key=$2
|
|
1241
|
+
AND status='leased' AND lease_owner=$3`,
|
|
1242
|
+
[
|
|
1243
|
+
partition,
|
|
1244
|
+
idempotencyKey,
|
|
1245
|
+
update.leaseOwner,
|
|
1246
|
+
update.retryAtMs === undefined ? 'dead-letter' : 'pending',
|
|
1247
|
+
update.retryAtMs ?? update.failure.atMs,
|
|
1248
|
+
JSON.stringify(update.failure),
|
|
1249
|
+
],
|
|
1250
|
+
);
|
|
1251
|
+
return result.rowCount === 1;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async retryReaction(
|
|
1255
|
+
partition: string,
|
|
1256
|
+
idempotencyKey: string,
|
|
1257
|
+
nowMs: number,
|
|
1258
|
+
): Promise<boolean> {
|
|
1259
|
+
const result = await this.#exec.query(
|
|
1260
|
+
`UPDATE sync_reactions
|
|
1261
|
+
SET status='pending', attempts=0, available_at_ms=$3,
|
|
1262
|
+
last_failure=NULL, lease_owner=NULL, lease_expires_at_ms=NULL,
|
|
1263
|
+
completed_at_ms=NULL
|
|
1264
|
+
WHERE partition=$1 AND idempotency_key=$2 AND status='dead-letter'`,
|
|
1265
|
+
[partition, idempotencyKey, nowMs],
|
|
1266
|
+
);
|
|
1267
|
+
return result.rowCount === 1;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
async getReaction(
|
|
1271
|
+
partition: string,
|
|
1272
|
+
idempotencyKey: string,
|
|
1273
|
+
): Promise<StoredReaction | undefined> {
|
|
1274
|
+
const { rows } = await this.#exec.query<PostgresReactionRecord>(
|
|
1275
|
+
'SELECT * FROM sync_reactions WHERE partition=$1 AND idempotency_key=$2',
|
|
1276
|
+
[partition, idempotencyKey],
|
|
1277
|
+
);
|
|
1278
|
+
return rows[0] === undefined ? undefined : toStoredReaction(rows[0]);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
async listReactions(
|
|
1282
|
+
partition: string,
|
|
1283
|
+
query: ReactionListQuery,
|
|
1284
|
+
): Promise<StoredReaction[]> {
|
|
1285
|
+
const where = ['partition=$1'];
|
|
1286
|
+
const params: unknown[] = [partition];
|
|
1287
|
+
if (query.statuses !== undefined && query.statuses.length > 0) {
|
|
1288
|
+
const placeholders = query.statuses.map(
|
|
1289
|
+
(_, index) => `$${params.length + index + 1}`,
|
|
1290
|
+
);
|
|
1291
|
+
where.push(`status IN (${placeholders.join(',')})`);
|
|
1292
|
+
params.push(...query.statuses);
|
|
1293
|
+
}
|
|
1294
|
+
if (query.types !== undefined && query.types.length > 0) {
|
|
1295
|
+
const placeholders = query.types.map(
|
|
1296
|
+
(_, index) => `$${params.length + index + 1}`,
|
|
1297
|
+
);
|
|
1298
|
+
where.push(`type IN (${placeholders.join(',')})`);
|
|
1299
|
+
params.push(...query.types);
|
|
1300
|
+
}
|
|
1301
|
+
params.push(query.limit);
|
|
1302
|
+
const { rows } = await this.#exec.query<PostgresReactionRecord>(
|
|
1303
|
+
`SELECT * FROM sync_reactions WHERE ${where.join(' AND ')}
|
|
1304
|
+
ORDER BY created_at_ms DESC, idempotency_key DESC LIMIT $${params.length}`,
|
|
1305
|
+
params,
|
|
1306
|
+
);
|
|
1307
|
+
return rows.map(toStoredReaction);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
async pruneReactions(
|
|
1311
|
+
partition: string,
|
|
1312
|
+
query: ReactionPruneQuery,
|
|
1313
|
+
): Promise<PrunedReactionCounts> {
|
|
1314
|
+
if (query.limit <= 0) return { completed: 0, deadLetter: 0 };
|
|
1315
|
+
const { rows } = await this.#exec.query<{
|
|
1316
|
+
status: 'completed' | 'dead-letter';
|
|
1317
|
+
}>(
|
|
1318
|
+
`WITH targets AS (
|
|
1319
|
+
SELECT partition, idempotency_key
|
|
1320
|
+
FROM sync_reactions
|
|
1321
|
+
WHERE partition=$1
|
|
1322
|
+
AND ((status='completed' AND completed_at_ms IS NOT NULL
|
|
1323
|
+
AND completed_at_ms<$2)
|
|
1324
|
+
OR (status='dead-letter' AND available_at_ms<$3))
|
|
1325
|
+
ORDER BY CASE WHEN status='completed' THEN completed_at_ms
|
|
1326
|
+
ELSE available_at_ms END,
|
|
1327
|
+
idempotency_key
|
|
1328
|
+
FOR UPDATE SKIP LOCKED
|
|
1329
|
+
LIMIT $4
|
|
1330
|
+
)
|
|
1331
|
+
DELETE FROM sync_reactions AS reaction
|
|
1332
|
+
USING targets
|
|
1333
|
+
WHERE reaction.partition=targets.partition
|
|
1334
|
+
AND reaction.idempotency_key=targets.idempotency_key
|
|
1335
|
+
AND ((reaction.status='completed'
|
|
1336
|
+
AND reaction.completed_at_ms IS NOT NULL
|
|
1337
|
+
AND reaction.completed_at_ms<$2)
|
|
1338
|
+
OR (reaction.status='dead-letter'
|
|
1339
|
+
AND reaction.available_at_ms<$3))
|
|
1340
|
+
RETURNING reaction.status`,
|
|
1341
|
+
[
|
|
1342
|
+
partition,
|
|
1343
|
+
query.completedBeforeMs,
|
|
1344
|
+
query.deadLetterBeforeMs,
|
|
1345
|
+
query.limit,
|
|
1346
|
+
],
|
|
1347
|
+
);
|
|
1348
|
+
return {
|
|
1349
|
+
completed: rows.filter((row) => row.status === 'completed').length,
|
|
1350
|
+
deadLetter: rows.filter((row) => row.status === 'dead-letter').length,
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1003
1354
|
async readCommitWindow(
|
|
1004
1355
|
partition: string,
|
|
1005
1356
|
query: CommitWindowQuery,
|
|
@@ -1251,7 +1602,7 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1251
1602
|
return rows.map((r) => r.blob_id);
|
|
1252
1603
|
}
|
|
1253
1604
|
|
|
1254
|
-
// -- admin/console read surface
|
|
1605
|
+
// -- admin/console read surface --------------------------------------------
|
|
1255
1606
|
|
|
1256
1607
|
async listClientRecords(partition: string): Promise<ClientRecord[]> {
|
|
1257
1608
|
const { rows } = await this.#exec.query<{
|
package/src/pull.ts
CHANGED
|
@@ -253,7 +253,7 @@ async function* sqliteImageSegment(
|
|
|
253
253
|
// builder through `sqliteImageBuilder`; when omitted we default to the
|
|
254
254
|
// in-tree `buildSqliteImage` ONLY on a Bun runtime, reached by a *dynamic*
|
|
255
255
|
// import so `bun:sqlite` is never a static dependency of the pull path
|
|
256
|
-
// (
|
|
256
|
+
// (runtime neutrality is enforced by test/runtime-neutrality.test.ts). On
|
|
257
257
|
// Workers/edge (no `Bun`) this yields the rows lane for a bit-2 client — a
|
|
258
258
|
// support floor, not a fallback (§5.3: sqlite is an *accept*, not a demand).
|
|
259
259
|
const buildImage = await resolveImageBuilder(ctx);
|