@syncular/server 0.32.0 → 0.33.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 +8 -2
- package/dist/handler.js +21 -7
- 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.js +4 -4
- package/dist/push.js +28 -7
- package/dist/realtime.d.ts +11 -0
- package/dist/realtime.js +38 -56
- 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/seed.js +4 -3
- 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 +12 -2
- package/src/handler.ts +38 -10
- package/src/index.ts +4 -0
- package/src/object-bucket.ts +83 -0
- package/src/operations-realtime.ts +7 -0
- package/src/operations.ts +8 -4
- package/src/push.ts +40 -9
- package/src/realtime.ts +45 -55
- 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/seed.ts +4 -3
package/src/handler.ts
CHANGED
|
@@ -526,11 +526,12 @@ async function* streamResponse(
|
|
|
526
526
|
}
|
|
527
527
|
try {
|
|
528
528
|
// Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
|
|
529
|
+
const currentSchema = compileSchema(ctx.schema);
|
|
529
530
|
let acceptedThrough = 0;
|
|
530
531
|
for (const push of plan.pushes) {
|
|
531
532
|
const processed = await processPushCommitWithTrace(
|
|
532
533
|
ctx,
|
|
533
|
-
|
|
534
|
+
currentSchema,
|
|
534
535
|
plan.resolved,
|
|
535
536
|
plan.header.clientId,
|
|
536
537
|
push,
|
|
@@ -543,20 +544,36 @@ async function* streamResponse(
|
|
|
543
544
|
const frame = {
|
|
544
545
|
...processed.frame,
|
|
545
546
|
results: processed.frame.results.map((result) => {
|
|
546
|
-
if (result.status !== 'conflict')
|
|
547
|
-
|
|
547
|
+
if (result.status !== 'conflict' || schema === currentSchema) {
|
|
548
|
+
return result;
|
|
549
|
+
}
|
|
550
|
+
const operation = push.operations[result.opIndex];
|
|
551
|
+
// §2.3: a replay returns the persisted result without rebuilding
|
|
552
|
+
// operations, so a reused clientCommitId with changed operations
|
|
553
|
+
// leaves no table to project the stored conflict row through.
|
|
554
|
+
if (operation === undefined) {
|
|
555
|
+
throw syncError(
|
|
556
|
+
'sync.invalid_request',
|
|
557
|
+
'replayed clientCommitId carries changed operations',
|
|
558
|
+
JSON.stringify({
|
|
559
|
+
clientCommitId: push.clientCommitId,
|
|
560
|
+
opIndex: result.opIndex,
|
|
561
|
+
}),
|
|
562
|
+
);
|
|
563
|
+
}
|
|
548
564
|
const target = schema.tables.get(operation.table)!;
|
|
549
|
-
const
|
|
550
|
-
operation.table,
|
|
551
|
-
)!;
|
|
565
|
+
const bitmapBytes = Math.ceil(target.columns.length / 8);
|
|
552
566
|
return {
|
|
553
567
|
...result,
|
|
554
|
-
serverRow: projectRowPayload(
|
|
568
|
+
serverRow: projectRowPayload(
|
|
569
|
+
currentSchema.tables.get(operation.table)!,
|
|
570
|
+
target,
|
|
571
|
+
result.serverRow,
|
|
572
|
+
),
|
|
555
573
|
conflictColumns: result.conflictColumns
|
|
556
|
-
.slice(0,
|
|
574
|
+
.slice(0, bitmapBytes)
|
|
557
575
|
.map((byte, i) =>
|
|
558
|
-
i ===
|
|
559
|
-
target.columns.length % 8 !== 0
|
|
576
|
+
i === bitmapBytes - 1 && target.columns.length % 8 !== 0
|
|
560
577
|
? byte & ((1 << (target.columns.length % 8)) - 1)
|
|
561
578
|
: byte,
|
|
562
579
|
),
|
|
@@ -700,6 +717,17 @@ async function* streamResponse(
|
|
|
700
717
|
updatedAtMs: clockOf(ctx)(),
|
|
701
718
|
subscriptions,
|
|
702
719
|
});
|
|
720
|
+
if (
|
|
721
|
+
ctx.realtime?.notifySubscriptions !== undefined &&
|
|
722
|
+
JSON.stringify(
|
|
723
|
+
(previous?.subscriptions ?? []).map((s) => [s.id, s.table, s.scopes]),
|
|
724
|
+
) !== JSON.stringify(subscriptions.map((s) => [s.id, s.table, s.scopes]))
|
|
725
|
+
) {
|
|
726
|
+
await ctx.realtime.notifySubscriptions(
|
|
727
|
+
ctx.partition,
|
|
728
|
+
plan.header.clientId,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
703
731
|
} catch (error) {
|
|
704
732
|
if (error instanceof SyncError) {
|
|
705
733
|
if (report !== undefined) {
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ export * from './admin';
|
|
|
11
11
|
export * from './authoritative-query';
|
|
12
12
|
export * from './blob-handlers';
|
|
13
13
|
export * from './blob-store';
|
|
14
|
+
export * from './bucket-blob-store';
|
|
15
|
+
export * from './bucket-segment-store';
|
|
14
16
|
export * from './content-encoding';
|
|
15
17
|
export * from './context';
|
|
16
18
|
export * from './crdt-merger';
|
|
@@ -65,8 +67,10 @@ export {
|
|
|
65
67
|
} from './reactions';
|
|
66
68
|
export * from './realtime';
|
|
67
69
|
export * from './restore';
|
|
70
|
+
export * from './object-bucket';
|
|
68
71
|
export * from './relational-rows';
|
|
69
72
|
export * from './s3-blob-store';
|
|
73
|
+
export * from './s3-object-bucket';
|
|
70
74
|
export * from './s3-segment-store';
|
|
71
75
|
export * from './schema';
|
|
72
76
|
export * from './scopes';
|
|
@@ -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
|
@@ -21,7 +21,11 @@ import {
|
|
|
21
21
|
import { reportError, SyncError, syncError } from './errors';
|
|
22
22
|
import { processPushOperationsWithTrace } from './push';
|
|
23
23
|
import { compileSchema } from './schema';
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
authorizeWrite,
|
|
26
|
+
renderScopeValue,
|
|
27
|
+
type ResolvedScopes,
|
|
28
|
+
} from './scopes';
|
|
25
29
|
import type {
|
|
26
30
|
AuthoritativeQueryRequest,
|
|
27
31
|
AuthoritativeQueryResult,
|
|
@@ -534,11 +538,11 @@ function commandOperations(
|
|
|
534
538
|
}
|
|
535
539
|
return mutation.values[column.name] ?? null;
|
|
536
540
|
});
|
|
537
|
-
const rowId = values[table.primaryKeyIndex];
|
|
538
|
-
if (
|
|
541
|
+
const rowId = renderScopeValue(values[table.primaryKeyIndex]);
|
|
542
|
+
if (rowId === undefined) {
|
|
539
543
|
throw syncError(
|
|
540
544
|
'operation.invalid_request',
|
|
541
|
-
'command upsert requires a non-empty
|
|
545
|
+
'command upsert requires a non-empty primary key',
|
|
542
546
|
);
|
|
543
547
|
}
|
|
544
548
|
return {
|
package/src/push.ts
CHANGED
|
@@ -503,6 +503,7 @@ async function applyOperation(
|
|
|
503
503
|
if (
|
|
504
504
|
present[i] &&
|
|
505
505
|
!crdtIndexes.has(i) &&
|
|
506
|
+
i !== table.primaryKeyIndex &&
|
|
506
507
|
effectiveVersion(i) > op.baseVersion
|
|
507
508
|
) {
|
|
508
509
|
marked.push(i);
|
|
@@ -978,7 +979,20 @@ async function enforceReferences(
|
|
|
978
979
|
const operations: ValidateCommitOperation[] = [];
|
|
979
980
|
const originByIndex: number[] = [];
|
|
980
981
|
|
|
982
|
+
const finalByRow = new Map<
|
|
983
|
+
string,
|
|
984
|
+
{ readonly first: StagedWrite; readonly last: StagedWrite }
|
|
985
|
+
>();
|
|
981
986
|
for (const write of staged) {
|
|
987
|
+
const rowKey = `${write.table.name}\u0000${write.rowId}`;
|
|
988
|
+
const existing = finalByRow.get(rowKey);
|
|
989
|
+
finalByRow.set(rowKey, {
|
|
990
|
+
first: existing?.first ?? write,
|
|
991
|
+
last: write,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
for (const { last: write } of finalByRow.values()) {
|
|
982
996
|
if (write.op !== 'upsert' || write.values === undefined) continue;
|
|
983
997
|
for (const reference of write.table.references) {
|
|
984
998
|
const value = write.values[reference.columnIndex];
|
|
@@ -1028,9 +1042,9 @@ async function enforceReferences(
|
|
|
1028
1042
|
readonly rowId: string;
|
|
1029
1043
|
readonly originOpIndex: number;
|
|
1030
1044
|
}> = [];
|
|
1031
|
-
for (const
|
|
1032
|
-
if (
|
|
1033
|
-
schedule(
|
|
1045
|
+
for (const { first, last } of finalByRow.values()) {
|
|
1046
|
+
if (last.op === 'delete' && first.stored !== undefined) {
|
|
1047
|
+
schedule(last.table, last.rowId, last.opIndex, scheduled, queue);
|
|
1034
1048
|
}
|
|
1035
1049
|
}
|
|
1036
1050
|
|
|
@@ -1040,6 +1054,13 @@ async function enforceReferences(
|
|
|
1040
1054
|
const key = `${item.table.name}\u0000${item.rowId}`;
|
|
1041
1055
|
if (processed.has(key)) continue;
|
|
1042
1056
|
processed.add(key);
|
|
1057
|
+
const parentPkColumn = item.table.columns[item.table.primaryKeyIndex];
|
|
1058
|
+
const parentKeyValue: RowValue =
|
|
1059
|
+
parentPkColumn?.type === 'integer'
|
|
1060
|
+
? Number(item.rowId)
|
|
1061
|
+
: parentPkColumn?.type === 'boolean'
|
|
1062
|
+
? item.rowId === 'true'
|
|
1063
|
+
: item.rowId;
|
|
1043
1064
|
for (const reference of item.table.referencedBy) {
|
|
1044
1065
|
if (tx.scanRowsByIndex === undefined) {
|
|
1045
1066
|
throw new Error(
|
|
@@ -1052,7 +1073,7 @@ async function enforceReferences(
|
|
|
1052
1073
|
const page = await tx.scanRowsByIndex({
|
|
1053
1074
|
table: reference.table,
|
|
1054
1075
|
index: reference.index,
|
|
1055
|
-
values: [
|
|
1076
|
+
values: [parentKeyValue],
|
|
1056
1077
|
afterRowId,
|
|
1057
1078
|
limit: 1_000,
|
|
1058
1079
|
});
|
|
@@ -1081,6 +1102,21 @@ async function enforceReferences(
|
|
|
1081
1102
|
);
|
|
1082
1103
|
}
|
|
1083
1104
|
for (const childRow of children) {
|
|
1105
|
+
if (reference.onDelete === 'CASCADE') {
|
|
1106
|
+
if (
|
|
1107
|
+
!schedule(
|
|
1108
|
+
child,
|
|
1109
|
+
childRow.rowId,
|
|
1110
|
+
item.originOpIndex,
|
|
1111
|
+
scheduled,
|
|
1112
|
+
queue,
|
|
1113
|
+
)
|
|
1114
|
+
) {
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
} else if (scheduled.has(`${child.name}\u0000${childRow.rowId}`)) {
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1084
1120
|
if (originByIndex.length >= cascadeLimit) {
|
|
1085
1121
|
return {
|
|
1086
1122
|
kind: 'terminate',
|
|
@@ -1091,11 +1127,6 @@ async function enforceReferences(
|
|
|
1091
1127
|
),
|
|
1092
1128
|
};
|
|
1093
1129
|
}
|
|
1094
|
-
if (
|
|
1095
|
-
!schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)
|
|
1096
|
-
) {
|
|
1097
|
-
continue;
|
|
1098
|
-
}
|
|
1099
1130
|
const syntheticIndex = clientOpCount + originByIndex.length;
|
|
1100
1131
|
const storedValues = decodeRow(child.columns, childRow.payload);
|
|
1101
1132
|
if (reference.onDelete === 'CASCADE') {
|
package/src/realtime.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
encodeMessage,
|
|
24
24
|
encodePresenceError,
|
|
25
25
|
encodePresenceFanout,
|
|
26
|
+
isSupportedProtocolWireVersion,
|
|
26
27
|
MessageStreamScanner,
|
|
27
28
|
PROTOCOL_WIRE_VERSION,
|
|
28
29
|
type PresenceKind,
|
|
@@ -278,6 +279,8 @@ export class RealtimeSession {
|
|
|
278
279
|
#registrationGeneration = 0;
|
|
279
280
|
/** Generation of the most recent refresh whose result was applied. */
|
|
280
281
|
#registrationApplied = 0;
|
|
282
|
+
/** Highest sequence that required a catch-up wake for this session. */
|
|
283
|
+
#wakeRequiredThroughSeq: number;
|
|
281
284
|
|
|
282
285
|
constructor(
|
|
283
286
|
hub: RealtimeHub,
|
|
@@ -301,6 +304,7 @@ export class RealtimeSession {
|
|
|
301
304
|
this.cursor = cursor;
|
|
302
305
|
this.lastKnownSeq = latestSeq;
|
|
303
306
|
this.wakePending = cursor < latestSeq;
|
|
307
|
+
this.#wakeRequiredThroughSeq = cursor < latestSeq ? latestSeq : 0;
|
|
304
308
|
this.registrations = registrations;
|
|
305
309
|
this.openedAtMs = clock();
|
|
306
310
|
this.#send = options.send;
|
|
@@ -336,9 +340,9 @@ export class RealtimeSession {
|
|
|
336
340
|
// proves those commits no longer need socket notifications, so the next
|
|
337
341
|
// notification must be adjacent to the acknowledged cursor.
|
|
338
342
|
this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
|
|
339
|
-
if (this.cursor >= this
|
|
343
|
+
if (this.cursor >= this.#wakeRequiredThroughSeq) this.wakePending = false;
|
|
340
344
|
// §8.2: acks update the client cursor record without an HTTP pull.
|
|
341
|
-
this.#persistCursor();
|
|
345
|
+
this.#persistCursor(cursor);
|
|
342
346
|
}
|
|
343
347
|
|
|
344
348
|
/**
|
|
@@ -571,7 +575,7 @@ export class RealtimeSession {
|
|
|
571
575
|
try {
|
|
572
576
|
const requestedWireVersion =
|
|
573
577
|
(requestBytes[4] ?? 0) | ((requestBytes[5] ?? 0) << 8);
|
|
574
|
-
if (requestedWireVersion
|
|
578
|
+
if (isSupportedProtocolWireVersion(requestedWireVersion)) {
|
|
575
579
|
this.wireVersion = requestedWireVersion;
|
|
576
580
|
}
|
|
577
581
|
let stream: AsyncIterable<Uint8Array> | undefined;
|
|
@@ -688,6 +692,15 @@ export class RealtimeSession {
|
|
|
688
692
|
* deltas immediately; it also survives a newer round-end refresh that
|
|
689
693
|
* failed, because that round applied nothing.
|
|
690
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
|
+
|
|
691
704
|
async refreshRegistrations(failClosed: boolean): Promise<void> {
|
|
692
705
|
const generation = ++this.#registrationGeneration;
|
|
693
706
|
let registrations: Registration[];
|
|
@@ -721,7 +734,7 @@ export class RealtimeSession {
|
|
|
721
734
|
this.#hub.disconnect(this);
|
|
722
735
|
}
|
|
723
736
|
|
|
724
|
-
#persistCursor(): void {
|
|
737
|
+
#persistCursor(cursor: number): void {
|
|
725
738
|
// §8.2: start the write now and keep it on the control tail so `drain()`
|
|
726
739
|
// can await it. Two acks may write concurrently: storage folds them with
|
|
727
740
|
// `MAX(cursor, ?)`, so neither order loses the later cursor.
|
|
@@ -731,7 +744,7 @@ export class RealtimeSession {
|
|
|
731
744
|
this.clientId,
|
|
732
745
|
this.actorId,
|
|
733
746
|
this.logEpoch,
|
|
734
|
-
|
|
747
|
+
cursor,
|
|
735
748
|
this.#clock(),
|
|
736
749
|
);
|
|
737
750
|
})();
|
|
@@ -767,6 +780,10 @@ export class RealtimeSession {
|
|
|
767
780
|
|
|
768
781
|
sendWake(reason: WakeReason): void {
|
|
769
782
|
this.wakePending = true;
|
|
783
|
+
this.#wakeRequiredThroughSeq = Math.max(
|
|
784
|
+
this.#wakeRequiredThroughSeq,
|
|
785
|
+
this.lastKnownSeq,
|
|
786
|
+
);
|
|
770
787
|
this.#sendSafe(
|
|
771
788
|
JSON.stringify({
|
|
772
789
|
event: 'sync',
|
|
@@ -1096,58 +1113,13 @@ export class RealtimeHub {
|
|
|
1096
1113
|
'socket sync rounds require a segment store on the realtime hub (§8.7)',
|
|
1097
1114
|
);
|
|
1098
1115
|
}
|
|
1116
|
+
// The whole config, so a new `SyncServerConfig` field reaches socket and
|
|
1117
|
+
// hub-served HTTP rounds without being listed here.
|
|
1099
1118
|
return {
|
|
1119
|
+
...this.#config,
|
|
1120
|
+
segments,
|
|
1100
1121
|
partition: identity.partition,
|
|
1101
1122
|
actorId: identity.actorId,
|
|
1102
|
-
schema: this.#config.schema,
|
|
1103
|
-
...(this.#config.schemaWindow !== undefined
|
|
1104
|
-
? { schemaWindow: this.#config.schemaWindow }
|
|
1105
|
-
: {}),
|
|
1106
|
-
storage: this.#config.storage,
|
|
1107
|
-
segments,
|
|
1108
|
-
resolveScopes: this.#config.resolveScopes,
|
|
1109
|
-
...(this.#config.blobs !== undefined
|
|
1110
|
-
? { blobs: this.#config.blobs }
|
|
1111
|
-
: {}),
|
|
1112
|
-
...(this.#config.maxBlobBytes !== undefined
|
|
1113
|
-
? { maxBlobBytes: this.#config.maxBlobBytes }
|
|
1114
|
-
: {}),
|
|
1115
|
-
...(this.#config.crdtMergers !== undefined
|
|
1116
|
-
? { crdtMergers: this.#config.crdtMergers }
|
|
1117
|
-
: {}),
|
|
1118
|
-
...(this.#config.validators !== undefined
|
|
1119
|
-
? { validators: this.#config.validators }
|
|
1120
|
-
: {}),
|
|
1121
|
-
...(this.#config.commitValidator !== undefined
|
|
1122
|
-
? { commitValidator: this.#config.commitValidator }
|
|
1123
|
-
: {}),
|
|
1124
|
-
...(this.#config.clock !== undefined
|
|
1125
|
-
? { clock: this.#config.clock }
|
|
1126
|
-
: {}),
|
|
1127
|
-
...(this.#config.limits !== undefined
|
|
1128
|
-
? { limits: this.#config.limits }
|
|
1129
|
-
: {}),
|
|
1130
|
-
...(this.#config.signedUrls !== undefined
|
|
1131
|
-
? { signedUrls: this.#config.signedUrls }
|
|
1132
|
-
: {}),
|
|
1133
|
-
...(this.#config.blobSignedUrls !== undefined
|
|
1134
|
-
? { blobSignedUrls: this.#config.blobSignedUrls }
|
|
1135
|
-
: {}),
|
|
1136
|
-
...(this.#config.blobUploadUrls !== undefined
|
|
1137
|
-
? { blobUploadUrls: this.#config.blobUploadUrls }
|
|
1138
|
-
: {}),
|
|
1139
|
-
...(this.#config.sqliteImageBuilder !== undefined
|
|
1140
|
-
? { sqliteImageBuilder: this.#config.sqliteImageBuilder }
|
|
1141
|
-
: {}),
|
|
1142
|
-
...(this.#config.leases !== undefined
|
|
1143
|
-
? { leases: this.#config.leases }
|
|
1144
|
-
: {}),
|
|
1145
|
-
...(this.#config.events !== undefined
|
|
1146
|
-
? { events: this.#config.events }
|
|
1147
|
-
: {}),
|
|
1148
|
-
...(this.#config.onError !== undefined
|
|
1149
|
-
? { onError: this.#config.onError }
|
|
1150
|
-
: {}),
|
|
1151
1123
|
realtime: this,
|
|
1152
1124
|
};
|
|
1153
1125
|
}
|
|
@@ -1211,7 +1183,9 @@ export class RealtimeHub {
|
|
|
1211
1183
|
this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES,
|
|
1212
1184
|
storage,
|
|
1213
1185
|
registry.logEpoch,
|
|
1214
|
-
record
|
|
1186
|
+
record !== undefined && isSupportedProtocolWireVersion(record.wireVersion)
|
|
1187
|
+
? record.wireVersion
|
|
1188
|
+
: PROTOCOL_WIRE_VERSION,
|
|
1215
1189
|
this.#config.events,
|
|
1216
1190
|
);
|
|
1217
1191
|
this.#sessions.add(session);
|
|
@@ -1292,6 +1266,22 @@ export class RealtimeHub {
|
|
|
1292
1266
|
}
|
|
1293
1267
|
}
|
|
1294
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
|
+
|
|
1295
1285
|
/**
|
|
1296
1286
|
* Host-initiated, fail-closed registration refresh (§8.7). A host calls this
|
|
1297
1287
|
* after it changes an actor's membership or connection state, so the
|