@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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { CompiledTable } from './schema';
|
|
2
|
+
import { quoteIdent, SYNC_PARTITION_COLUMN } from './relational-rows';
|
|
3
|
+
import type { AuthoritativeQueryValue } from './storage';
|
|
4
|
+
|
|
5
|
+
export interface PreparedAuthoritativeQuery {
|
|
6
|
+
readonly sql: string;
|
|
7
|
+
readonly params: readonly (AuthoritativeQueryValue | typeof PARTITION_BIND)[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BoundAuthoritativeQuery {
|
|
11
|
+
readonly sql: string;
|
|
12
|
+
readonly params: readonly AuthoritativeQueryValue[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const PARTITION_BIND = Symbol('syncular.authoritative_partition');
|
|
16
|
+
|
|
17
|
+
const RESERVED_ALIAS = new Set([
|
|
18
|
+
'on',
|
|
19
|
+
'where',
|
|
20
|
+
'group',
|
|
21
|
+
'order',
|
|
22
|
+
'inner',
|
|
23
|
+
'left',
|
|
24
|
+
'right',
|
|
25
|
+
'full',
|
|
26
|
+
'outer',
|
|
27
|
+
'natural',
|
|
28
|
+
'join',
|
|
29
|
+
'cross',
|
|
30
|
+
'using',
|
|
31
|
+
'limit',
|
|
32
|
+
'having',
|
|
33
|
+
]);
|
|
34
|
+
const IDENT = '[A-Za-z_][A-Za-z0-9_]*';
|
|
35
|
+
const TABLE_REF_RE = new RegExp(
|
|
36
|
+
`\\b(FROM|(?:NATURAL\\s+)?(?:(?:LEFT|RIGHT|FULL)(?:\\s+OUTER)?|INNER|CROSS)?\\s*JOIN)\\s+((?:\\(\\s*)*)(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${[...RESERVED_ALIAS].join('|')})\\b)${IDENT}))?`,
|
|
37
|
+
'gi',
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
function protectedSqlEnd(sql: string, index: number): number | undefined {
|
|
41
|
+
const char = sql[index];
|
|
42
|
+
const next = sql[index + 1];
|
|
43
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
44
|
+
let end = index + 1;
|
|
45
|
+
while (end < sql.length) {
|
|
46
|
+
if (sql[end] === char && sql[end + 1] === char) end += 2;
|
|
47
|
+
else if (sql[end] === char) return end + 1;
|
|
48
|
+
else end += 1;
|
|
49
|
+
}
|
|
50
|
+
return sql.length;
|
|
51
|
+
}
|
|
52
|
+
if (char === '[') {
|
|
53
|
+
const end = sql.indexOf(']', index + 1);
|
|
54
|
+
return end < 0 ? sql.length : end + 1;
|
|
55
|
+
}
|
|
56
|
+
if (char === '-' && next === '-') {
|
|
57
|
+
const end = sql.indexOf('\n', index);
|
|
58
|
+
return end < 0 ? sql.length : end;
|
|
59
|
+
}
|
|
60
|
+
if (char === '/' && next === '*') {
|
|
61
|
+
const end = sql.indexOf('*/', index + 2);
|
|
62
|
+
return end < 0 ? sql.length : end + 2;
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function maskedSql(sql: string): string {
|
|
68
|
+
let out = '';
|
|
69
|
+
let index = 0;
|
|
70
|
+
while (index < sql.length) {
|
|
71
|
+
const end = protectedSqlEnd(sql, index);
|
|
72
|
+
if (end === undefined) out += sql[index];
|
|
73
|
+
else out += sql.slice(index, end).replace(/[^\n]/g, ' ');
|
|
74
|
+
index = end ?? index + 1;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Turn generated local SQL into a partition-local authoritative statement.
|
|
81
|
+
* Only relations declared by the generated descriptor are rewritten. Values
|
|
82
|
+
* remain parameters; request data is never interpolated into SQL.
|
|
83
|
+
*/
|
|
84
|
+
export function prepareAuthoritativeQuery(
|
|
85
|
+
sql: string,
|
|
86
|
+
params: readonly AuthoritativeQueryValue[],
|
|
87
|
+
declaredTables: readonly string[],
|
|
88
|
+
tables: ReadonlyMap<string, CompiledTable>,
|
|
89
|
+
): PreparedAuthoritativeQuery {
|
|
90
|
+
if (maskedSql(sql).includes(';'))
|
|
91
|
+
throw new Error('registered query must be one SELECT');
|
|
92
|
+
const declared = new Set(declaredTables);
|
|
93
|
+
const masked = maskedSql(sql);
|
|
94
|
+
const replacements: Array<{
|
|
95
|
+
readonly start: number;
|
|
96
|
+
readonly end: number;
|
|
97
|
+
readonly text: string;
|
|
98
|
+
}> = [];
|
|
99
|
+
const found = new Set<string>();
|
|
100
|
+
for (const match of masked.matchAll(TABLE_REF_RE)) {
|
|
101
|
+
const rawTable = match[3] as string;
|
|
102
|
+
const table = tables.get(rawTable);
|
|
103
|
+
if (table === undefined) continue;
|
|
104
|
+
if (!declared.has(table.name)) {
|
|
105
|
+
throw new Error('registered query table metadata does not match its SQL');
|
|
106
|
+
}
|
|
107
|
+
if (!table.materialize) {
|
|
108
|
+
throw new Error('registered query targets a non-materialized table');
|
|
109
|
+
}
|
|
110
|
+
let alias = match[4];
|
|
111
|
+
if (alias !== undefined && RESERVED_ALIAS.has(alias.toLowerCase())) {
|
|
112
|
+
alias = undefined;
|
|
113
|
+
}
|
|
114
|
+
const matchStart = match.index ?? 0;
|
|
115
|
+
const afterOperator = (match[1] as string).length;
|
|
116
|
+
const relative = match[0]
|
|
117
|
+
.toLowerCase()
|
|
118
|
+
.indexOf(rawTable.toLowerCase(), afterOperator);
|
|
119
|
+
const start = matchStart + relative;
|
|
120
|
+
const projection = table.columns.map((column) => quoteIdent(column.name));
|
|
121
|
+
replacements.push({
|
|
122
|
+
start,
|
|
123
|
+
end: start + rawTable.length,
|
|
124
|
+
text: `(SELECT ${projection.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=/*syncular_partition*/?)${alias === undefined ? ` AS ${quoteIdent(table.name)}` : ''}`,
|
|
125
|
+
});
|
|
126
|
+
found.add(table.name);
|
|
127
|
+
}
|
|
128
|
+
if (
|
|
129
|
+
found.size !== declared.size ||
|
|
130
|
+
[...declared].some((table) => !found.has(table))
|
|
131
|
+
) {
|
|
132
|
+
throw new Error('registered query table metadata does not match its SQL');
|
|
133
|
+
}
|
|
134
|
+
let rewritten = sql;
|
|
135
|
+
for (const replacement of replacements.sort(
|
|
136
|
+
(left, right) => right.start - left.start,
|
|
137
|
+
)) {
|
|
138
|
+
rewritten =
|
|
139
|
+
rewritten.slice(0, replacement.start) +
|
|
140
|
+
replacement.text +
|
|
141
|
+
rewritten.slice(replacement.end);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const bound: (AuthoritativeQueryValue | typeof PARTITION_BIND)[] = [];
|
|
145
|
+
let anonymousIndex = 0;
|
|
146
|
+
let rendered = '';
|
|
147
|
+
for (let index = 0; index < rewritten.length; index += 1) {
|
|
148
|
+
if (rewritten.startsWith('/*syncular_partition*/?', index)) {
|
|
149
|
+
rendered += '?';
|
|
150
|
+
bound.push(PARTITION_BIND);
|
|
151
|
+
index += '/*syncular_partition*/?'.length - 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const protectedEnd = protectedSqlEnd(rewritten, index);
|
|
155
|
+
if (protectedEnd !== undefined) {
|
|
156
|
+
rendered += rewritten.slice(index, protectedEnd);
|
|
157
|
+
index = protectedEnd - 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const char = rewritten[index] as string;
|
|
161
|
+
if (char !== '?') {
|
|
162
|
+
rendered += char;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
let end = index + 1;
|
|
166
|
+
while (end < rewritten.length && /[0-9]/.test(rewritten[end] as string)) {
|
|
167
|
+
end += 1;
|
|
168
|
+
}
|
|
169
|
+
const numbered = rewritten.slice(index + 1, end);
|
|
170
|
+
const parameterIndex =
|
|
171
|
+
numbered.length > 0
|
|
172
|
+
? Number.parseInt(numbered, 10) - 1
|
|
173
|
+
: anonymousIndex++;
|
|
174
|
+
if (numbered.length > 0) {
|
|
175
|
+
anonymousIndex = Math.max(anonymousIndex, parameterIndex + 1);
|
|
176
|
+
}
|
|
177
|
+
if (parameterIndex < 0 || parameterIndex >= params.length) {
|
|
178
|
+
throw new Error('registered query bind metadata does not match its SQL');
|
|
179
|
+
}
|
|
180
|
+
const value = params[parameterIndex];
|
|
181
|
+
if (value === undefined) {
|
|
182
|
+
throw new Error('registered query bind metadata does not match its SQL');
|
|
183
|
+
}
|
|
184
|
+
rendered += '?';
|
|
185
|
+
bound.push(value);
|
|
186
|
+
index = end - 1;
|
|
187
|
+
}
|
|
188
|
+
return { sql: rendered, params: bound };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function bindAuthoritativePartition(
|
|
192
|
+
prepared: PreparedAuthoritativeQuery,
|
|
193
|
+
partition: string,
|
|
194
|
+
): BoundAuthoritativeQuery {
|
|
195
|
+
return {
|
|
196
|
+
sql: prepared.sql,
|
|
197
|
+
params: prepared.params.map((value) =>
|
|
198
|
+
value === PARTITION_BIND ? partition : value,
|
|
199
|
+
),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function postgresPlaceholders(sql: string): string {
|
|
204
|
+
let bind = 0;
|
|
205
|
+
let rendered = '';
|
|
206
|
+
for (let index = 0; index < sql.length; index += 1) {
|
|
207
|
+
const protectedEnd = protectedSqlEnd(sql, index);
|
|
208
|
+
if (protectedEnd !== undefined) {
|
|
209
|
+
rendered += sql.slice(index, protectedEnd);
|
|
210
|
+
index = protectedEnd - 1;
|
|
211
|
+
} else if (sql[index] === '?') {
|
|
212
|
+
rendered += `$${++bind}`;
|
|
213
|
+
} else {
|
|
214
|
+
rendered += sql[index];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return rendered;
|
|
218
|
+
}
|
package/src/blob-store.ts
CHANGED
|
Binary file
|
package/src/context.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The host context for `handleSyncRequest
|
|
2
|
+
* The host context for `handleSyncRequest`: storage, the
|
|
3
3
|
* `resolveScopes` host callback (runs in the host process — the moat),
|
|
4
4
|
* clock, and a segment store. Framework-free; adapters supply `partition`
|
|
5
5
|
* and `actorId` from host authentication (§1.1).
|
|
@@ -9,6 +9,7 @@ import type { BlobStore } from './blob-store';
|
|
|
9
9
|
import type { CrdtMergerRegistry } from './crdt-merger';
|
|
10
10
|
import type { SyncularServerEvents } from './events';
|
|
11
11
|
import type { LeaseStore } from './lease-store';
|
|
12
|
+
import type { AnyReactionPlanner } from './reactions';
|
|
12
13
|
import type { ServerSchema } from './schema';
|
|
13
14
|
import type { SegmentStore } from './segment-store';
|
|
14
15
|
import type {
|
|
@@ -23,6 +24,9 @@ import type { CommitValidator, ValidatorRegistry } from './validate';
|
|
|
23
24
|
/** SSP2 body content type (§1.1). */
|
|
24
25
|
export const SSP2_CONTENT_TYPE = 'application/vnd.syncular.sync.v2';
|
|
25
26
|
|
|
27
|
+
/** Internal idempotency namespace. Ordinary SSP2 client IDs cannot use it. */
|
|
28
|
+
export const REMOTE_COMMAND_CLIENT_ID_PREFIX = '["remote-command",';
|
|
29
|
+
|
|
26
30
|
export interface ResolveScopesArgs {
|
|
27
31
|
readonly partition: string;
|
|
28
32
|
readonly actorId: string;
|
|
@@ -119,6 +123,12 @@ export interface SyncServerConfig {
|
|
|
119
123
|
* commit-log/idempotency append. A throw rolls back the complete commit.
|
|
120
124
|
*/
|
|
121
125
|
readonly commitValidator?: CommitValidator;
|
|
126
|
+
/**
|
|
127
|
+
* Pure durable-reaction planner. Runs once after candidate validation and
|
|
128
|
+
* before commit-log/idempotency append. Its bounded records are enqueued in
|
|
129
|
+
* the same transaction; handlers run later through `ReactionRunner`.
|
|
130
|
+
*/
|
|
131
|
+
readonly reactionPlanner?: AnyReactionPlanner;
|
|
122
132
|
readonly resolveScopes: ResolveScopes;
|
|
123
133
|
/**
|
|
124
134
|
* §7.3 auth leases. Absent ⇒ the feature is off: no `LEASE` frame is
|
|
@@ -154,7 +164,7 @@ export interface SyncServerConfig {
|
|
|
154
164
|
*/
|
|
155
165
|
readonly blobUploadUrls?: BlobUploadPresignConfig;
|
|
156
166
|
/**
|
|
157
|
-
* §5.3 sqlite-image builder
|
|
167
|
+
* §5.3 sqlite-image builder, injected so the pull path never
|
|
158
168
|
* statically imports `bun:sqlite`. Absent ⇒ the sqlite-image lane is off
|
|
159
169
|
* (bit-2 clients are served the rows lane) — the Workers/edge posture. A
|
|
160
170
|
* Bun/Node host wires `buildSqliteImage` from `@syncular/server`.
|
package/src/d1-storage.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cloudflare D1 server storage
|
|
2
|
+
* Cloudflare D1 server storage for Workers deployments.
|
|
3
3
|
*
|
|
4
4
|
* D1 *is* SQLite exposed over an async, statement-at-a-time API
|
|
5
5
|
* (`prepare(sql).bind(...).all()` / `.first()` / `.run()`, plus `batch([…])`
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
* rather than a lock D1 does not expose.
|
|
41
41
|
*/
|
|
42
42
|
import { decodeRow, type RowValue } from '@syncular/core';
|
|
43
|
+
import {
|
|
44
|
+
bindAuthoritativePartition,
|
|
45
|
+
prepareAuthoritativeQuery,
|
|
46
|
+
} from './authoritative-query';
|
|
43
47
|
import { syncError } from './errors';
|
|
44
48
|
import {
|
|
45
49
|
commitWindowPageSql,
|
|
@@ -79,14 +83,24 @@ import {
|
|
|
79
83
|
toStoredRow,
|
|
80
84
|
} from './sqlite-dialect';
|
|
81
85
|
import type {
|
|
86
|
+
AuthoritativeQueryRequest,
|
|
87
|
+
AuthoritativeQueryResult,
|
|
82
88
|
ClientCursorInfo,
|
|
83
89
|
ClientRecord,
|
|
84
90
|
ClientSubscription,
|
|
85
91
|
CommitMetadata,
|
|
86
92
|
CommitMetadataQuery,
|
|
87
93
|
CommitWindowQuery,
|
|
94
|
+
DurableJsonValue,
|
|
88
95
|
IndexRowScanQuery,
|
|
89
96
|
NewCommit,
|
|
97
|
+
NewReaction,
|
|
98
|
+
PrunedReactionCounts,
|
|
99
|
+
ReactionClaimQuery,
|
|
100
|
+
ReactionFailure,
|
|
101
|
+
ReactionFailureUpdate,
|
|
102
|
+
ReactionListQuery,
|
|
103
|
+
ReactionPruneQuery,
|
|
90
104
|
RowScanQuery,
|
|
91
105
|
ScopeActivityQuery,
|
|
92
106
|
ScopeCommitActivity,
|
|
@@ -94,6 +108,7 @@ import type {
|
|
|
94
108
|
StorageTransaction,
|
|
95
109
|
StoredCommit,
|
|
96
110
|
StoredPushResult,
|
|
111
|
+
StoredReaction,
|
|
97
112
|
StoredRow,
|
|
98
113
|
} from './storage';
|
|
99
114
|
import { isD1ConstraintError, StorageConstraintError } from './storage-errors';
|
|
@@ -122,6 +137,52 @@ interface BufferedStatement {
|
|
|
122
137
|
readonly params: readonly unknown[];
|
|
123
138
|
}
|
|
124
139
|
|
|
140
|
+
interface D1ReactionRecord {
|
|
141
|
+
idempotency_key: string;
|
|
142
|
+
type: string;
|
|
143
|
+
version: number;
|
|
144
|
+
payload: string;
|
|
145
|
+
source_client_id: string;
|
|
146
|
+
source_client_commit_id: string;
|
|
147
|
+
source_commit_seq: number;
|
|
148
|
+
created_at_ms: number;
|
|
149
|
+
available_at_ms: number;
|
|
150
|
+
status: StoredReaction['status'];
|
|
151
|
+
attempts: number;
|
|
152
|
+
max_attempts: number;
|
|
153
|
+
lease_owner: string | null;
|
|
154
|
+
lease_expires_at_ms: number | null;
|
|
155
|
+
completed_at_ms: number | null;
|
|
156
|
+
last_failure: string | null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toStoredReaction(record: D1ReactionRecord): StoredReaction {
|
|
160
|
+
return {
|
|
161
|
+
idempotencyKey: record.idempotency_key,
|
|
162
|
+
type: record.type,
|
|
163
|
+
version: record.version,
|
|
164
|
+
payload: JSON.parse(record.payload) as DurableJsonValue,
|
|
165
|
+
sourceClientId: record.source_client_id,
|
|
166
|
+
sourceClientCommitId: record.source_client_commit_id,
|
|
167
|
+
sourceCommitSeq: record.source_commit_seq,
|
|
168
|
+
createdAtMs: record.created_at_ms,
|
|
169
|
+
maxAttempts: record.max_attempts,
|
|
170
|
+
status: record.status,
|
|
171
|
+
attempts: record.attempts,
|
|
172
|
+
availableAtMs: record.available_at_ms,
|
|
173
|
+
...(record.lease_owner !== null ? { leaseOwner: record.lease_owner } : {}),
|
|
174
|
+
...(record.lease_expires_at_ms !== null
|
|
175
|
+
? { leaseExpiresAtMs: record.lease_expires_at_ms }
|
|
176
|
+
: {}),
|
|
177
|
+
...(record.completed_at_ms !== null
|
|
178
|
+
? { completedAtMs: record.completed_at_ms }
|
|
179
|
+
: {}),
|
|
180
|
+
...(record.last_failure !== null
|
|
181
|
+
? { lastFailure: JSON.parse(record.last_failure) as ReactionFailure }
|
|
182
|
+
: {}),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
125
186
|
function relationalValuesEqual(left: RowValue, right: RowValue): boolean {
|
|
126
187
|
if (left instanceof Uint8Array && right instanceof Uint8Array) {
|
|
127
188
|
return (
|
|
@@ -588,6 +649,32 @@ class D1Transaction implements StorageTransaction {
|
|
|
588
649
|
);
|
|
589
650
|
}
|
|
590
651
|
|
|
652
|
+
async enqueueReactions(reactions: readonly NewReaction[]): Promise<void> {
|
|
653
|
+
this.#assertOpen();
|
|
654
|
+
for (const reaction of reactions) {
|
|
655
|
+
this.#buffer_(
|
|
656
|
+
`INSERT INTO sync_reactions(
|
|
657
|
+
partition, idempotency_key, type, version, payload,
|
|
658
|
+
source_client_id, source_client_commit_id, source_commit_seq,
|
|
659
|
+
created_at_ms, available_at_ms, status, attempts, max_attempts
|
|
660
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,'pending',0,?)`,
|
|
661
|
+
[
|
|
662
|
+
this.#partition,
|
|
663
|
+
reaction.idempotencyKey,
|
|
664
|
+
reaction.type,
|
|
665
|
+
reaction.version,
|
|
666
|
+
JSON.stringify(reaction.payload),
|
|
667
|
+
reaction.sourceClientId,
|
|
668
|
+
reaction.sourceClientCommitId,
|
|
669
|
+
reaction.sourceCommitSeq,
|
|
670
|
+
reaction.createdAtMs,
|
|
671
|
+
reaction.createdAtMs,
|
|
672
|
+
reaction.maxAttempts,
|
|
673
|
+
],
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
591
678
|
async commit(): Promise<void> {
|
|
592
679
|
this.#assertOpen();
|
|
593
680
|
if (this.#buffer.length === 0) {
|
|
@@ -687,7 +774,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
687
774
|
const bindCount = tableColumnNames(table).length;
|
|
688
775
|
if (bindCount > D1_MAX_BIND_PARAMS) {
|
|
689
776
|
throw new Error(
|
|
690
|
-
`table ${JSON.stringify(table.name)} needs ${bindCount} bound parameters per upsert
|
|
777
|
+
`table ${JSON.stringify(table.name)} needs ${bindCount} bound parameters per upsert; D1 caps statements at ${D1_MAX_BIND_PARAMS}`,
|
|
691
778
|
);
|
|
692
779
|
}
|
|
693
780
|
}
|
|
@@ -831,6 +918,61 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
831
918
|
return row?.max_commit_seq ?? 0;
|
|
832
919
|
}
|
|
833
920
|
|
|
921
|
+
async queryAuthoritative(
|
|
922
|
+
partition: string,
|
|
923
|
+
query: AuthoritativeQueryRequest,
|
|
924
|
+
): Promise<AuthoritativeQueryResult> {
|
|
925
|
+
if (this.#tables === undefined) {
|
|
926
|
+
throw new Error(
|
|
927
|
+
'ensureSchema(schema) must run before registered queries',
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
const prepared = bindAuthoritativePartition(
|
|
931
|
+
prepareAuthoritativeQuery(
|
|
932
|
+
query.sql,
|
|
933
|
+
query.params,
|
|
934
|
+
query.tables,
|
|
935
|
+
this.#tables,
|
|
936
|
+
),
|
|
937
|
+
partition,
|
|
938
|
+
);
|
|
939
|
+
const results = await this.#db.batch([
|
|
940
|
+
this.#db.prepare(prepared.sql).bind(...prepared.params),
|
|
941
|
+
this.#db
|
|
942
|
+
.prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
943
|
+
.bind(partition),
|
|
944
|
+
]);
|
|
945
|
+
const rowsResult = results[0];
|
|
946
|
+
const cursorResult = results[1];
|
|
947
|
+
if (
|
|
948
|
+
typeof rowsResult !== 'object' ||
|
|
949
|
+
rowsResult === null ||
|
|
950
|
+
!('results' in rowsResult) ||
|
|
951
|
+
!Array.isArray(rowsResult.results) ||
|
|
952
|
+
typeof cursorResult !== 'object' ||
|
|
953
|
+
cursorResult === null ||
|
|
954
|
+
!('results' in cursorResult) ||
|
|
955
|
+
!Array.isArray(cursorResult.results)
|
|
956
|
+
) {
|
|
957
|
+
throw new Error('D1 registered query returned an invalid batch result');
|
|
958
|
+
}
|
|
959
|
+
const cursor = cursorResult.results[0];
|
|
960
|
+
const maxCommitSeq =
|
|
961
|
+
typeof cursor === 'object' &&
|
|
962
|
+
cursor !== null &&
|
|
963
|
+
'max_commit_seq' in cursor &&
|
|
964
|
+
typeof cursor.max_commit_seq === 'number'
|
|
965
|
+
? cursor.max_commit_seq
|
|
966
|
+
: 0;
|
|
967
|
+
return {
|
|
968
|
+
rows: rowsResult.results.filter(
|
|
969
|
+
(row): row is Readonly<Record<string, unknown>> =>
|
|
970
|
+
typeof row === 'object' && row !== null,
|
|
971
|
+
),
|
|
972
|
+
maxCommitSeq,
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
834
976
|
async getHorizonSeq(partition: string): Promise<number> {
|
|
835
977
|
const row = await this.#db
|
|
836
978
|
.prepare('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
|
|
@@ -918,6 +1060,216 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
918
1060
|
}
|
|
919
1061
|
}
|
|
920
1062
|
|
|
1063
|
+
async claimReactions(
|
|
1064
|
+
partition: string,
|
|
1065
|
+
query: ReactionClaimQuery,
|
|
1066
|
+
): Promise<StoredReaction[]> {
|
|
1067
|
+
if (query.types.length === 0 || query.limit <= 0) return [];
|
|
1068
|
+
if (query.types.length + 7 > D1_MAX_BIND_PARAMS) {
|
|
1069
|
+
throw new Error('D1 reaction claim exceeds the bound-parameter limit');
|
|
1070
|
+
}
|
|
1071
|
+
const typeParams = query.types.map(() => '?').join(',');
|
|
1072
|
+
const { results } = await this.#db
|
|
1073
|
+
.prepare(
|
|
1074
|
+
`UPDATE sync_reactions
|
|
1075
|
+
SET status='leased', attempts=attempts+1,
|
|
1076
|
+
lease_owner=?, lease_expires_at_ms=?, completed_at_ms=NULL
|
|
1077
|
+
WHERE (partition, idempotency_key) IN (
|
|
1078
|
+
SELECT partition, idempotency_key
|
|
1079
|
+
FROM sync_reactions
|
|
1080
|
+
WHERE partition=? AND type IN (${typeParams})
|
|
1081
|
+
AND ((status='pending' AND available_at_ms<=?)
|
|
1082
|
+
OR (status='leased' AND lease_expires_at_ms<=?))
|
|
1083
|
+
ORDER BY CASE WHEN status='leased' THEN lease_expires_at_ms
|
|
1084
|
+
ELSE available_at_ms END,
|
|
1085
|
+
created_at_ms, idempotency_key
|
|
1086
|
+
LIMIT ?
|
|
1087
|
+
)
|
|
1088
|
+
RETURNING *`,
|
|
1089
|
+
)
|
|
1090
|
+
.bind(
|
|
1091
|
+
query.leaseOwner,
|
|
1092
|
+
Math.min(Number.MAX_SAFE_INTEGER, query.nowMs + query.leaseDurationMs),
|
|
1093
|
+
partition,
|
|
1094
|
+
...query.types,
|
|
1095
|
+
query.nowMs,
|
|
1096
|
+
query.nowMs,
|
|
1097
|
+
query.limit,
|
|
1098
|
+
)
|
|
1099
|
+
.all<D1ReactionRecord>();
|
|
1100
|
+
return results
|
|
1101
|
+
.map(toStoredReaction)
|
|
1102
|
+
.sort(
|
|
1103
|
+
(a, b) =>
|
|
1104
|
+
a.createdAtMs - b.createdAtMs ||
|
|
1105
|
+
a.idempotencyKey.localeCompare(b.idempotencyKey),
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async completeReaction(
|
|
1110
|
+
partition: string,
|
|
1111
|
+
idempotencyKey: string,
|
|
1112
|
+
leaseOwner: string,
|
|
1113
|
+
completedAtMs: number,
|
|
1114
|
+
): Promise<boolean> {
|
|
1115
|
+
const record = await this.#db
|
|
1116
|
+
.prepare(
|
|
1117
|
+
`UPDATE sync_reactions
|
|
1118
|
+
SET status='completed', completed_at_ms=?,
|
|
1119
|
+
lease_owner=NULL, lease_expires_at_ms=NULL
|
|
1120
|
+
WHERE partition=? AND idempotency_key=?
|
|
1121
|
+
AND status='leased' AND lease_owner=?
|
|
1122
|
+
RETURNING idempotency_key`,
|
|
1123
|
+
)
|
|
1124
|
+
.bind(completedAtMs, partition, idempotencyKey, leaseOwner)
|
|
1125
|
+
.first<{ idempotency_key: string }>();
|
|
1126
|
+
return record !== null;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
async extendReactionLease(
|
|
1130
|
+
partition: string,
|
|
1131
|
+
idempotencyKey: string,
|
|
1132
|
+
leaseOwner: string,
|
|
1133
|
+
leaseExpiresAtMs: number,
|
|
1134
|
+
): Promise<boolean> {
|
|
1135
|
+
const record = await this.#db
|
|
1136
|
+
.prepare(
|
|
1137
|
+
`UPDATE sync_reactions SET lease_expires_at_ms=?
|
|
1138
|
+
WHERE partition=? AND idempotency_key=?
|
|
1139
|
+
AND status='leased' AND lease_owner=?
|
|
1140
|
+
RETURNING idempotency_key`,
|
|
1141
|
+
)
|
|
1142
|
+
.bind(leaseExpiresAtMs, partition, idempotencyKey, leaseOwner)
|
|
1143
|
+
.first<{ idempotency_key: string }>();
|
|
1144
|
+
return record !== null;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
async failReaction(
|
|
1148
|
+
partition: string,
|
|
1149
|
+
idempotencyKey: string,
|
|
1150
|
+
update: ReactionFailureUpdate,
|
|
1151
|
+
): Promise<boolean> {
|
|
1152
|
+
const record = await this.#db
|
|
1153
|
+
.prepare(
|
|
1154
|
+
`UPDATE sync_reactions
|
|
1155
|
+
SET status=?, available_at_ms=?, last_failure=?,
|
|
1156
|
+
lease_owner=NULL, lease_expires_at_ms=NULL
|
|
1157
|
+
WHERE partition=? AND idempotency_key=?
|
|
1158
|
+
AND status='leased' AND lease_owner=?
|
|
1159
|
+
RETURNING idempotency_key`,
|
|
1160
|
+
)
|
|
1161
|
+
.bind(
|
|
1162
|
+
update.retryAtMs === undefined ? 'dead-letter' : 'pending',
|
|
1163
|
+
update.retryAtMs ?? update.failure.atMs,
|
|
1164
|
+
JSON.stringify(update.failure),
|
|
1165
|
+
partition,
|
|
1166
|
+
idempotencyKey,
|
|
1167
|
+
update.leaseOwner,
|
|
1168
|
+
)
|
|
1169
|
+
.first<{ idempotency_key: string }>();
|
|
1170
|
+
return record !== null;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
async retryReaction(
|
|
1174
|
+
partition: string,
|
|
1175
|
+
idempotencyKey: string,
|
|
1176
|
+
nowMs: number,
|
|
1177
|
+
): Promise<boolean> {
|
|
1178
|
+
const record = await this.#db
|
|
1179
|
+
.prepare(
|
|
1180
|
+
`UPDATE sync_reactions
|
|
1181
|
+
SET status='pending', attempts=0, available_at_ms=?,
|
|
1182
|
+
last_failure=NULL, lease_owner=NULL, lease_expires_at_ms=NULL,
|
|
1183
|
+
completed_at_ms=NULL
|
|
1184
|
+
WHERE partition=? AND idempotency_key=? AND status='dead-letter'
|
|
1185
|
+
RETURNING idempotency_key`,
|
|
1186
|
+
)
|
|
1187
|
+
.bind(nowMs, partition, idempotencyKey)
|
|
1188
|
+
.first<{ idempotency_key: string }>();
|
|
1189
|
+
return record !== null;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
async getReaction(
|
|
1193
|
+
partition: string,
|
|
1194
|
+
idempotencyKey: string,
|
|
1195
|
+
): Promise<StoredReaction | undefined> {
|
|
1196
|
+
const record = await this.#db
|
|
1197
|
+
.prepare(
|
|
1198
|
+
'SELECT * FROM sync_reactions WHERE partition=? AND idempotency_key=?',
|
|
1199
|
+
)
|
|
1200
|
+
.bind(partition, idempotencyKey)
|
|
1201
|
+
.first<D1ReactionRecord>();
|
|
1202
|
+
return record === null ? undefined : toStoredReaction(record);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
async listReactions(
|
|
1206
|
+
partition: string,
|
|
1207
|
+
query: ReactionListQuery,
|
|
1208
|
+
): Promise<StoredReaction[]> {
|
|
1209
|
+
const where = ['partition=?'];
|
|
1210
|
+
const params: (string | number)[] = [partition];
|
|
1211
|
+
if (query.statuses !== undefined && query.statuses.length > 0) {
|
|
1212
|
+
where.push(`status IN (${query.statuses.map(() => '?').join(',')})`);
|
|
1213
|
+
params.push(...query.statuses);
|
|
1214
|
+
}
|
|
1215
|
+
if (query.types !== undefined && query.types.length > 0) {
|
|
1216
|
+
where.push(`type IN (${query.types.map(() => '?').join(',')})`);
|
|
1217
|
+
params.push(...query.types);
|
|
1218
|
+
}
|
|
1219
|
+
if (params.length + 1 > D1_MAX_BIND_PARAMS) {
|
|
1220
|
+
throw new Error('D1 reaction list exceeds the bound-parameter limit');
|
|
1221
|
+
}
|
|
1222
|
+
params.push(query.limit);
|
|
1223
|
+
const { results } = await this.#db
|
|
1224
|
+
.prepare(
|
|
1225
|
+
`SELECT * FROM sync_reactions WHERE ${where.join(' AND ')}
|
|
1226
|
+
ORDER BY created_at_ms DESC, idempotency_key DESC LIMIT ?`,
|
|
1227
|
+
)
|
|
1228
|
+
.bind(...params)
|
|
1229
|
+
.all<D1ReactionRecord>();
|
|
1230
|
+
return results.map(toStoredReaction);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
async pruneReactions(
|
|
1234
|
+
partition: string,
|
|
1235
|
+
query: ReactionPruneQuery,
|
|
1236
|
+
): Promise<PrunedReactionCounts> {
|
|
1237
|
+
if (query.limit <= 0) return { completed: 0, deadLetter: 0 };
|
|
1238
|
+
const { results } = await this.#db
|
|
1239
|
+
.prepare(
|
|
1240
|
+
`DELETE FROM sync_reactions
|
|
1241
|
+
WHERE partition=? AND idempotency_key IN (
|
|
1242
|
+
SELECT idempotency_key FROM sync_reactions
|
|
1243
|
+
WHERE partition=?
|
|
1244
|
+
AND ((status='completed' AND completed_at_ms IS NOT NULL
|
|
1245
|
+
AND completed_at_ms<?)
|
|
1246
|
+
OR (status='dead-letter' AND available_at_ms<?))
|
|
1247
|
+
ORDER BY CASE WHEN status='completed' THEN completed_at_ms
|
|
1248
|
+
ELSE available_at_ms END,
|
|
1249
|
+
idempotency_key
|
|
1250
|
+
LIMIT ?
|
|
1251
|
+
)
|
|
1252
|
+
AND ((status='completed' AND completed_at_ms IS NOT NULL
|
|
1253
|
+
AND completed_at_ms<?)
|
|
1254
|
+
OR (status='dead-letter' AND available_at_ms<?))
|
|
1255
|
+
RETURNING status`,
|
|
1256
|
+
)
|
|
1257
|
+
.bind(
|
|
1258
|
+
partition,
|
|
1259
|
+
partition,
|
|
1260
|
+
query.completedBeforeMs,
|
|
1261
|
+
query.deadLetterBeforeMs,
|
|
1262
|
+
query.limit,
|
|
1263
|
+
query.completedBeforeMs,
|
|
1264
|
+
query.deadLetterBeforeMs,
|
|
1265
|
+
)
|
|
1266
|
+
.all<{ status: 'completed' | 'dead-letter' }>();
|
|
1267
|
+
return {
|
|
1268
|
+
completed: results.filter((row) => row.status === 'completed').length,
|
|
1269
|
+
deadLetter: results.filter((row) => row.status === 'dead-letter').length,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
|
|
921
1273
|
async readCommitWindow(
|
|
922
1274
|
partition: string,
|
|
923
1275
|
query: CommitWindowQuery,
|
|
@@ -1137,7 +1489,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1137
1489
|
return results.map((r) => r.blob_id);
|
|
1138
1490
|
}
|
|
1139
1491
|
|
|
1140
|
-
// -- admin/console read surface
|
|
1492
|
+
// -- admin/console read surface --------------------------------------------
|
|
1141
1493
|
|
|
1142
1494
|
async listClientRecords(partition: string): Promise<ClientRecord[]> {
|
|
1143
1495
|
const { results } = await this.#db
|