@syncular/server 0.32.1 → 0.34.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/blob-handlers.js +3 -3
- package/dist/bucket-blob-store.d.ts +77 -0
- package/dist/bucket-blob-store.js +266 -0
- package/dist/bucket-segment-store.d.ts +77 -0
- package/dist/bucket-segment-store.js +445 -0
- package/dist/context.d.ts +7 -0
- package/dist/d1-storage.js +2 -2
- package/dist/handler.js +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/object-bucket.d.ts +78 -0
- package/dist/object-bucket.js +18 -0
- package/dist/operations-realtime.js +3 -0
- package/dist/operations.d.ts +20 -3
- package/dist/operations.js +23 -3
- package/dist/realtime.d.ts +11 -0
- package/dist/realtime.js +25 -58
- package/dist/relational-rows.d.ts +6 -3
- package/dist/relational-rows.js +8 -9
- package/dist/s3-blob-store.d.ts +9 -64
- package/dist/s3-blob-store.js +25 -359
- package/dist/s3-object-bucket.d.ts +39 -0
- package/dist/s3-object-bucket.js +164 -0
- package/dist/s3-segment-store.d.ts +9 -61
- package/dist/s3-segment-store.js +25 -503
- package/dist/sqlite-storage.js +3 -8
- package/package.json +2 -2
- package/src/blob-handlers.ts +4 -4
- package/src/bucket-blob-store.ts +351 -0
- package/src/bucket-segment-store.ts +582 -0
- package/src/context.ts +10 -0
- package/src/d1-storage.ts +3 -3
- package/src/handler.ts +11 -0
- package/src/index.ts +4 -0
- package/src/object-bucket.ts +83 -0
- package/src/operations-realtime.ts +7 -0
- package/src/operations.ts +52 -5
- package/src/realtime.ts +29 -58
- package/src/relational-rows.ts +8 -11
- package/src/s3-blob-store.ts +25 -466
- package/src/s3-object-bucket.ts +233 -0
- package/src/s3-segment-store.ts +25 -646
- package/src/sqlite-storage.ts +3 -10
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The object-bucket seam: the handful of object-store operations the durable
|
|
3
|
+
* segment store (§5.1) and blob store (§5.9.2) actually perform, expressed
|
|
4
|
+
* without an HTTP transport.
|
|
5
|
+
*
|
|
6
|
+
* Two adapters implement it — `S3ObjectBucket` (`s3-object-bucket.ts`,
|
|
7
|
+
* hand-rolled SigV4 over `fetch`) and the R2 binding adapter in
|
|
8
|
+
* `@syncular/server-workers` (`r2-stores.ts`, the Worker's `env.BUCKET`) — so
|
|
9
|
+
* one implementation of the store logic (`bucket-segment-store.ts`,
|
|
10
|
+
* `bucket-blob-store.ts`) runs over both. The key layouts are the same on
|
|
11
|
+
* either transport, so a bucket can be served through the S3 API for one host
|
|
12
|
+
* and the binding for another.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is structural: an adapter may be any object with these
|
|
15
|
+
* methods, and Cloudflare's `R2Bucket` is assignable to the declaration in
|
|
16
|
+
* `r2-stores.ts` without a dependency on `@cloudflare/workers-types`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** One stored object, readable once. Mirrors `R2ObjectBody`. */
|
|
20
|
+
export interface BucketObject {
|
|
21
|
+
/** The body, streamed; `null` only if the backend served none. */
|
|
22
|
+
readonly body: ReadableStream<Uint8Array<ArrayBuffer>> | null;
|
|
23
|
+
/** Backend ETag of the stored object (opaque, used as a CAS token). */
|
|
24
|
+
readonly etag: string | undefined;
|
|
25
|
+
/** Stored size in bytes, when the backend reports one. */
|
|
26
|
+
readonly size: number | undefined;
|
|
27
|
+
/** User metadata: the `x-amz-meta-*` headers S3-side, `customMetadata` on R2. */
|
|
28
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
29
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
30
|
+
text(): Promise<string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One stored object's description without its body. Mirrors `R2Object`. */
|
|
34
|
+
export interface BucketObjectHead {
|
|
35
|
+
readonly etag: string | undefined;
|
|
36
|
+
readonly size: number | undefined;
|
|
37
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BucketWriteOptions {
|
|
41
|
+
readonly body: Uint8Array;
|
|
42
|
+
readonly contentType: string;
|
|
43
|
+
/** User metadata; values must be ASCII (both backends require it). */
|
|
44
|
+
readonly metadata?: Readonly<Record<string, string>> | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Create-only write (`If-None-Match: *`, R2 `etagDoesNotMatch: '*'`). The
|
|
47
|
+
* content-addressed bytes object is written this way so a second publisher
|
|
48
|
+
* of identical bytes is a no-op and a hash collision cannot overwrite.
|
|
49
|
+
*/
|
|
50
|
+
readonly createOnly?: boolean | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Compare-and-swap write (`If-Match`, R2 `etagMatches`): the write lands
|
|
53
|
+
* only if the stored ETag still equals this. The read-modify-write of the
|
|
54
|
+
* segment record and of the stats accumulator depend on it.
|
|
55
|
+
*/
|
|
56
|
+
readonly ifMatch?: string | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One page of keys under a prefix. `cursor` is set when more remain. */
|
|
60
|
+
export interface BucketListPage {
|
|
61
|
+
readonly keys: readonly string[];
|
|
62
|
+
readonly cursor: string | undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ObjectBucket {
|
|
66
|
+
/** The object plus its bytes, or `undefined` when the key is absent. */
|
|
67
|
+
get(key: string): Promise<BucketObject | undefined>;
|
|
68
|
+
head(key: string): Promise<BucketObjectHead | undefined>;
|
|
69
|
+
/**
|
|
70
|
+
* Write one object. Returns `false` when the `createOnly` / `ifMatch`
|
|
71
|
+
* precondition failed, so the caller can re-read and retry; a backend that
|
|
72
|
+
* ignores conditional writes would instead silently lose a concurrent
|
|
73
|
+
* update, which is why the return value is not optional.
|
|
74
|
+
*/
|
|
75
|
+
put(key: string, options: BucketWriteOptions): Promise<boolean>;
|
|
76
|
+
/** Delete one object. Deleting an absent key succeeds (idempotent). */
|
|
77
|
+
delete(key: string): Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* List keys under a prefix, one page at a time. The blob orphan sweep
|
|
80
|
+
* (§5.9.2) is the only caller: everything on the hot path is a GET/HEAD.
|
|
81
|
+
*/
|
|
82
|
+
list(prefix: string, cursor?: string): Promise<BucketListPage>;
|
|
83
|
+
}
|
|
@@ -271,5 +271,12 @@ export function composeRealtimeNotifiers(
|
|
|
271
271
|
notifiers.map((notifier) => notifier.notifyCommit(partition, commit)),
|
|
272
272
|
);
|
|
273
273
|
},
|
|
274
|
+
notifySubscriptions: async (partition, clientId) => {
|
|
275
|
+
await Promise.all(
|
|
276
|
+
notifiers.map((notifier) =>
|
|
277
|
+
notifier.notifySubscriptions?.(partition, clientId),
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
},
|
|
274
281
|
};
|
|
275
282
|
}
|
package/src/operations.ts
CHANGED
|
@@ -8,11 +8,12 @@ import {
|
|
|
8
8
|
encodeRemoteOperationResponse,
|
|
9
9
|
encodeSparseRow,
|
|
10
10
|
type PushOperation,
|
|
11
|
+
type RemoteOperationRequest,
|
|
11
12
|
type RemoteOperationResponse,
|
|
12
13
|
type RowValue,
|
|
13
14
|
type ScopeMap,
|
|
14
15
|
} from '@syncular/core';
|
|
15
|
-
import type { SyncRequestContext } from './context';
|
|
16
|
+
import type { ResolveScopes, SyncRequestContext } from './context';
|
|
16
17
|
import {
|
|
17
18
|
REMOTE_COMMAND_CLIENT_ID_PREFIX,
|
|
18
19
|
RESOLVER_OUTAGE,
|
|
@@ -33,7 +34,11 @@ import type {
|
|
|
33
34
|
} from './storage';
|
|
34
35
|
import type { StorageTransaction } from './storage';
|
|
35
36
|
import { transactionQuery } from './storage-query';
|
|
36
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
toValidateRow,
|
|
39
|
+
ValidationRejection,
|
|
40
|
+
type ValidateRow,
|
|
41
|
+
} from './validate';
|
|
37
42
|
|
|
38
43
|
export interface RemoteQueryDependency {
|
|
39
44
|
readonly table: string;
|
|
@@ -683,10 +688,41 @@ export class RemoteOperationRegistry {
|
|
|
683
688
|
}
|
|
684
689
|
}
|
|
685
690
|
|
|
691
|
+
/**
|
|
692
|
+
* The registry and identity one remote operation request runs with
|
|
693
|
+
* (REMOTE.md §5.1). `actorId` is the commit's actor and defaults to the
|
|
694
|
+
* authenticated actor; `resolveScopes` replaces the configured resolver for
|
|
695
|
+
* this request.
|
|
696
|
+
*/
|
|
697
|
+
export interface RemoteOperationBinding {
|
|
698
|
+
readonly registry: RemoteOperationRegistry;
|
|
699
|
+
readonly actorId?: string;
|
|
700
|
+
readonly resolveScopes?: ResolveScopes;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Chooses the binding of one decoded request (REMOTE.md §5.1). `ctx` carries
|
|
705
|
+
* the authenticated actor; the resolver runs after the client-id binding
|
|
706
|
+
* check and may throw a `SyncError` or a `ValidationRejection`.
|
|
707
|
+
*/
|
|
708
|
+
export type RemoteOperationResolver = (
|
|
709
|
+
request: RemoteOperationRequest,
|
|
710
|
+
ctx: SyncRequestContext,
|
|
711
|
+
) => RemoteOperationBinding | Promise<RemoteOperationBinding>;
|
|
712
|
+
|
|
686
713
|
function encodeRemoteOperationError(
|
|
687
714
|
error: unknown,
|
|
688
715
|
fallbackCode: 'operation.invalid_request' | 'operation.execution_failed',
|
|
689
716
|
): Uint8Array {
|
|
717
|
+
if (error instanceof ValidationRejection) {
|
|
718
|
+
return encodeRemoteOperationResponse({
|
|
719
|
+
revision: 1,
|
|
720
|
+
kind: 'error',
|
|
721
|
+
code: error.code,
|
|
722
|
+
message: error.message,
|
|
723
|
+
retryable: false,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
690
726
|
const sync =
|
|
691
727
|
error instanceof SyncError
|
|
692
728
|
? error
|
|
@@ -707,8 +743,8 @@ function encodeRemoteOperationError(
|
|
|
707
743
|
|
|
708
744
|
export async function handleRemoteOperation(
|
|
709
745
|
bytes: Uint8Array,
|
|
710
|
-
|
|
711
|
-
|
|
746
|
+
authenticated: SyncRequestContext,
|
|
747
|
+
operations: RemoteOperationRegistry | RemoteOperationResolver,
|
|
712
748
|
): Promise<Uint8Array> {
|
|
713
749
|
let request;
|
|
714
750
|
try {
|
|
@@ -728,6 +764,7 @@ export async function handleRemoteOperation(
|
|
|
728
764
|
} catch (error) {
|
|
729
765
|
return encodeRemoteOperationError(error, 'operation.invalid_request');
|
|
730
766
|
}
|
|
767
|
+
let ctx = authenticated;
|
|
731
768
|
try {
|
|
732
769
|
await touchAuthenticatedPartition(ctx);
|
|
733
770
|
if (request.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
@@ -752,6 +789,16 @@ export async function handleRemoteOperation(
|
|
|
752
789
|
) {
|
|
753
790
|
throw syncError('operation.invalid_request');
|
|
754
791
|
}
|
|
792
|
+
let registry = operations;
|
|
793
|
+
if (typeof registry === 'function') {
|
|
794
|
+
const binding = await registry(request, ctx);
|
|
795
|
+
registry = binding.registry;
|
|
796
|
+
ctx = {
|
|
797
|
+
...ctx,
|
|
798
|
+
actorId: binding.actorId ?? ctx.actorId,
|
|
799
|
+
resolveScopes: binding.resolveScopes ?? ctx.resolveScopes,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
755
802
|
const operation = registry.get(request.operationId);
|
|
756
803
|
if (operation === undefined) {
|
|
757
804
|
throw syncError('operation.unknown');
|
|
@@ -772,7 +819,7 @@ export async function handleRemoteOperation(
|
|
|
772
819
|
),
|
|
773
820
|
);
|
|
774
821
|
} catch (error) {
|
|
775
|
-
if (!(error instanceof SyncError)) {
|
|
822
|
+
if (!(error instanceof SyncError || error instanceof ValidationRejection)) {
|
|
776
823
|
reportError(error, ctx.onError, 'operations');
|
|
777
824
|
}
|
|
778
825
|
return encodeRemoteOperationError(error, 'operation.execution_failed');
|
package/src/realtime.ts
CHANGED
|
@@ -692,6 +692,15 @@ export class RealtimeSession {
|
|
|
692
692
|
* deltas immediately; it also survives a newer round-end refresh that
|
|
693
693
|
* failed, because that round applied nothing.
|
|
694
694
|
*/
|
|
695
|
+
/**
|
|
696
|
+
* A pull outside this connection changed the client's subscriptions. A
|
|
697
|
+
* round in flight on the connection reloads them at its end anyway.
|
|
698
|
+
*/
|
|
699
|
+
async refreshAfterPull(): Promise<void> {
|
|
700
|
+
if (this.#activeRound !== undefined) return;
|
|
701
|
+
await this.refreshRegistrations(false);
|
|
702
|
+
}
|
|
703
|
+
|
|
695
704
|
async refreshRegistrations(failClosed: boolean): Promise<void> {
|
|
696
705
|
const generation = ++this.#registrationGeneration;
|
|
697
706
|
let registrations: Registration[];
|
|
@@ -1104,67 +1113,13 @@ export class RealtimeHub {
|
|
|
1104
1113
|
'socket sync rounds require a segment store on the realtime hub (§8.7)',
|
|
1105
1114
|
);
|
|
1106
1115
|
}
|
|
1116
|
+
// The whole config, so a new `SyncServerConfig` field reaches socket and
|
|
1117
|
+
// hub-served HTTP rounds without being listed here.
|
|
1107
1118
|
return {
|
|
1119
|
+
...this.#config,
|
|
1120
|
+
segments,
|
|
1108
1121
|
partition: identity.partition,
|
|
1109
1122
|
actorId: identity.actorId,
|
|
1110
|
-
schema: this.#config.schema,
|
|
1111
|
-
...(this.#config.schemaWindow !== undefined
|
|
1112
|
-
? { schemaWindow: this.#config.schemaWindow }
|
|
1113
|
-
: {}),
|
|
1114
|
-
storage: this.#config.storage,
|
|
1115
|
-
segments,
|
|
1116
|
-
resolveScopes: this.#config.resolveScopes,
|
|
1117
|
-
...(this.#config.blobs !== undefined
|
|
1118
|
-
? { blobs: this.#config.blobs }
|
|
1119
|
-
: {}),
|
|
1120
|
-
...(this.#config.maxBlobBytes !== undefined
|
|
1121
|
-
? { maxBlobBytes: this.#config.maxBlobBytes }
|
|
1122
|
-
: {}),
|
|
1123
|
-
...(this.#config.crdtMergers !== undefined
|
|
1124
|
-
? { crdtMergers: this.#config.crdtMergers }
|
|
1125
|
-
: {}),
|
|
1126
|
-
...(this.#config.validators !== undefined
|
|
1127
|
-
? { validators: this.#config.validators }
|
|
1128
|
-
: {}),
|
|
1129
|
-
...(this.#config.commitValidator !== undefined
|
|
1130
|
-
? { commitValidator: this.#config.commitValidator }
|
|
1131
|
-
: {}),
|
|
1132
|
-
...(this.#config.reactionPlanner !== undefined
|
|
1133
|
-
? { reactionPlanner: this.#config.reactionPlanner }
|
|
1134
|
-
: {}),
|
|
1135
|
-
...(this.#config.checkpoints !== undefined
|
|
1136
|
-
? { checkpoints: this.#config.checkpoints }
|
|
1137
|
-
: {}),
|
|
1138
|
-
...(this.#config.clock !== undefined
|
|
1139
|
-
? { clock: this.#config.clock }
|
|
1140
|
-
: {}),
|
|
1141
|
-
...(this.#config.limits !== undefined
|
|
1142
|
-
? { limits: this.#config.limits }
|
|
1143
|
-
: {}),
|
|
1144
|
-
...(this.#config.signedUrls !== undefined
|
|
1145
|
-
? { signedUrls: this.#config.signedUrls }
|
|
1146
|
-
: {}),
|
|
1147
|
-
...(this.#config.blobSignedUrls !== undefined
|
|
1148
|
-
? { blobSignedUrls: this.#config.blobSignedUrls }
|
|
1149
|
-
: {}),
|
|
1150
|
-
...(this.#config.blobUploadUrls !== undefined
|
|
1151
|
-
? { blobUploadUrls: this.#config.blobUploadUrls }
|
|
1152
|
-
: {}),
|
|
1153
|
-
...(this.#config.sqliteImageBuilder !== undefined
|
|
1154
|
-
? { sqliteImageBuilder: this.#config.sqliteImageBuilder }
|
|
1155
|
-
: {}),
|
|
1156
|
-
...(this.#config.leases !== undefined
|
|
1157
|
-
? { leases: this.#config.leases }
|
|
1158
|
-
: {}),
|
|
1159
|
-
...(this.#config.events !== undefined
|
|
1160
|
-
? { events: this.#config.events }
|
|
1161
|
-
: {}),
|
|
1162
|
-
...(this.#config.onError !== undefined
|
|
1163
|
-
? { onError: this.#config.onError }
|
|
1164
|
-
: {}),
|
|
1165
|
-
...(this.#config.mapError !== undefined
|
|
1166
|
-
? { mapError: this.#config.mapError }
|
|
1167
|
-
: {}),
|
|
1168
1123
|
realtime: this,
|
|
1169
1124
|
};
|
|
1170
1125
|
}
|
|
@@ -1311,6 +1266,22 @@ export class RealtimeHub {
|
|
|
1311
1266
|
}
|
|
1312
1267
|
}
|
|
1313
1268
|
|
|
1269
|
+
/**
|
|
1270
|
+
* RealtimeNotifier: a pull changed a client's subscription list (§8.7).
|
|
1271
|
+
* Its sockets reload their registrations with the round-end semantics, so
|
|
1272
|
+
* an unreadable record keeps the previous registrations.
|
|
1273
|
+
*/
|
|
1274
|
+
async notifySubscriptions(
|
|
1275
|
+
partition: string,
|
|
1276
|
+
clientId: string,
|
|
1277
|
+
): Promise<void> {
|
|
1278
|
+
for (const session of [...this.#sessions]) {
|
|
1279
|
+
if (session.partition !== partition || session.clientId !== clientId)
|
|
1280
|
+
continue;
|
|
1281
|
+
await session.refreshAfterPull();
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1314
1285
|
/**
|
|
1315
1286
|
* Host-initiated, fail-closed registration refresh (§8.7). A host calls this
|
|
1316
1287
|
* after it changes an actor's membership or connection state, so the
|
package/src/relational-rows.ts
CHANGED
|
@@ -709,20 +709,17 @@ export function prefetchRowsSql(table: CompiledTable): string {
|
|
|
709
709
|
* SQLite/D1: the newest `commit_seq` in the inverted change-scope index for
|
|
710
710
|
* one variable's values, as one index-only max per value (a `(partition,
|
|
711
711
|
* tbl, var, value)` prefix), so the cost is independent of the table's
|
|
712
|
-
* history.
|
|
712
|
+
* history. The values arrive as one JSON array, so the statement has no
|
|
713
|
+
* compound SELECT and four bound parameters for any value count (D1 caps a
|
|
714
|
+
* compound SELECT at 5 terms and a statement at 100 parameters).
|
|
715
|
+
* Params: [partition, tbl, var, values as JSON array text].
|
|
713
716
|
*/
|
|
714
|
-
export
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
perValue.push(
|
|
718
|
-
'SELECT (SELECT max(commit_seq) FROM sync_change_scopes WHERE partition=? AND tbl=? AND var=? AND value=?) AS seq',
|
|
719
|
-
);
|
|
720
|
-
}
|
|
721
|
-
return `SELECT max(seq) AS seq FROM (${perValue.join(' UNION ALL ')})`;
|
|
722
|
-
}
|
|
717
|
+
export const LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL = `SELECT max((SELECT max(commit_seq) FROM sync_change_scopes
|
|
718
|
+
WHERE partition=? AND tbl=? AND var=? AND value=v.value)) AS seq
|
|
719
|
+
FROM json_each(?) AS v`;
|
|
723
720
|
|
|
724
721
|
/**
|
|
725
|
-
* PostgreSQL set-based form of `
|
|
722
|
+
* PostgreSQL set-based form of `LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL` for the
|
|
726
723
|
* subscriptions of one table in a pull. `$2` is a JSON array of
|
|
727
724
|
* `{i, var, vals}` records. Params: [partition, specs (JSON text), tbl].
|
|
728
725
|
*/
|