@syncular/server 0.21.1 → 0.23.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/README.md +20 -6
- package/dist/admin.d.ts +3 -1
- package/dist/admin.js +7 -1
- package/dist/blob-handlers.js +1 -1
- package/dist/context.d.ts +8 -1
- package/dist/d1-storage.d.ts +11 -3
- package/dist/d1-storage.js +84 -2
- package/dist/errors.js +10 -0
- package/dist/handler.js +50 -4
- package/dist/operations.js +3 -3
- package/dist/pg-executor.d.ts +11 -1
- package/dist/postgres-storage.d.ts +20 -5
- package/dist/postgres-storage.js +345 -9
- package/dist/pull.js +9 -5
- package/dist/push.js +18 -0
- package/dist/readiness.d.ts +9 -2
- package/dist/readiness.js +27 -2
- package/dist/realtime.d.ts +40 -1
- package/dist/realtime.js +116 -19
- package/dist/relational-rows.d.ts +20 -0
- package/dist/relational-rows.js +29 -0
- package/dist/s3-segment-store.d.ts +20 -8
- package/dist/s3-segment-store.js +216 -68
- package/dist/schema.js +11 -0
- package/dist/segment-download.d.ts +1 -1
- package/dist/segment-download.js +50 -17
- package/dist/segment-store.d.ts +86 -0
- package/dist/segment-store.js +156 -17
- package/dist/signed-url.d.ts +7 -1
- package/dist/signed-url.js +10 -1
- package/dist/sqlite-dialect.d.ts +7 -1
- package/dist/sqlite-dialect.js +23 -0
- package/dist/sqlite-segment-store.js +153 -42
- package/dist/sqlite-storage.d.ts +11 -3
- package/dist/sqlite-storage.js +346 -34
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +4 -0
- package/dist/storage.d.ts +152 -2
- package/dist/storage.js +40 -1
- package/dist/validate.d.ts +20 -1
- package/package.json +2 -2
- package/src/admin.ts +13 -1
- package/src/blob-handlers.ts +1 -1
- package/src/context.ts +8 -0
- package/src/d1-storage.ts +143 -1
- package/src/errors.ts +10 -0
- package/src/handler.ts +62 -4
- package/src/operations.ts +17 -13
- package/src/pg-executor.ts +11 -1
- package/src/postgres-storage.ts +520 -5
- package/src/pull.ts +9 -1
- package/src/push.ts +18 -0
- package/src/readiness.ts +39 -4
- package/src/realtime.ts +117 -18
- package/src/relational-rows.ts +36 -0
- package/src/s3-segment-store.ts +275 -84
- package/src/schema.ts +15 -0
- package/src/segment-download.ts +79 -28
- package/src/segment-store.ts +250 -18
- package/src/signed-url.ts +12 -2
- package/src/sqlite-dialect.ts +23 -0
- package/src/sqlite-segment-store.ts +245 -65
- package/src/sqlite-storage.ts +494 -36
- package/src/storage-errors.ts +13 -1
- package/src/storage.ts +243 -1
- package/src/validate.ts +20 -1
package/dist/realtime.d.ts
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A `RealtimeSession` wraps a connected socket's send/receive callbacks:
|
|
5
5
|
* the host feeds inbound text frames to `handleMessage`, binary frames to
|
|
6
|
-
* `handleBinary`, and wires `session.close()` to socket close.
|
|
6
|
+
* `handleBinary`, and wires `session.close()` to socket close. `handleMessage`
|
|
7
|
+
* queues the ack's cursor write without awaiting it, so `drain()` is the
|
|
8
|
+
* awaitable boundary a host uses before closing its storage or ending a
|
|
9
|
+
* hibernatable event (§8.2). Initial
|
|
7
10
|
* subscription registration comes from the client's most recent pull
|
|
8
11
|
* (§8.1, loaded from the client record); a sync round completed on the
|
|
9
12
|
* connection replaces it at round end (§8.7). Deltas are complete SSP2
|
|
@@ -112,6 +115,13 @@ export declare class RealtimeSession {
|
|
|
112
115
|
/** Feed an inbound text frame (client → server control message, §8.2
|
|
113
116
|
* ack, §8.6.2 presence). */
|
|
114
117
|
handleMessage(text: string): void;
|
|
118
|
+
/**
|
|
119
|
+
* Resolve once every queued control-plane write has settled (§8.2). A host
|
|
120
|
+
* awaits this before it closes its storage or ends a hibernatable event, so
|
|
121
|
+
* an ack's cursor write is not abandoned mid-flight. A persistence failure
|
|
122
|
+
* surfaces here instead of vanishing.
|
|
123
|
+
*/
|
|
124
|
+
drain(): Promise<void>;
|
|
115
125
|
/** Deliver a fanout event to this session (called by the hub for peers,
|
|
116
126
|
* §8.6.3 receive authorization checked by the caller). */
|
|
117
127
|
receivePresence(scopeKey: string, kind: PresenceKind, actorId: string, clientId: string, doc: PresenceDoc | null): void;
|
|
@@ -135,6 +145,21 @@ export declare class RealtimeSession {
|
|
|
135
145
|
* coordinated hosts await it to retain their partition FIFO through commit.
|
|
136
146
|
*/
|
|
137
147
|
handleBinary(bytes: Uint8Array): Promise<void> | undefined;
|
|
148
|
+
/**
|
|
149
|
+
* Reload this connection's registrations and reconcile presence (§8.7).
|
|
150
|
+
* The round-end refresh and the host-initiated `RealtimeHub.refreshScopes`
|
|
151
|
+
* both route through here under one per-session generation, so a load that
|
|
152
|
+
* resolves late can never overwrite a newer result: a slow resolver may
|
|
153
|
+
* return scopes a later refresh already revoked, and the later refresh
|
|
154
|
+
* keeps the connection's current grants.
|
|
155
|
+
*
|
|
156
|
+
* `failClosed` selects the failure semantics. The round-end refresh keeps
|
|
157
|
+
* the previous registrations so a failed round changes nothing. The host
|
|
158
|
+
* refresh empties them so a revoked or unreadable grant stops receiving
|
|
159
|
+
* deltas immediately; it also survives a newer round-end refresh that
|
|
160
|
+
* failed, because that round applied nothing.
|
|
161
|
+
*/
|
|
162
|
+
refreshRegistrations(failClosed: boolean): Promise<void>;
|
|
138
163
|
sendHeartbeat(): void;
|
|
139
164
|
sendWake(reason: WakeReason): void;
|
|
140
165
|
/** Called by the hub for every applied commit notification. */
|
|
@@ -182,6 +207,20 @@ export declare class RealtimeHub {
|
|
|
182
207
|
disconnect(session: RealtimeSession): void;
|
|
183
208
|
/** RealtimeNotifier: fan an applied commit out to matching sessions. */
|
|
184
209
|
notifyCommit(partition: string, commit: StoredCommit): Promise<void>;
|
|
210
|
+
/**
|
|
211
|
+
* Host-initiated, fail-closed registration refresh (§8.7). A host calls this
|
|
212
|
+
* after it changes an actor's membership or connection state, so the
|
|
213
|
+
* partition's connected sessions stop using grants the host already revoked
|
|
214
|
+
* without waiting for the client to happen to run a round.
|
|
215
|
+
*
|
|
216
|
+
* This differs deliberately from the round-end refresh
|
|
217
|
+
* (`RealtimeSession.refreshRegistrations`), which keeps the previous
|
|
218
|
+
* registrations when the client record or resolver cannot be read so a
|
|
219
|
+
* failed round changes nothing. Here an unresolvable session is emptied:
|
|
220
|
+
* a revoked or unreadable grant must stop receiving deltas rather than live
|
|
221
|
+
* on until the next round. Presence is reconciled exactly as at round end.
|
|
222
|
+
*/
|
|
223
|
+
refreshScopes(partition: string, actorId?: string): Promise<void>;
|
|
185
224
|
/** Broadcast a wake-up (host-initiated resync, schema rollover, §8.3). */
|
|
186
225
|
wake(partition: string, reason: WakeReason): void;
|
|
187
226
|
}
|
package/dist/realtime.js
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A `RealtimeSession` wraps a connected socket's send/receive callbacks:
|
|
5
5
|
* the host feeds inbound text frames to `handleMessage`, binary frames to
|
|
6
|
-
* `handleBinary`, and wires `session.close()` to socket close.
|
|
6
|
+
* `handleBinary`, and wires `session.close()` to socket close. `handleMessage`
|
|
7
|
+
* queues the ack's cursor write without awaiting it, so `drain()` is the
|
|
8
|
+
* awaitable boundary a host uses before closing its storage or ending a
|
|
9
|
+
* hibernatable event (§8.2). Initial
|
|
7
10
|
* subscription registration comes from the client's most recent pull
|
|
8
11
|
* (§8.1, loaded from the client record); a sync round completed on the
|
|
9
12
|
* connection replaces it at round end (§8.7). Deltas are complete SSP2
|
|
@@ -147,6 +150,14 @@ export class RealtimeSession {
|
|
|
147
150
|
/** §8.6.4 rate cap: per-scope-key last-fanout time + a pending latest
|
|
148
151
|
* document coalesced while over the cap. */
|
|
149
152
|
#presenceRate = new Map();
|
|
153
|
+
/** Queued control-plane writes (ack cursor persistence, §8.2). */
|
|
154
|
+
#controlTail = Promise.resolve();
|
|
155
|
+
/** First control-plane failure not yet surfaced by `drain()`. */
|
|
156
|
+
#controlFailure = undefined;
|
|
157
|
+
/** Issue counter for registration refreshes (§8.7). */
|
|
158
|
+
#registrationGeneration = 0;
|
|
159
|
+
/** Generation of the most recent refresh whose result was applied. */
|
|
160
|
+
#registrationApplied = 0;
|
|
150
161
|
constructor(hub, options, registrations, cursor, latestSeq, clock, maxDeltaBytes, storage, logEpoch, wireVersion, events) {
|
|
151
162
|
this.sessionId = crypto.randomUUID();
|
|
152
163
|
this.partition = options.partition;
|
|
@@ -198,7 +209,21 @@ export class RealtimeSession {
|
|
|
198
209
|
if (this.cursor >= this.lastKnownSeq)
|
|
199
210
|
this.wakePending = false;
|
|
200
211
|
// §8.2: acks update the client cursor record without an HTTP pull.
|
|
201
|
-
|
|
212
|
+
this.#persistCursor();
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Resolve once every queued control-plane write has settled (§8.2). A host
|
|
216
|
+
* awaits this before it closes its storage or ends a hibernatable event, so
|
|
217
|
+
* an ack's cursor write is not abandoned mid-flight. A persistence failure
|
|
218
|
+
* surfaces here instead of vanishing.
|
|
219
|
+
*/
|
|
220
|
+
drain() {
|
|
221
|
+
return this.#controlTail.then(() => {
|
|
222
|
+
const failure = this.#controlFailure;
|
|
223
|
+
this.#controlFailure = undefined;
|
|
224
|
+
if (failure !== undefined)
|
|
225
|
+
throw failure;
|
|
226
|
+
});
|
|
202
227
|
}
|
|
203
228
|
/** §8.6.2 inbound presence publish/leave from the client. */
|
|
204
229
|
#handlePresence(text) {
|
|
@@ -421,7 +446,26 @@ export class RealtimeSession {
|
|
|
421
446
|
// begin its next round as soon as END *arrives* (§8.7 rule 3), so
|
|
422
447
|
// in-flight must end with the send, not with generator cleanup.
|
|
423
448
|
const iterator = stream[Symbol.asyncIterator]();
|
|
424
|
-
let step
|
|
449
|
+
let step;
|
|
450
|
+
try {
|
|
451
|
+
step = await iterator.next();
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
// The buffered read-verify refusal is thrown on the generator's first
|
|
455
|
+
// `next()`, before any chunk is yielded (the generator builds the whole
|
|
456
|
+
// response, then verifies). Nothing has escaped, so it is delivered as
|
|
457
|
+
// the same §8.4 ERROR frame HTTP returns rather than a silent close. A
|
|
458
|
+
// throw after the first chunk still takes the §8.7 violation path.
|
|
459
|
+
if (error instanceof DecodeError || error instanceof SyncError) {
|
|
460
|
+
const sync = error instanceof SyncError
|
|
461
|
+
? error
|
|
462
|
+
: syncError(error.code, error.message);
|
|
463
|
+
finishRound();
|
|
464
|
+
await this.#sendRoundChunk(errorResponseBytes(sync, this.wireVersion, this.logEpoch));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
425
469
|
while (!step.done) {
|
|
426
470
|
const chunk = step.value;
|
|
427
471
|
step = await iterator.next();
|
|
@@ -439,7 +483,7 @@ export class RealtimeSession {
|
|
|
439
483
|
finishRound();
|
|
440
484
|
// §8.7 registration at round end: reload the persisted list — it
|
|
441
485
|
// only advances on success, so failed rounds change nothing.
|
|
442
|
-
await this
|
|
486
|
+
await this.refreshRegistrations(false);
|
|
443
487
|
}
|
|
444
488
|
}
|
|
445
489
|
async #sendRoundChunk(chunk) {
|
|
@@ -448,19 +492,41 @@ export class RealtimeSession {
|
|
|
448
492
|
tagged.set(chunk, 1);
|
|
449
493
|
await this.#send(tagged);
|
|
450
494
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
495
|
+
/**
|
|
496
|
+
* Reload this connection's registrations and reconcile presence (§8.7).
|
|
497
|
+
* The round-end refresh and the host-initiated `RealtimeHub.refreshScopes`
|
|
498
|
+
* both route through here under one per-session generation, so a load that
|
|
499
|
+
* resolves late can never overwrite a newer result: a slow resolver may
|
|
500
|
+
* return scopes a later refresh already revoked, and the later refresh
|
|
501
|
+
* keeps the connection's current grants.
|
|
502
|
+
*
|
|
503
|
+
* `failClosed` selects the failure semantics. The round-end refresh keeps
|
|
504
|
+
* the previous registrations so a failed round changes nothing. The host
|
|
505
|
+
* refresh empties them so a revoked or unreadable grant stops receiving
|
|
506
|
+
* deltas immediately; it also survives a newer round-end refresh that
|
|
507
|
+
* failed, because that round applied nothing.
|
|
508
|
+
*/
|
|
509
|
+
async refreshRegistrations(failClosed) {
|
|
510
|
+
const generation = ++this.#registrationGeneration;
|
|
511
|
+
let registrations;
|
|
456
512
|
try {
|
|
457
|
-
|
|
513
|
+
registrations = await this.#hub.loadRegistrations(this.partition, this.actorId, this.clientId);
|
|
458
514
|
}
|
|
459
515
|
catch {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
516
|
+
if (!failClosed)
|
|
517
|
+
return;
|
|
518
|
+
// Fail closed (host refresh): drop every grant on the connection.
|
|
519
|
+
registrations = [];
|
|
463
520
|
}
|
|
521
|
+
// A newer refresh already applied its result; this late one is stale.
|
|
522
|
+
if (generation < this.#registrationApplied)
|
|
523
|
+
return;
|
|
524
|
+
this.#registrationApplied = generation;
|
|
525
|
+
// §8.6.3: a registration change re-derives the presence grant — capture
|
|
526
|
+
// the keys before, reconcile after (leaves on lost keys, snapshots on
|
|
527
|
+
// gained keys).
|
|
528
|
+
const previousKeys = this.#currentScopeKeys();
|
|
529
|
+
this.registrations = registrations;
|
|
464
530
|
this.reconcilePresence(previousKeys);
|
|
465
531
|
}
|
|
466
532
|
/** §8.7 protocol violation: drop the connection, fail loud. The
|
|
@@ -470,13 +536,19 @@ export class RealtimeSession {
|
|
|
470
536
|
this.#closeSocket?.();
|
|
471
537
|
this.#hub.disconnect(this);
|
|
472
538
|
}
|
|
473
|
-
|
|
474
|
-
|
|
539
|
+
#persistCursor() {
|
|
540
|
+
// §8.2: start the write now and keep it on the control tail so `drain()`
|
|
541
|
+
// can await it. Two acks may write concurrently: storage folds them with
|
|
542
|
+
// `MAX(cursor, ?)`, so neither order loses the later cursor.
|
|
543
|
+
const write = (async () => {
|
|
475
544
|
await this.#storage.advanceClientCursor(this.partition, this.clientId, this.actorId, this.logEpoch, this.cursor, this.#clock());
|
|
476
|
-
}
|
|
477
|
-
catch {
|
|
478
|
-
//
|
|
479
|
-
|
|
545
|
+
})();
|
|
546
|
+
write.catch((error) => {
|
|
547
|
+
// Best-effort for the connection (the next pull repairs the cursor);
|
|
548
|
+
// `drain()` carries the first failure to the host.
|
|
549
|
+
this.#controlFailure ??= error;
|
|
550
|
+
});
|
|
551
|
+
this.#controlTail = Promise.allSettled([this.#controlTail, write]).then(() => undefined);
|
|
480
552
|
}
|
|
481
553
|
/** Fire-and-forget send for control text and deltas; a host send
|
|
482
554
|
* failure surfaces on the socket, not here. */
|
|
@@ -872,6 +944,31 @@ export class RealtimeHub {
|
|
|
872
944
|
session.deliverCommit(commit);
|
|
873
945
|
}
|
|
874
946
|
}
|
|
947
|
+
/**
|
|
948
|
+
* Host-initiated, fail-closed registration refresh (§8.7). A host calls this
|
|
949
|
+
* after it changes an actor's membership or connection state, so the
|
|
950
|
+
* partition's connected sessions stop using grants the host already revoked
|
|
951
|
+
* without waiting for the client to happen to run a round.
|
|
952
|
+
*
|
|
953
|
+
* This differs deliberately from the round-end refresh
|
|
954
|
+
* (`RealtimeSession.refreshRegistrations`), which keeps the previous
|
|
955
|
+
* registrations when the client record or resolver cannot be read so a
|
|
956
|
+
* failed round changes nothing. Here an unresolvable session is emptied:
|
|
957
|
+
* a revoked or unreadable grant must stop receiving deltas rather than live
|
|
958
|
+
* on until the next round. Presence is reconciled exactly as at round end.
|
|
959
|
+
*/
|
|
960
|
+
async refreshScopes(partition, actorId) {
|
|
961
|
+
for (const session of [...this.#sessions]) {
|
|
962
|
+
if (session.partition !== partition)
|
|
963
|
+
continue;
|
|
964
|
+
if (actorId !== undefined && session.actorId !== actorId)
|
|
965
|
+
continue;
|
|
966
|
+
// Fail closed (see the doc above): an unreadable client record drops
|
|
967
|
+
// every grant on the connection. The session's generation guard keeps
|
|
968
|
+
// this revocation from being overwritten by a slower round-end reload.
|
|
969
|
+
await session.refreshRegistrations(true);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
875
972
|
/** Broadcast a wake-up (host-initiated resync, schema rollover, §8.3). */
|
|
876
973
|
wake(partition, reason) {
|
|
877
974
|
for (const session of this.#sessions) {
|
|
@@ -75,6 +75,26 @@ export declare function columnSqlType(column: RowColumn, dialect: RelationalDial
|
|
|
75
75
|
* materialized), version, scopes, payload.
|
|
76
76
|
*/
|
|
77
77
|
export declare function tableColumnNames(table: CompiledTable): string[];
|
|
78
|
+
/**
|
|
79
|
+
* The physical column set the row codec and the scope index read for one
|
|
80
|
+
* configured table. The persisted marker's layouts describe the codec's app
|
|
81
|
+
* columns only and its version number is the schema's, not the database's, so
|
|
82
|
+
* a same-version database that is missing a storage-internal meta column (a
|
|
83
|
+
* version-only bump whose ALTER never ran) still has to be refused: compare
|
|
84
|
+
* the physical table and fail closed without writing a migration.
|
|
85
|
+
*
|
|
86
|
+
* `existingColumns` must be resolved the way the unqualified row queries
|
|
87
|
+
* resolve the table — on PostgreSQL, through the session `search_path`, not
|
|
88
|
+
* `current_schema()`. A search path whose first schema is not where the
|
|
89
|
+
* configured tables live is outside the supported contract: DDL is created in
|
|
90
|
+
* that first schema, so `ensureSchema` already writes shadow tables there.
|
|
91
|
+
*
|
|
92
|
+
* Scope: this checks that the columns EXIST. Stored types, nullability, and
|
|
93
|
+
* non-column storage internals (the tombstone bookkeeping) stay outside it,
|
|
94
|
+
* because a wrong type or nullability is a codec-compatibility question the
|
|
95
|
+
* persisted layouts answer for app columns.
|
|
96
|
+
*/
|
|
97
|
+
export declare function assertPhysicalColumns(table: CompiledTable, existingColumns: ReadonlySet<string>): void;
|
|
78
98
|
/** CREATE TABLE IF NOT EXISTS for one app table. */
|
|
79
99
|
export declare function createTableDdl(table: CompiledTable, dialect: RelationalDialect): string;
|
|
80
100
|
/**
|
package/dist/relational-rows.js
CHANGED
|
@@ -169,6 +169,35 @@ export function tableColumnNames(table) {
|
|
|
169
169
|
SYNC_COLUMN_VERSIONS_COLUMN,
|
|
170
170
|
];
|
|
171
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* The physical column set the row codec and the scope index read for one
|
|
174
|
+
* configured table. The persisted marker's layouts describe the codec's app
|
|
175
|
+
* columns only and its version number is the schema's, not the database's, so
|
|
176
|
+
* a same-version database that is missing a storage-internal meta column (a
|
|
177
|
+
* version-only bump whose ALTER never ran) still has to be refused: compare
|
|
178
|
+
* the physical table and fail closed without writing a migration.
|
|
179
|
+
*
|
|
180
|
+
* `existingColumns` must be resolved the way the unqualified row queries
|
|
181
|
+
* resolve the table — on PostgreSQL, through the session `search_path`, not
|
|
182
|
+
* `current_schema()`. A search path whose first schema is not where the
|
|
183
|
+
* configured tables live is outside the supported contract: DDL is created in
|
|
184
|
+
* that first schema, so `ensureSchema` already writes shadow tables there.
|
|
185
|
+
*
|
|
186
|
+
* Scope: this checks that the columns EXIST. Stored types, nullability, and
|
|
187
|
+
* non-column storage internals (the tombstone bookkeeping) stay outside it,
|
|
188
|
+
* because a wrong type or nullability is a codec-compatibility question the
|
|
189
|
+
* persisted layouts answer for app columns.
|
|
190
|
+
*/
|
|
191
|
+
export function assertPhysicalColumns(table, existingColumns) {
|
|
192
|
+
if (existingColumns.size === 0) {
|
|
193
|
+
throw new Error(`table ${JSON.stringify(table.name)} is missing from the database at the stored schema version — refusing to serve a database whose rows the running code cannot read`);
|
|
194
|
+
}
|
|
195
|
+
for (const name of tableColumnNames(table)) {
|
|
196
|
+
if (existingColumns.has(name))
|
|
197
|
+
continue;
|
|
198
|
+
throw new Error(`table ${JSON.stringify(table.name)} is missing column ${JSON.stringify(name)} at the stored schema version — refusing to serve a database whose rows the running code cannot read`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
172
201
|
/** CREATE TABLE IF NOT EXISTS for one app table. */
|
|
173
202
|
export function createTableDdl(table, dialect) {
|
|
174
203
|
const versionType = dialect === 'postgres' ? 'BIGINT' : 'INTEGER';
|
|
@@ -6,12 +6,19 @@
|
|
|
6
6
|
* Key layout (deterministic — every lookup is a GET/HEAD, never a LIST):
|
|
7
7
|
*
|
|
8
8
|
* {keyPrefix}seg/sha256/{hex}
|
|
9
|
-
* The segment bytes, verbatim (the object body MUST be
|
|
10
|
-
* content-addressed bytes so presigned GETs serve them
|
|
11
|
-
* the client's §5.1 hash check passes).
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* The segment bytes, verbatim and immutable (the object body MUST be
|
|
10
|
+
* exactly the content-addressed bytes so presigned GETs serve them
|
|
11
|
+
* directly and the client's §5.1 hash check passes).
|
|
12
|
+
*
|
|
13
|
+
* {keyPrefix}rec/sha256/{hex}.json
|
|
14
|
+
* The `SegmentRecord` (§5.1). It is the one MUTABLE object of the two:
|
|
15
|
+
* the scope-digest set is a union that every publication extends, so
|
|
16
|
+
* the merge is a read-modify-write. Keeping the record in the body (not
|
|
17
|
+
* in the bytes object's user metadata) is what makes that write
|
|
18
|
+
* conditional — a body ETag changes when the union changes, while the
|
|
19
|
+
* bytes ETag is a content hash and is identical for identical bytes. A
|
|
20
|
+
* `412` re-reads and merges again, so no concurrent publication can drop
|
|
21
|
+
* another one's digest. `get` therefore reads two objects.
|
|
15
22
|
*
|
|
16
23
|
* {keyPrefix}find/{sha256Hex(canonical reuse key)}.json
|
|
17
24
|
* Whole-table reuse pointer (§5.3): written only when
|
|
@@ -21,9 +28,14 @@
|
|
|
21
28
|
* confirm the segment object itself still exists — lifecycle GC may
|
|
22
29
|
* remove objects independently of pointers).
|
|
23
30
|
*
|
|
31
|
+
* Records written before the split kept the record in the bytes object's
|
|
32
|
+
* user metadata `x-amz-meta-syncular-record`; `get` still reads that shape
|
|
33
|
+
* and the next `put` materializes the record object from it, so an upgrade
|
|
34
|
+
* loses no cache entry.
|
|
35
|
+
*
|
|
24
36
|
* TTL mapping: expiry is **store-side and authoritative** — `expiresAtMs`
|
|
25
|
-
* (put-time + `ttlMs`, default 24 h) is recorded in the
|
|
26
|
-
*
|
|
37
|
+
* (put-time + `ttlMs`, default 24 h) is recorded in the record and the
|
|
38
|
+
* pointer; `get` returns expired records so §5.5 can answer
|
|
27
39
|
* `sync.segment_expired`, and `find` filters them out itself. S3 lifecycle
|
|
28
40
|
* expiration is garbage collection only: configure it comfortably ABOVE
|
|
29
41
|
* `ttlMs` (e.g. 2 days for the 24 h default) so clients normally see the
|