@syncular/server 0.15.47 → 0.16.1
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 +48 -5
- package/dist/admin.d.ts +1 -5
- package/dist/admin.js +3 -12
- package/dist/authoritative-query.d.ts +14 -6
- package/dist/authoritative-query.js +60 -82
- package/dist/blob-handlers.js +4 -1
- package/dist/context.d.ts +3 -1
- package/dist/context.js +4 -0
- package/dist/d1-storage.d.ts +8 -2
- package/dist/d1-storage.js +134 -26
- package/dist/errors.js +6 -0
- package/dist/events.d.ts +2 -1
- package/dist/frame-bytes.d.ts +2 -2
- package/dist/frame-bytes.js +33 -14
- package/dist/handler.js +58 -14
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/operations.d.ts +2 -0
- package/dist/operations.js +25 -5
- package/dist/postgres-storage.d.ts +9 -3
- package/dist/postgres-storage.js +110 -20
- package/dist/prune.d.ts +3 -1
- package/dist/prune.js +18 -14
- package/dist/pull.d.ts +1 -1
- package/dist/pull.js +74 -37
- package/dist/push.js +5 -5
- package/dist/realtime.d.ts +4 -1
- package/dist/realtime.js +33 -9
- package/dist/restore.d.ts +13 -0
- package/dist/restore.js +13 -0
- package/dist/s3-segment-store.js +10 -1
- package/dist/seed.js +36 -4
- package/dist/segment-download.js +5 -2
- package/dist/segment-store.d.ts +3 -0
- package/dist/segment-store.js +1 -0
- package/dist/sqlite-bun-driver.d.ts +2 -1
- package/dist/sqlite-bun-driver.js +5 -2
- package/dist/sqlite-bun.js +2 -2
- package/dist/sqlite-dialect.d.ts +1 -1
- package/dist/sqlite-dialect.js +7 -0
- package/dist/sqlite-image.d.ts +3 -3
- package/dist/sqlite-image.js +10 -6
- package/dist/sqlite-node.js +2 -2
- package/dist/sqlite-segment-store.js +14 -5
- package/dist/sqlite-storage.d.ts +8 -2
- package/dist/sqlite-storage.js +147 -36
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +3 -0
- package/dist/storage.d.ts +38 -10
- package/package.json +2 -2
- package/src/admin.ts +7 -15
- package/src/authoritative-query.ts +89 -94
- package/src/blob-handlers.ts +8 -1
- package/src/context.ts +16 -1
- package/src/d1-storage.ts +193 -29
- package/src/errors.ts +6 -0
- package/src/events.ts +2 -1
- package/src/frame-bytes.ts +40 -14
- package/src/handler.ts +102 -29
- package/src/index.ts +1 -0
- package/src/operations.ts +37 -4
- package/src/postgres-storage.ts +184 -38
- package/src/prune.ts +29 -15
- package/src/pull.ts +90 -41
- package/src/push.ts +5 -5
- package/src/realtime.ts +46 -7
- package/src/restore.ts +28 -0
- package/src/s3-segment-store.ts +10 -1
- package/src/seed.ts +46 -4
- package/src/segment-download.ts +11 -2
- package/src/segment-store.ts +4 -0
- package/src/sqlite-bun-driver.ts +6 -2
- package/src/sqlite-bun.ts +2 -2
- package/src/sqlite-dialect.ts +7 -0
- package/src/sqlite-image.ts +26 -15
- package/src/sqlite-node.ts +2 -2
- package/src/sqlite-segment-store.ts +18 -4
- package/src/sqlite-storage.ts +203 -39
- package/src/storage-errors.ts +10 -1
- package/src/storage.ts +57 -10
|
@@ -14,28 +14,16 @@ export interface BoundAuthoritativeQuery {
|
|
|
14
14
|
|
|
15
15
|
const PARTITION_BIND = Symbol('syncular.authoritative_partition');
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
);
|
|
17
|
+
/** Compiler-proven occurrences in the exact generated positional statement. */
|
|
18
|
+
export interface AuthoritativeRelationPlan {
|
|
19
|
+
readonly sql: string;
|
|
20
|
+
readonly relations: readonly {
|
|
21
|
+
readonly table: string;
|
|
22
|
+
readonly start: number;
|
|
23
|
+
readonly end: number;
|
|
24
|
+
readonly alias?: string;
|
|
25
|
+
}[];
|
|
26
|
+
}
|
|
39
27
|
|
|
40
28
|
function protectedSqlEnd(sql: string, index: number): number | undefined {
|
|
41
29
|
const char = sql[index];
|
|
@@ -64,66 +52,57 @@ function protectedSqlEnd(sql: string, index: number): number | undefined {
|
|
|
64
52
|
return undefined;
|
|
65
53
|
}
|
|
66
54
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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[],
|
|
55
|
+
/** Validate trusted generated metadata before registration or storage execution. */
|
|
56
|
+
export function validateAuthoritativeRelationPlan(
|
|
57
|
+
plan: AuthoritativeRelationPlan,
|
|
87
58
|
declaredTables: readonly string[],
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
59
|
+
): void {
|
|
60
|
+
if (
|
|
61
|
+
plan === undefined ||
|
|
62
|
+
typeof plan.sql !== 'string' ||
|
|
63
|
+
!Array.isArray(plan.relations)
|
|
64
|
+
) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
'registered query requires generated relation plans; regenerate queries',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const sql = plan.sql;
|
|
92
70
|
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
71
|
const found = new Set<string>();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
72
|
+
let previousEnd = 0;
|
|
73
|
+
for (const relation of plan.relations) {
|
|
74
|
+
if (
|
|
75
|
+
!Number.isSafeInteger(relation.start) ||
|
|
76
|
+
!Number.isSafeInteger(relation.end) ||
|
|
77
|
+
relation.start < previousEnd ||
|
|
78
|
+
relation.end <= relation.start ||
|
|
79
|
+
relation.end > sql.length
|
|
80
|
+
) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
'registered query relation boundaries do not match its SQL; regenerate queries',
|
|
83
|
+
);
|
|
106
84
|
}
|
|
107
|
-
|
|
108
|
-
|
|
85
|
+
previousEnd = relation.end;
|
|
86
|
+
if (!declared.has(relation.table)) {
|
|
87
|
+
throw new Error('registered query table metadata does not match its SQL');
|
|
109
88
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
89
|
+
const spelling = sql.slice(relation.start, relation.end);
|
|
90
|
+
const quote = spelling[0];
|
|
91
|
+
const name =
|
|
92
|
+
quote === '['
|
|
93
|
+
? spelling.slice(1, -1)
|
|
94
|
+
: quote === '"' || quote === '`'
|
|
95
|
+
? spelling
|
|
96
|
+
.slice(1, -1)
|
|
97
|
+
.split(quote + quote)
|
|
98
|
+
.join(quote)
|
|
99
|
+
: spelling;
|
|
100
|
+
if (name.toLowerCase() !== relation.table.toLowerCase()) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
'registered query relation name does not match its SQL; regenerate queries',
|
|
103
|
+
);
|
|
113
104
|
}
|
|
114
|
-
|
|
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);
|
|
105
|
+
found.add(relation.table);
|
|
127
106
|
}
|
|
128
107
|
if (
|
|
129
108
|
found.size !== declared.size ||
|
|
@@ -131,42 +110,58 @@ export function prepareAuthoritativeQuery(
|
|
|
131
110
|
) {
|
|
132
111
|
throw new Error('registered query table metadata does not match its SQL');
|
|
133
112
|
}
|
|
134
|
-
|
|
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
|
-
}
|
|
113
|
+
}
|
|
143
114
|
|
|
115
|
+
/** Bind every compiler-proven table occurrence to the authenticated partition. */
|
|
116
|
+
export function prepareAuthoritativeQuery(
|
|
117
|
+
plan: AuthoritativeRelationPlan,
|
|
118
|
+
params: readonly AuthoritativeQueryValue[],
|
|
119
|
+
declaredTables: readonly string[],
|
|
120
|
+
tables: ReadonlyMap<string, CompiledTable>,
|
|
121
|
+
): PreparedAuthoritativeQuery {
|
|
122
|
+
validateAuthoritativeRelationPlan(plan, declaredTables);
|
|
123
|
+
const sql = plan.sql;
|
|
124
|
+
const replacements = plan.relations.map((relation) => {
|
|
125
|
+
const table = tables.get(relation.table);
|
|
126
|
+
if (table === undefined)
|
|
127
|
+
throw new Error('registered query targets an unknown table');
|
|
128
|
+
if (!table.materialize)
|
|
129
|
+
throw new Error('registered query targets a non-materialized table');
|
|
130
|
+
return {
|
|
131
|
+
start: relation.start,
|
|
132
|
+
end: relation.end,
|
|
133
|
+
text: `(SELECT ${table.columns.map((column) => quoteIdent(column.name)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=?)${relation.alias === undefined ? ` AS ${quoteIdent(table.name)}` : ''}`,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
144
136
|
const bound: (AuthoritativeQueryValue | typeof PARTITION_BIND)[] = [];
|
|
145
137
|
let anonymousIndex = 0;
|
|
146
138
|
let rendered = '';
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
139
|
+
let nextRelation = 0;
|
|
140
|
+
for (let index = 0; index < sql.length; index += 1) {
|
|
141
|
+
const replacement = replacements[nextRelation];
|
|
142
|
+
if (replacement?.start === index) {
|
|
143
|
+
rendered += replacement.text;
|
|
150
144
|
bound.push(PARTITION_BIND);
|
|
151
|
-
index
|
|
145
|
+
index = replacement.end - 1;
|
|
146
|
+
nextRelation += 1;
|
|
152
147
|
continue;
|
|
153
148
|
}
|
|
154
|
-
const protectedEnd = protectedSqlEnd(
|
|
149
|
+
const protectedEnd = protectedSqlEnd(sql, index);
|
|
155
150
|
if (protectedEnd !== undefined) {
|
|
156
|
-
rendered +=
|
|
151
|
+
rendered += sql.slice(index, protectedEnd);
|
|
157
152
|
index = protectedEnd - 1;
|
|
158
153
|
continue;
|
|
159
154
|
}
|
|
160
|
-
const char =
|
|
155
|
+
const char = sql[index] as string;
|
|
161
156
|
if (char !== '?') {
|
|
162
157
|
rendered += char;
|
|
163
158
|
continue;
|
|
164
159
|
}
|
|
165
160
|
let end = index + 1;
|
|
166
|
-
while (end <
|
|
161
|
+
while (end < sql.length && /[0-9]/.test(sql[end] as string)) {
|
|
167
162
|
end += 1;
|
|
168
163
|
}
|
|
169
|
-
const numbered =
|
|
164
|
+
const numbered = sql.slice(index + 1, end);
|
|
170
165
|
const parameterIndex =
|
|
171
166
|
numbered.length > 0
|
|
172
167
|
? Number.parseInt(numbered, 10) - 1
|
package/src/blob-handlers.ts
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { blobIdFor, isBlobId } from './blob-store';
|
|
10
10
|
import type { SyncRequestContext } from './context';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
clockOf,
|
|
13
|
+
RESOLVER_OUTAGE,
|
|
14
|
+
touchAuthenticatedPartition,
|
|
15
|
+
} from './context';
|
|
12
16
|
import { SyncError, syncError } from './errors';
|
|
13
17
|
import { emitEvent } from './events';
|
|
14
18
|
import { compileSchema } from './schema';
|
|
@@ -72,6 +76,7 @@ export async function handleBlobUpload(
|
|
|
72
76
|
ctx: SyncRequestContext,
|
|
73
77
|
request: BlobUploadRequest,
|
|
74
78
|
): Promise<void> {
|
|
79
|
+
await touchAuthenticatedPartition(ctx);
|
|
75
80
|
const store = ctx.blobs;
|
|
76
81
|
if (store === undefined) {
|
|
77
82
|
throw syncError('blob.not_found', 'this server has no blob store (§5.9)');
|
|
@@ -125,6 +130,7 @@ export async function handleBlobDownload(
|
|
|
125
130
|
ctx: SyncRequestContext,
|
|
126
131
|
blobId: string,
|
|
127
132
|
): Promise<BlobDownloadResult> {
|
|
133
|
+
await touchAuthenticatedPartition(ctx);
|
|
128
134
|
const events = ctx.events;
|
|
129
135
|
if (events === undefined) return downloadBlob(ctx, blobId);
|
|
130
136
|
const clock = clockOf(ctx);
|
|
@@ -301,6 +307,7 @@ export async function handleBlobUploadGrant(
|
|
|
301
307
|
ctx: SyncRequestContext,
|
|
302
308
|
request: BlobUploadGrantRequest,
|
|
303
309
|
): Promise<BlobUploadGrantResult> {
|
|
310
|
+
await touchAuthenticatedPartition(ctx);
|
|
304
311
|
const store = ctx.blobs;
|
|
305
312
|
if (store === undefined) {
|
|
306
313
|
throw syncError('blob.not_found', 'this server has no blob store (§5.9)');
|
package/src/context.ts
CHANGED
|
@@ -18,7 +18,11 @@ import type {
|
|
|
18
18
|
SegmentUrlConfig,
|
|
19
19
|
} from './signed-url';
|
|
20
20
|
import type { SqliteImageBuilder } from './sqlite-image';
|
|
21
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
PartitionRegistryEntry,
|
|
23
|
+
ServerStorage,
|
|
24
|
+
StoredCommit,
|
|
25
|
+
} from './storage';
|
|
22
26
|
import type { CommitValidator, ValidatorRegistry } from './validate';
|
|
23
27
|
|
|
24
28
|
/** SSP2 body content type (§1.1). */
|
|
@@ -192,3 +196,14 @@ export function clockOf(ctx: SyncServerConfig): () => number {
|
|
|
192
196
|
export function limitsOf(ctx: SyncServerConfig): ServerLimits {
|
|
193
197
|
return { ...DEFAULT_LIMITS, ...ctx.limits };
|
|
194
198
|
}
|
|
199
|
+
|
|
200
|
+
/** Refresh the registry after host authentication and return log continuity. */
|
|
201
|
+
export function touchAuthenticatedPartition(
|
|
202
|
+
ctx: SyncRequestContext,
|
|
203
|
+
): Promise<PartitionRegistryEntry> {
|
|
204
|
+
return ctx.storage.touchPartition(
|
|
205
|
+
ctx.partition,
|
|
206
|
+
clockOf(ctx)(),
|
|
207
|
+
crypto.randomUUID(),
|
|
208
|
+
);
|
|
209
|
+
}
|
package/src/d1-storage.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { validateCommitPruneQuery } from './prune';
|
|
2
|
+
import { StorageQueryError } from './storage-errors';
|
|
3
|
+
import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
1
4
|
/**
|
|
2
5
|
* Cloudflare D1 server storage for Workers deployments.
|
|
3
6
|
*
|
|
@@ -95,6 +98,7 @@ import type {
|
|
|
95
98
|
IndexRowScanQuery,
|
|
96
99
|
NewCommit,
|
|
97
100
|
NewReaction,
|
|
101
|
+
PartitionRegistryEntry,
|
|
98
102
|
PrunedReactionCounts,
|
|
99
103
|
ReactionClaimQuery,
|
|
100
104
|
ReactionFailure,
|
|
@@ -752,6 +756,14 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
752
756
|
for (const statement of sqliteDdlStatements()) {
|
|
753
757
|
await this.#db.exec(`${statement.replace(/\s+/g, ' ')};`);
|
|
754
758
|
}
|
|
759
|
+
const { results } = await this.#db
|
|
760
|
+
.prepare('PRAGMA table_info("sync_clients")')
|
|
761
|
+
.all<{ name: string }>();
|
|
762
|
+
if (!results.some((column) => column.name === 'wire_version')) {
|
|
763
|
+
await this.#db.exec(
|
|
764
|
+
'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1;',
|
|
765
|
+
);
|
|
766
|
+
}
|
|
755
767
|
}
|
|
756
768
|
|
|
757
769
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
@@ -859,6 +871,95 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
859
871
|
this.#schemaVersion = schema.version;
|
|
860
872
|
}
|
|
861
873
|
|
|
874
|
+
async touchPartition(
|
|
875
|
+
partition: string,
|
|
876
|
+
authenticatedAtMs: number,
|
|
877
|
+
initialLogEpoch: string,
|
|
878
|
+
): Promise<PartitionRegistryEntry> {
|
|
879
|
+
if (initialLogEpoch.length === 0) {
|
|
880
|
+
throw new Error('initial log epoch must be non-empty');
|
|
881
|
+
}
|
|
882
|
+
await this.#db
|
|
883
|
+
.prepare(
|
|
884
|
+
`INSERT INTO sync_partition_registry(
|
|
885
|
+
partition, log_epoch, last_authenticated_at_ms
|
|
886
|
+
) VALUES (?,?,?)
|
|
887
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
888
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
|
|
889
|
+
)
|
|
890
|
+
.bind(partition, initialLogEpoch, authenticatedAtMs)
|
|
891
|
+
.run();
|
|
892
|
+
const row = await this.#db
|
|
893
|
+
.prepare(
|
|
894
|
+
`SELECT log_epoch, epoch_required, last_authenticated_at_ms
|
|
895
|
+
FROM sync_partition_registry WHERE partition=?`,
|
|
896
|
+
)
|
|
897
|
+
.bind(partition)
|
|
898
|
+
.first<{
|
|
899
|
+
log_epoch: string;
|
|
900
|
+
epoch_required: number;
|
|
901
|
+
last_authenticated_at_ms: number;
|
|
902
|
+
}>();
|
|
903
|
+
if (row === null)
|
|
904
|
+
throw new Error('partition registry write did not persist');
|
|
905
|
+
return {
|
|
906
|
+
partition,
|
|
907
|
+
logEpoch: row.log_epoch,
|
|
908
|
+
epochRequired: row.epoch_required === 1,
|
|
909
|
+
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async rotatePartitionLogEpoch(
|
|
914
|
+
partition: string,
|
|
915
|
+
logEpoch: string,
|
|
916
|
+
authenticatedAtMs: number,
|
|
917
|
+
): Promise<PartitionRegistryEntry> {
|
|
918
|
+
if (logEpoch.length === 0) throw new Error('log epoch must be non-empty');
|
|
919
|
+
await this.#db.batch([
|
|
920
|
+
this.#db
|
|
921
|
+
.prepare(
|
|
922
|
+
`INSERT INTO sync_partition_registry(
|
|
923
|
+
partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
924
|
+
) VALUES (?,?,1,?)
|
|
925
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
926
|
+
log_epoch=excluded.log_epoch,
|
|
927
|
+
epoch_required=1,
|
|
928
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
|
|
929
|
+
)
|
|
930
|
+
.bind(partition, logEpoch, authenticatedAtMs),
|
|
931
|
+
this.#db
|
|
932
|
+
.prepare('DELETE FROM sync_clients WHERE partition=?')
|
|
933
|
+
.bind(partition),
|
|
934
|
+
]);
|
|
935
|
+
return {
|
|
936
|
+
partition,
|
|
937
|
+
logEpoch,
|
|
938
|
+
epochRequired: true,
|
|
939
|
+
lastAuthenticatedAtMs: authenticatedAtMs,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async listPartitionRegistry(): Promise<PartitionRegistryEntry[]> {
|
|
944
|
+
const { results } = await this.#db
|
|
945
|
+
.prepare(
|
|
946
|
+
`SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
947
|
+
FROM sync_partition_registry ORDER BY partition`,
|
|
948
|
+
)
|
|
949
|
+
.all<{
|
|
950
|
+
partition: string;
|
|
951
|
+
log_epoch: string;
|
|
952
|
+
epoch_required: number;
|
|
953
|
+
last_authenticated_at_ms: number;
|
|
954
|
+
}>();
|
|
955
|
+
return results.map((row) => ({
|
|
956
|
+
partition: row.partition,
|
|
957
|
+
logEpoch: row.log_epoch,
|
|
958
|
+
epochRequired: row.epoch_required === 1,
|
|
959
|
+
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
960
|
+
}));
|
|
961
|
+
}
|
|
962
|
+
|
|
862
963
|
/** Keyset-paged migration rewrite (see the sqlite storage's counterpart). */
|
|
863
964
|
async #rewriteRows(
|
|
864
965
|
table: CompiledTable,
|
|
@@ -929,7 +1030,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
929
1030
|
}
|
|
930
1031
|
const prepared = bindAuthoritativePartition(
|
|
931
1032
|
prepareAuthoritativeQuery(
|
|
932
|
-
query.
|
|
1033
|
+
query.plan,
|
|
933
1034
|
query.params,
|
|
934
1035
|
query.tables,
|
|
935
1036
|
this.#tables,
|
|
@@ -973,6 +1074,16 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
973
1074
|
};
|
|
974
1075
|
}
|
|
975
1076
|
|
|
1077
|
+
async getPartitionLogEpoch(partition: string): Promise<string | undefined> {
|
|
1078
|
+
const row = await this.#db
|
|
1079
|
+
.prepare(
|
|
1080
|
+
'SELECT log_epoch FROM sync_partition_registry WHERE partition=?',
|
|
1081
|
+
)
|
|
1082
|
+
.bind(partition)
|
|
1083
|
+
.first<{ log_epoch: string }>();
|
|
1084
|
+
return row?.log_epoch;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
976
1087
|
async getHorizonSeq(partition: string): Promise<number> {
|
|
977
1088
|
const row = await this.#db
|
|
978
1089
|
.prepare('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
|
|
@@ -984,33 +1095,76 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
984
1095
|
async setHorizonSeq(partition: string, seq: number): Promise<void> {
|
|
985
1096
|
await this.#db
|
|
986
1097
|
.prepare(
|
|
987
|
-
'INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?) ON CONFLICT(partition) DO UPDATE SET horizon_seq=excluded.horizon_seq',
|
|
1098
|
+
'INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?) ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)',
|
|
988
1099
|
)
|
|
989
1100
|
.bind(partition, seq)
|
|
990
1101
|
.run();
|
|
991
1102
|
}
|
|
992
1103
|
|
|
993
|
-
async pruneCommitsThrough(
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1104
|
+
async pruneCommitsThrough(
|
|
1105
|
+
partition: string,
|
|
1106
|
+
query: CommitPruneQuery,
|
|
1107
|
+
): Promise<CommitPruneResult> {
|
|
1108
|
+
validateCommitPruneQuery(query);
|
|
1109
|
+
if (!this.#pushApplySerialized)
|
|
1110
|
+
throw new Error(
|
|
1111
|
+
'D1 pruning requires externally serialized partition writes',
|
|
1112
|
+
);
|
|
1113
|
+
const epochGuard =
|
|
1114
|
+
'EXISTS (SELECT 1 FROM sync_partition_registry WHERE partition=? AND log_epoch=?)';
|
|
1115
|
+
const horizon =
|
|
1116
|
+
'(SELECT horizon_seq FROM sync_partitions WHERE partition=?)';
|
|
1117
|
+
const results = await this.#db.batch([
|
|
1001
1118
|
this.#db
|
|
1002
|
-
.prepare(
|
|
1003
|
-
|
|
1119
|
+
.prepare(
|
|
1120
|
+
`SELECT log_epoch, coalesce(${horizon},0) AS previous_horizon_seq FROM sync_partition_registry WHERE partition=?`,
|
|
1121
|
+
)
|
|
1122
|
+
.bind(partition, partition),
|
|
1004
1123
|
this.#db
|
|
1005
|
-
.prepare(
|
|
1006
|
-
|
|
1124
|
+
.prepare(`INSERT INTO sync_partitions(partition,horizon_seq) SELECT ?,? WHERE ${epochGuard}
|
|
1125
|
+
ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)`)
|
|
1126
|
+
.bind(partition, query.throughSeq, partition, query.logEpoch),
|
|
1007
1127
|
this.#db
|
|
1008
1128
|
.prepare(
|
|
1009
|
-
|
|
1129
|
+
`SELECT horizon_seq, (SELECT count(*) FROM sync_commits WHERE partition=? AND commit_seq<=${horizon}) AS removed_commits FROM sync_partitions WHERE partition=?`,
|
|
1010
1130
|
)
|
|
1011
|
-
.bind(partition,
|
|
1131
|
+
.bind(partition, partition, partition),
|
|
1132
|
+
...['sync_commits', 'sync_changes', 'sync_change_scopes'].map((table) =>
|
|
1133
|
+
this.#db
|
|
1134
|
+
.prepare(
|
|
1135
|
+
`DELETE FROM ${table} WHERE partition=? AND commit_seq<=${horizon} AND ${epochGuard}`,
|
|
1136
|
+
)
|
|
1137
|
+
.bind(partition, partition, partition, query.logEpoch),
|
|
1138
|
+
),
|
|
1012
1139
|
]);
|
|
1013
|
-
|
|
1140
|
+
const [before, after] = [results[0], results[2]].map((result) => {
|
|
1141
|
+
if (
|
|
1142
|
+
typeof result !== 'object' ||
|
|
1143
|
+
result === null ||
|
|
1144
|
+
!('results' in result) ||
|
|
1145
|
+
!Array.isArray(result.results)
|
|
1146
|
+
) {
|
|
1147
|
+
throw new Error('D1 pruning returned an invalid batch result');
|
|
1148
|
+
}
|
|
1149
|
+
const row: unknown = result.results[0];
|
|
1150
|
+
return typeof row === 'object' && row !== null
|
|
1151
|
+
? (row as Readonly<Record<string, unknown>>)
|
|
1152
|
+
: undefined;
|
|
1153
|
+
});
|
|
1154
|
+
if (before?.log_epoch !== query.logEpoch)
|
|
1155
|
+
throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
|
|
1156
|
+
if (
|
|
1157
|
+
typeof before.previous_horizon_seq !== 'number' ||
|
|
1158
|
+
typeof after?.horizon_seq !== 'number' ||
|
|
1159
|
+
typeof after.removed_commits !== 'number'
|
|
1160
|
+
) {
|
|
1161
|
+
throw new Error('D1 pruning returned invalid horizon metadata');
|
|
1162
|
+
}
|
|
1163
|
+
return {
|
|
1164
|
+
previousHorizonSeq: before.previous_horizon_seq,
|
|
1165
|
+
horizonSeq: after.horizon_seq,
|
|
1166
|
+
removedCommits: after.removed_commits,
|
|
1167
|
+
};
|
|
1014
1168
|
}
|
|
1015
1169
|
|
|
1016
1170
|
async getCommitSeqBefore(
|
|
@@ -1390,12 +1544,13 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1390
1544
|
): Promise<ClientRecord | undefined> {
|
|
1391
1545
|
const record = await this.#db
|
|
1392
1546
|
.prepare(
|
|
1393
|
-
'SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
|
|
1547
|
+
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
|
|
1394
1548
|
)
|
|
1395
1549
|
.bind(partition, clientId)
|
|
1396
1550
|
.first<{
|
|
1397
1551
|
client_id: string;
|
|
1398
1552
|
actor_id: string;
|
|
1553
|
+
wire_version: number;
|
|
1399
1554
|
cursor: number;
|
|
1400
1555
|
subscriptions: string;
|
|
1401
1556
|
updated_at_ms: number;
|
|
@@ -1404,6 +1559,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1404
1559
|
return {
|
|
1405
1560
|
clientId: record.client_id,
|
|
1406
1561
|
actorId: record.actor_id,
|
|
1562
|
+
wireVersion: record.wire_version,
|
|
1407
1563
|
cursor: record.cursor,
|
|
1408
1564
|
updatedAtMs: record.updated_at_ms,
|
|
1409
1565
|
subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
|
|
@@ -1416,12 +1572,13 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1416
1572
|
): Promise<void> {
|
|
1417
1573
|
await this.#db
|
|
1418
1574
|
.prepare(
|
|
1419
|
-
'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms) VALUES (
|
|
1575
|
+
'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)',
|
|
1420
1576
|
)
|
|
1421
1577
|
.bind(
|
|
1422
1578
|
partition,
|
|
1423
1579
|
record.clientId,
|
|
1424
1580
|
record.actorId,
|
|
1581
|
+
record.wireVersion,
|
|
1425
1582
|
record.cursor,
|
|
1426
1583
|
JSON.stringify(record.subscriptions),
|
|
1427
1584
|
record.updatedAtMs,
|
|
@@ -1429,6 +1586,19 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1429
1586
|
.run();
|
|
1430
1587
|
}
|
|
1431
1588
|
|
|
1589
|
+
async getActiveClientCursorFloor(
|
|
1590
|
+
partition: string,
|
|
1591
|
+
cutoffMs: number,
|
|
1592
|
+
): Promise<number | null> {
|
|
1593
|
+
const row = await this.#db
|
|
1594
|
+
.prepare(
|
|
1595
|
+
'SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?',
|
|
1596
|
+
)
|
|
1597
|
+
.bind(partition, cutoffMs)
|
|
1598
|
+
.first<{ cursor: number | null }>();
|
|
1599
|
+
return row!.cursor;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1432
1602
|
async listClientCursors(partition: string): Promise<ClientCursorInfo[]> {
|
|
1433
1603
|
const { results } = await this.#db
|
|
1434
1604
|
.prepare(
|
|
@@ -1494,12 +1664,13 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1494
1664
|
async listClientRecords(partition: string): Promise<ClientRecord[]> {
|
|
1495
1665
|
const { results } = await this.#db
|
|
1496
1666
|
.prepare(
|
|
1497
|
-
'SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
|
|
1667
|
+
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
|
|
1498
1668
|
)
|
|
1499
1669
|
.bind(partition)
|
|
1500
1670
|
.all<{
|
|
1501
1671
|
client_id: string;
|
|
1502
1672
|
actor_id: string;
|
|
1673
|
+
wire_version: number;
|
|
1503
1674
|
cursor: number;
|
|
1504
1675
|
subscriptions: string;
|
|
1505
1676
|
updated_at_ms: number;
|
|
@@ -1507,6 +1678,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1507
1678
|
return results.map((record) => ({
|
|
1508
1679
|
clientId: record.client_id,
|
|
1509
1680
|
actorId: record.actor_id,
|
|
1681
|
+
wireVersion: record.wire_version,
|
|
1510
1682
|
cursor: record.cursor,
|
|
1511
1683
|
updatedAtMs: record.updated_at_ms,
|
|
1512
1684
|
subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
|
|
@@ -1628,14 +1800,6 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1628
1800
|
}
|
|
1629
1801
|
|
|
1630
1802
|
async listPartitions(): Promise<string[]> {
|
|
1631
|
-
|
|
1632
|
-
// first pull — a partition with only one of the two still shows up.
|
|
1633
|
-
const { results } = await this.#db
|
|
1634
|
-
.prepare(
|
|
1635
|
-
`SELECT partition FROM sync_partitions
|
|
1636
|
-
UNION SELECT partition FROM sync_clients ORDER BY partition`,
|
|
1637
|
-
)
|
|
1638
|
-
.all<{ partition: string }>();
|
|
1639
|
-
return results.map((r) => r.partition);
|
|
1803
|
+
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
1640
1804
|
}
|
|
1641
1805
|
}
|
package/src/errors.ts
CHANGED
|
@@ -199,6 +199,12 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
|
|
|
199
199
|
recommendedAction: 'upgradeClient',
|
|
200
200
|
httpStatus: 400,
|
|
201
201
|
},
|
|
202
|
+
'sync.client_wire_unsupported': {
|
|
203
|
+
category: 'schema-mismatch',
|
|
204
|
+
retryable: false,
|
|
205
|
+
recommendedAction: 'upgradeClient',
|
|
206
|
+
httpStatus: 400,
|
|
207
|
+
},
|
|
202
208
|
'sync.websocket_connection_limit': {
|
|
203
209
|
category: 'rate-limited',
|
|
204
210
|
retryable: true,
|
package/src/events.ts
CHANGED
|
@@ -29,10 +29,11 @@ export interface RequestHandledEvent {
|
|
|
29
29
|
/**
|
|
30
30
|
* `ok` — response streamed to END;
|
|
31
31
|
* `schema_floor` — §2.4 required-schema answer;
|
|
32
|
+
* `reset` — §2.1 log-epoch reset answer;
|
|
32
33
|
* `rejected` — request validation failed before any bytes (§1.7);
|
|
33
34
|
* `error` — in-band ERROR frame (§1.6) or a thrown host failure.
|
|
34
35
|
*/
|
|
35
|
-
readonly outcome: 'ok' | 'schema_floor' | 'rejected' | 'error';
|
|
36
|
+
readonly outcome: 'ok' | 'schema_floor' | 'reset' | 'rejected' | 'error';
|
|
36
37
|
/** §10.2 code for `rejected`/`error`; `"internal"` for non-SyncErrors. */
|
|
37
38
|
readonly errorCode?: string;
|
|
38
39
|
readonly pushCommits: number;
|