@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.
Files changed (43) hide show
  1. package/dist/blob-handlers.js +3 -3
  2. package/dist/bucket-blob-store.d.ts +77 -0
  3. package/dist/bucket-blob-store.js +266 -0
  4. package/dist/bucket-segment-store.d.ts +77 -0
  5. package/dist/bucket-segment-store.js +445 -0
  6. package/dist/context.d.ts +7 -0
  7. package/dist/d1-storage.js +2 -2
  8. package/dist/handler.js +4 -0
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.js +4 -0
  11. package/dist/object-bucket.d.ts +78 -0
  12. package/dist/object-bucket.js +18 -0
  13. package/dist/operations-realtime.js +3 -0
  14. package/dist/operations.d.ts +20 -3
  15. package/dist/operations.js +23 -3
  16. package/dist/realtime.d.ts +11 -0
  17. package/dist/realtime.js +25 -58
  18. package/dist/relational-rows.d.ts +6 -3
  19. package/dist/relational-rows.js +8 -9
  20. package/dist/s3-blob-store.d.ts +9 -64
  21. package/dist/s3-blob-store.js +25 -359
  22. package/dist/s3-object-bucket.d.ts +39 -0
  23. package/dist/s3-object-bucket.js +164 -0
  24. package/dist/s3-segment-store.d.ts +9 -61
  25. package/dist/s3-segment-store.js +25 -503
  26. package/dist/sqlite-storage.js +3 -8
  27. package/package.json +2 -2
  28. package/src/blob-handlers.ts +4 -4
  29. package/src/bucket-blob-store.ts +351 -0
  30. package/src/bucket-segment-store.ts +582 -0
  31. package/src/context.ts +10 -0
  32. package/src/d1-storage.ts +3 -3
  33. package/src/handler.ts +11 -0
  34. package/src/index.ts +4 -0
  35. package/src/object-bucket.ts +83 -0
  36. package/src/operations-realtime.ts +7 -0
  37. package/src/operations.ts +52 -5
  38. package/src/realtime.ts +29 -58
  39. package/src/relational-rows.ts +8 -11
  40. package/src/s3-blob-store.ts +25 -466
  41. package/src/s3-object-bucket.ts +233 -0
  42. package/src/s3-segment-store.ts +25 -646
  43. 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 { toValidateRow, type ValidateRow } from './validate';
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
- ctx: SyncRequestContext,
711
- registry: RemoteOperationRegistry,
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
@@ -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. Bind order: [partition, tbl, var, value] per value.
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 function latestScopedChangeSeqSql(valueCount: number): string {
715
- const perValue: string[] = [];
716
- for (let i = 0; i < valueCount; i++) {
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 `latestScopedChangeSeqSql` for the
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
  */