@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.
Files changed (80) hide show
  1. package/README.md +48 -5
  2. package/dist/admin.d.ts +1 -5
  3. package/dist/admin.js +3 -12
  4. package/dist/authoritative-query.d.ts +14 -6
  5. package/dist/authoritative-query.js +60 -82
  6. package/dist/blob-handlers.js +4 -1
  7. package/dist/context.d.ts +3 -1
  8. package/dist/context.js +4 -0
  9. package/dist/d1-storage.d.ts +8 -2
  10. package/dist/d1-storage.js +134 -26
  11. package/dist/errors.js +6 -0
  12. package/dist/events.d.ts +2 -1
  13. package/dist/frame-bytes.d.ts +2 -2
  14. package/dist/frame-bytes.js +33 -14
  15. package/dist/handler.js +58 -14
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/operations.d.ts +2 -0
  19. package/dist/operations.js +25 -5
  20. package/dist/postgres-storage.d.ts +9 -3
  21. package/dist/postgres-storage.js +110 -20
  22. package/dist/prune.d.ts +3 -1
  23. package/dist/prune.js +18 -14
  24. package/dist/pull.d.ts +1 -1
  25. package/dist/pull.js +74 -37
  26. package/dist/push.js +5 -5
  27. package/dist/realtime.d.ts +4 -1
  28. package/dist/realtime.js +33 -9
  29. package/dist/restore.d.ts +13 -0
  30. package/dist/restore.js +13 -0
  31. package/dist/s3-segment-store.js +10 -1
  32. package/dist/seed.js +36 -4
  33. package/dist/segment-download.js +5 -2
  34. package/dist/segment-store.d.ts +3 -0
  35. package/dist/segment-store.js +1 -0
  36. package/dist/sqlite-bun-driver.d.ts +2 -1
  37. package/dist/sqlite-bun-driver.js +5 -2
  38. package/dist/sqlite-bun.js +2 -2
  39. package/dist/sqlite-dialect.d.ts +1 -1
  40. package/dist/sqlite-dialect.js +7 -0
  41. package/dist/sqlite-image.d.ts +3 -3
  42. package/dist/sqlite-image.js +10 -6
  43. package/dist/sqlite-node.js +2 -2
  44. package/dist/sqlite-segment-store.js +14 -5
  45. package/dist/sqlite-storage.d.ts +8 -2
  46. package/dist/sqlite-storage.js +147 -36
  47. package/dist/storage-errors.d.ts +1 -1
  48. package/dist/storage-errors.js +3 -0
  49. package/dist/storage.d.ts +38 -10
  50. package/package.json +2 -2
  51. package/src/admin.ts +7 -15
  52. package/src/authoritative-query.ts +89 -94
  53. package/src/blob-handlers.ts +8 -1
  54. package/src/context.ts +16 -1
  55. package/src/d1-storage.ts +193 -29
  56. package/src/errors.ts +6 -0
  57. package/src/events.ts +2 -1
  58. package/src/frame-bytes.ts +40 -14
  59. package/src/handler.ts +102 -29
  60. package/src/index.ts +1 -0
  61. package/src/operations.ts +37 -4
  62. package/src/postgres-storage.ts +184 -38
  63. package/src/prune.ts +29 -15
  64. package/src/pull.ts +90 -41
  65. package/src/push.ts +5 -5
  66. package/src/realtime.ts +46 -7
  67. package/src/restore.ts +28 -0
  68. package/src/s3-segment-store.ts +10 -1
  69. package/src/seed.ts +46 -4
  70. package/src/segment-download.ts +11 -2
  71. package/src/segment-store.ts +4 -0
  72. package/src/sqlite-bun-driver.ts +6 -2
  73. package/src/sqlite-bun.ts +2 -2
  74. package/src/sqlite-dialect.ts +7 -0
  75. package/src/sqlite-image.ts +26 -15
  76. package/src/sqlite-node.ts +2 -2
  77. package/src/sqlite-segment-store.ts +18 -4
  78. package/src/sqlite-storage.ts +203 -39
  79. package/src/storage-errors.ts +10 -1
  80. package/src/storage.ts +57 -10
package/README.md CHANGED
@@ -15,6 +15,11 @@ registry, command mutations use the ordinary serialized push path, and
15
15
  specified in [`docs/REMOTE.md`](../../docs/REMOTE.md) and the practical setup is
16
16
  in the [remote operations guide](https://syncular.dev/guide-remote-operations/).
17
17
 
18
+ Remote query registration requires generated `relationPlans` for each selected
19
+ SQL statement. Run `syncular generate` before upgrading existing query modules.
20
+ The server uses those boundaries to bind every physical table occurrence to
21
+ the authenticated partition, including quoted self joins and CTE bodies.
22
+
18
23
  Application intent belongs in immutable domain event rows written in the same
19
24
  commit as the state change. `SyncularServerEvents` below remains operational
20
25
  telemetry. See the [domain event guide](https://syncular.dev/guide-domain-events/).
@@ -105,6 +110,12 @@ Log the cause for operators and stop startup. Do not catch readiness errors in
105
110
  authentication or convert them to a 401; request-time schema checks are only a
106
111
  defensive fallback.
107
112
 
113
+ After restoring an authoritative database, keep traffic stopped and call
114
+ `rotatePartitionLogEpoch({ storage, partition })` for every restored
115
+ partition. The rotation clears stale client cursors and requires version 2
116
+ clients to reset their server-derived rows while preserving the outbox. Follow
117
+ the complete [backup and restore runbook](https://syncular.dev/server-backup-restore/).
118
+
108
119
  ## Write validators and recovery metadata
109
120
 
110
121
  `validators` is the server-authoritative seam for row business rules that
@@ -507,7 +518,7 @@ by design:
507
518
  | `horizonStatus(partition)` | `{maxCommitSeq, horizonSeq, retainedCommits, activeCursorFloor, recommendedHorizonSeq, recommendation}` — the horizon a prune pass would reach now (§4.6) + a coarse `up-to-date` / `prune-recommended`. |
508
519
  | `segmentStats()` / `blobStats(partition)` / `stats(partition)` | Counts/bytes where the stores expose them (segments split rows/sqlite). `undefined` when a store omits `stats()`. |
509
520
  | `metrics(partition, {windowMs?, buckets?})` | Ring-derived request/push health over a trailing window (default 5 min): request count/rate, error share, p50/p95 duration, push applied/rejected/conflicted, and per-bucket counts for a sparkline. Zero new server state; all zeros when no ring is wired. |
510
- | `listPartitions()` / `partitionsOverview()` | Every partition the storage holds state for; the overview adds retained backlog, client counts (known/active), and the prune recommendation per partition — the fleet view. |
521
+ | `listPartitionRegistry()` / `partitionsOverview()` | Every authenticated partition; the registry carries log continuity and last-authenticated time, while the overview adds retained backlog, client counts, and the prune recommendation. |
511
522
  | `events({type?, sinceMs?, clientId?, actorId?, limit?})` | The ring tail, newest first. Empty when no ring is wired (`hasEventStream` reports which). |
512
523
  | `subscribeEvents(listener)` | Live events as they land in the ring; returns the unsubscribe function, `undefined` when no ring is wired. |
513
524
 
@@ -921,10 +932,22 @@ at or below it. Nothing prunes automatically — the host schedules it.
921
932
 
922
933
  **When to run.** A periodic job per partition — hourly to daily is the
923
934
  sensible range; there is no benefit below the granularity of your
924
- `activeWindowMs`. Prune is cheap when there is nothing to do (one cursor
925
- scan + two point reads), so err on the side of running it often rather
926
- than letting a backlog build. Pass `events` to get `prune.completed`
927
- per pass.
935
+ `activeWindowMs`. Pass `events` to get `prune.completed` per pass.
936
+
937
+ Pruning verifies the captured log epoch and updates the horizon together with
938
+ commit/change/scope deletion in one transaction. Concurrent passes cannot
939
+ lower the horizon. A retry cleans up eligible records even when the horizon
940
+ already covers them. A restore invalidates a pending pass with
941
+ `sync.storage.prune_epoch_mismatch`; recompute its retention inputs.
942
+ Unregistered partitions cannot be pruned. D1 maintenance enters the owning
943
+ Durable Object's existing write queue through
944
+ `SyncularRealtimeHost.pruneCommitLog`.
945
+
946
+ Custom storage adapters must add `getPartitionLogEpoch(partition)` and replace
947
+ `pruneCommitsThrough(partition, seq)` with
948
+ `pruneCommitsThrough(partition, { logEpoch, throughSeq })`, returning
949
+ `{ previousHorizonSeq, horizonSeq, removedCommits }` from the transaction.
950
+ `setHorizonSeq` remains monotonic and is no longer used by the pruning helper.
928
951
 
929
952
  **The retention floors (§4.6, encoded in `RetentionPolicy`).** The
930
953
  horizon never advances past `min(cursor)` of *active* clients — clients
@@ -1143,3 +1166,23 @@ deterministic in-process sqlite loopback):
1143
1166
  ```sh
1144
1167
  SYNCULAR_PG_URL=postgres://user:pass@localhost:5432/db bun run bench
1145
1168
  ```
1169
+
1170
+ Custom storage adapters must implement
1171
+ `getActiveClientCursorFloor(partition, cutoffMs)`. Return the minimum cursor
1172
+ whose `updatedAtMs >= cutoffMs`, or `null` when no client qualifies. Preserve
1173
+ negative bootstrap cursors. Pruning and admin horizon status use this scalar
1174
+ aggregate; `listClientCursors` remains the explicit listing interface.
1175
+
1176
+ SQLite image builders now return `Promise<Uint8Array>` and receive
1177
+ `rowBatches`, an iterable or async iterable of row arrays. Replace custom
1178
+ builders' `input.rows` loop with `for await (const rows of input.rowBatches)`,
1179
+ insert each batch into the dedicated image database, and count rows during
1180
+ consumption. Write the final row count into `_syncular_segment` before
1181
+ serialization. Await `buildSqliteImage(input)` when calling the built-in
1182
+ Bun or Node builder directly.
1183
+
1184
+ The server shares in-flight builds for the same storage pair and artifact
1185
+ identity after authorization. Sharing is local to one process. Signed URL
1186
+ grants remain per request. The first eligibility probe has at most
1187
+ `limitSnapshotRows + 1` rows; subsequent builder batches have at most 5,000
1188
+ rows. The image database and serialized output still consume memory.
package/dist/admin.d.ts CHANGED
@@ -223,11 +223,7 @@ export declare class SyncularAdmin {
223
223
  readonly windowMs?: number;
224
224
  readonly buckets?: number;
225
225
  }): AdminMetrics;
226
- /**
227
- * Every partition the storage knows (commit log + client records) — the
228
- * fleet-view backing and the console's partition picker. Fails loud when
229
- * the backend omits the optional `listPartitions`.
230
- */
226
+ /** Every authenticated partition in the storage-backed registry. */
231
227
  listPartitions(): Promise<string[]>;
232
228
  /**
233
229
  * The fleet view: one row per known partition — retained backlog, client
package/dist/admin.js CHANGED
@@ -173,11 +173,7 @@ export class SyncularAdmin {
173
173
  const nowMs = this.#clock();
174
174
  const maxCommitSeq = await this.#storage.getMaxCommitSeq(partition);
175
175
  const horizonSeq = await this.#storage.getHorizonSeq(partition);
176
- const cursors = await this.#storage.listClientCursors(partition);
177
- const activeCursors = cursors
178
- .filter((c) => c.updatedAtMs >= nowMs - this.#retention.activeWindowMs)
179
- .map((c) => c.cursor);
180
- const activeCursorFloor = activeCursors.length > 0 ? Math.min(...activeCursors) : null;
176
+ const activeCursorFloor = await this.#storage.getActiveClientCursorFloor(partition, nowMs - this.#retention.activeWindowMs);
181
177
  const cursorFloor = activeCursorFloor ?? Number.MAX_SAFE_INTEGER;
182
178
  const forcedSeq = await this.#storage.getCommitSeqBefore(partition, nowMs - this.#retention.ageForceMs);
183
179
  const retainFloor = maxCommitSeq - this.#retention.minRetainedCommits;
@@ -283,14 +279,9 @@ export class SyncularAdmin {
283
279
  buckets: { widthMs, counts, errors },
284
280
  };
285
281
  }
286
- /**
287
- * Every partition the storage knows (commit log + client records) — the
288
- * fleet-view backing and the console's partition picker. Fails loud when
289
- * the backend omits the optional `listPartitions`.
290
- */
282
+ /** Every authenticated partition in the storage-backed registry. */
291
283
  async listPartitions() {
292
- const list = required(this.#storage.listPartitions?.bind(this.#storage), 'storage');
293
- return list();
284
+ return (await this.#storage.listPartitionRegistry()).map((entry) => entry.partition);
294
285
  }
295
286
  /**
296
287
  * The fleet view: one row per known partition — retained backlog, client
@@ -9,12 +9,20 @@ export interface BoundAuthoritativeQuery {
9
9
  readonly params: readonly AuthoritativeQueryValue[];
10
10
  }
11
11
  declare const PARTITION_BIND: unique symbol;
12
- /**
13
- * Turn generated local SQL into a partition-local authoritative statement.
14
- * Only relations declared by the generated descriptor are rewritten. Values
15
- * remain parameters; request data is never interpolated into SQL.
16
- */
17
- export declare function prepareAuthoritativeQuery(sql: string, params: readonly AuthoritativeQueryValue[], declaredTables: readonly string[], tables: ReadonlyMap<string, CompiledTable>): PreparedAuthoritativeQuery;
12
+ /** Compiler-proven occurrences in the exact generated positional statement. */
13
+ export interface AuthoritativeRelationPlan {
14
+ readonly sql: string;
15
+ readonly relations: readonly {
16
+ readonly table: string;
17
+ readonly start: number;
18
+ readonly end: number;
19
+ readonly alias?: string;
20
+ }[];
21
+ }
22
+ /** Validate trusted generated metadata before registration or storage execution. */
23
+ export declare function validateAuthoritativeRelationPlan(plan: AuthoritativeRelationPlan, declaredTables: readonly string[]): void;
24
+ /** Bind every compiler-proven table occurrence to the authenticated partition. */
25
+ export declare function prepareAuthoritativeQuery(plan: AuthoritativeRelationPlan, params: readonly AuthoritativeQueryValue[], declaredTables: readonly string[], tables: ReadonlyMap<string, CompiledTable>): PreparedAuthoritativeQuery;
18
26
  export declare function bindAuthoritativePartition(prepared: PreparedAuthoritativeQuery, partition: string): BoundAuthoritativeQuery;
19
27
  export declare function postgresPlaceholders(sql: string): string;
20
28
  export {};
@@ -1,24 +1,5 @@
1
1
  import { quoteIdent, SYNC_PARTITION_COLUMN } from './relational-rows.js';
2
2
  const PARTITION_BIND = Symbol('syncular.authoritative_partition');
3
- const RESERVED_ALIAS = new Set([
4
- 'on',
5
- 'where',
6
- 'group',
7
- 'order',
8
- 'inner',
9
- 'left',
10
- 'right',
11
- 'full',
12
- 'outer',
13
- 'natural',
14
- 'join',
15
- 'cross',
16
- 'using',
17
- 'limit',
18
- 'having',
19
- ]);
20
- const IDENT = '[A-Za-z_][A-Za-z0-9_]*';
21
- const TABLE_REF_RE = new RegExp(`\\b(FROM|(?:NATURAL\\s+)?(?:(?:LEFT|RIGHT|FULL)(?:\\s+OUTER)?|INNER|CROSS)?\\s*JOIN)\\s+((?:\\(\\s*)*)(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${[...RESERVED_ALIAS].join('|')})\\b)${IDENT}))?`, 'gi');
22
3
  function protectedSqlEnd(sql, index) {
23
4
  const char = sql[index];
24
5
  const next = sql[index + 1];
@@ -48,97 +29,94 @@ function protectedSqlEnd(sql, index) {
48
29
  }
49
30
  return undefined;
50
31
  }
51
- function maskedSql(sql) {
52
- let out = '';
53
- let index = 0;
54
- while (index < sql.length) {
55
- const end = protectedSqlEnd(sql, index);
56
- if (end === undefined)
57
- out += sql[index];
58
- else
59
- out += sql.slice(index, end).replace(/[^\n]/g, ' ');
60
- index = end ?? index + 1;
32
+ /** Validate trusted generated metadata before registration or storage execution. */
33
+ export function validateAuthoritativeRelationPlan(plan, declaredTables) {
34
+ if (plan === undefined ||
35
+ typeof plan.sql !== 'string' ||
36
+ !Array.isArray(plan.relations)) {
37
+ throw new Error('registered query requires generated relation plans; regenerate queries');
61
38
  }
62
- return out;
63
- }
64
- /**
65
- * Turn generated local SQL into a partition-local authoritative statement.
66
- * Only relations declared by the generated descriptor are rewritten. Values
67
- * remain parameters; request data is never interpolated into SQL.
68
- */
69
- export function prepareAuthoritativeQuery(sql, params, declaredTables, tables) {
70
- if (maskedSql(sql).includes(';'))
71
- throw new Error('registered query must be one SELECT');
39
+ const sql = plan.sql;
72
40
  const declared = new Set(declaredTables);
73
- const masked = maskedSql(sql);
74
- const replacements = [];
75
41
  const found = new Set();
76
- for (const match of masked.matchAll(TABLE_REF_RE)) {
77
- const rawTable = match[3];
78
- const table = tables.get(rawTable);
79
- if (table === undefined)
80
- continue;
81
- if (!declared.has(table.name)) {
82
- throw new Error('registered query table metadata does not match its SQL');
42
+ let previousEnd = 0;
43
+ for (const relation of plan.relations) {
44
+ if (!Number.isSafeInteger(relation.start) ||
45
+ !Number.isSafeInteger(relation.end) ||
46
+ relation.start < previousEnd ||
47
+ relation.end <= relation.start ||
48
+ relation.end > sql.length) {
49
+ throw new Error('registered query relation boundaries do not match its SQL; regenerate queries');
83
50
  }
84
- if (!table.materialize) {
85
- throw new Error('registered query targets a non-materialized table');
51
+ previousEnd = relation.end;
52
+ if (!declared.has(relation.table)) {
53
+ throw new Error('registered query table metadata does not match its SQL');
86
54
  }
87
- let alias = match[4];
88
- if (alias !== undefined && RESERVED_ALIAS.has(alias.toLowerCase())) {
89
- alias = undefined;
55
+ const spelling = sql.slice(relation.start, relation.end);
56
+ const quote = spelling[0];
57
+ const name = quote === '['
58
+ ? spelling.slice(1, -1)
59
+ : quote === '"' || quote === '`'
60
+ ? spelling
61
+ .slice(1, -1)
62
+ .split(quote + quote)
63
+ .join(quote)
64
+ : spelling;
65
+ if (name.toLowerCase() !== relation.table.toLowerCase()) {
66
+ throw new Error('registered query relation name does not match its SQL; regenerate queries');
90
67
  }
91
- const matchStart = match.index ?? 0;
92
- const afterOperator = match[1].length;
93
- const relative = match[0]
94
- .toLowerCase()
95
- .indexOf(rawTable.toLowerCase(), afterOperator);
96
- const start = matchStart + relative;
97
- const projection = table.columns.map((column) => quoteIdent(column.name));
98
- replacements.push({
99
- start,
100
- end: start + rawTable.length,
101
- text: `(SELECT ${projection.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=/*syncular_partition*/?)${alias === undefined ? ` AS ${quoteIdent(table.name)}` : ''}`,
102
- });
103
- found.add(table.name);
68
+ found.add(relation.table);
104
69
  }
105
70
  if (found.size !== declared.size ||
106
71
  [...declared].some((table) => !found.has(table))) {
107
72
  throw new Error('registered query table metadata does not match its SQL');
108
73
  }
109
- let rewritten = sql;
110
- for (const replacement of replacements.sort((left, right) => right.start - left.start)) {
111
- rewritten =
112
- rewritten.slice(0, replacement.start) +
113
- replacement.text +
114
- rewritten.slice(replacement.end);
115
- }
74
+ }
75
+ /** Bind every compiler-proven table occurrence to the authenticated partition. */
76
+ export function prepareAuthoritativeQuery(plan, params, declaredTables, tables) {
77
+ validateAuthoritativeRelationPlan(plan, declaredTables);
78
+ const sql = plan.sql;
79
+ const replacements = plan.relations.map((relation) => {
80
+ const table = tables.get(relation.table);
81
+ if (table === undefined)
82
+ throw new Error('registered query targets an unknown table');
83
+ if (!table.materialize)
84
+ throw new Error('registered query targets a non-materialized table');
85
+ return {
86
+ start: relation.start,
87
+ end: relation.end,
88
+ 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)}` : ''}`,
89
+ };
90
+ });
116
91
  const bound = [];
117
92
  let anonymousIndex = 0;
118
93
  let rendered = '';
119
- for (let index = 0; index < rewritten.length; index += 1) {
120
- if (rewritten.startsWith('/*syncular_partition*/?', index)) {
121
- rendered += '?';
94
+ let nextRelation = 0;
95
+ for (let index = 0; index < sql.length; index += 1) {
96
+ const replacement = replacements[nextRelation];
97
+ if (replacement?.start === index) {
98
+ rendered += replacement.text;
122
99
  bound.push(PARTITION_BIND);
123
- index += '/*syncular_partition*/?'.length - 1;
100
+ index = replacement.end - 1;
101
+ nextRelation += 1;
124
102
  continue;
125
103
  }
126
- const protectedEnd = protectedSqlEnd(rewritten, index);
104
+ const protectedEnd = protectedSqlEnd(sql, index);
127
105
  if (protectedEnd !== undefined) {
128
- rendered += rewritten.slice(index, protectedEnd);
106
+ rendered += sql.slice(index, protectedEnd);
129
107
  index = protectedEnd - 1;
130
108
  continue;
131
109
  }
132
- const char = rewritten[index];
110
+ const char = sql[index];
133
111
  if (char !== '?') {
134
112
  rendered += char;
135
113
  continue;
136
114
  }
137
115
  let end = index + 1;
138
- while (end < rewritten.length && /[0-9]/.test(rewritten[end])) {
116
+ while (end < sql.length && /[0-9]/.test(sql[end])) {
139
117
  end += 1;
140
118
  }
141
- const numbered = rewritten.slice(index + 1, end);
119
+ const numbered = sql.slice(index + 1, end);
142
120
  const parameterIndex = numbered.length > 0
143
121
  ? Number.parseInt(numbered, 10) - 1
144
122
  : anonymousIndex++;
@@ -7,7 +7,7 @@
7
7
  * adapters map these onto `PUT/GET <mount>/blobs/{blobId}`.
8
8
  */
9
9
  import { blobIdFor, isBlobId } from './blob-store.js';
10
- import { clockOf, RESOLVER_OUTAGE } from './context.js';
10
+ import { clockOf, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
11
11
  import { SyncError, syncError } from './errors.js';
12
12
  import { emitEvent } from './events.js';
13
13
  import { compileSchema } from './schema.js';
@@ -16,6 +16,7 @@ import { issueBlobUploadUrl, issueBlobUrl } from './signed-url.js';
16
16
  const DEFAULT_MAX_BLOB_BYTES = 64 * 1024 * 1024;
17
17
  /** `PUT <mount>/blobs/{blobId}` (§5.9.3). Host auth is the adapter's job. */
18
18
  export async function handleBlobUpload(ctx, request) {
19
+ await touchAuthenticatedPartition(ctx);
19
20
  const store = ctx.blobs;
20
21
  if (store === undefined) {
21
22
  throw syncError('blob.not_found', 'this server has no blob store (§5.9)');
@@ -50,6 +51,7 @@ export async function handleBlobUpload(ctx, request) {
50
51
  * reference index passes the §3.4 scope check for the actor.
51
52
  */
52
53
  export async function handleBlobDownload(ctx, blobId) {
54
+ await touchAuthenticatedPartition(ctx);
53
55
  const events = ctx.events;
54
56
  if (events === undefined)
55
57
  return downloadBlob(ctx, blobId);
@@ -213,6 +215,7 @@ async function downloadBlob(ctx, blobId) {
213
215
  * result when no presigned-upload store is configured (client streams direct).
214
216
  */
215
217
  export async function handleBlobUploadGrant(ctx, request) {
218
+ await touchAuthenticatedPartition(ctx);
216
219
  const store = ctx.blobs;
217
220
  if (store === undefined) {
218
221
  throw syncError('blob.not_found', 'this server has no blob store (§5.9)');
package/dist/context.d.ts CHANGED
@@ -14,7 +14,7 @@ import type { ServerSchema } from './schema.js';
14
14
  import type { SegmentStore } from './segment-store.js';
15
15
  import type { BlobPresignConfig, BlobUploadPresignConfig, SegmentUrlConfig } from './signed-url.js';
16
16
  import type { SqliteImageBuilder } from './sqlite-image.js';
17
- import type { ServerStorage, StoredCommit } from './storage.js';
17
+ import type { PartitionRegistryEntry, ServerStorage, StoredCommit } from './storage.js';
18
18
  import type { CommitValidator, ValidatorRegistry } from './validate.js';
19
19
  /** SSP2 body content type (§1.1). */
20
20
  export declare const SSP2_CONTENT_TYPE = "application/vnd.syncular.sync.v2";
@@ -166,3 +166,5 @@ export interface SyncRequestContext extends SyncServerConfig {
166
166
  }
167
167
  export declare function clockOf(ctx: SyncServerConfig): () => number;
168
168
  export declare function limitsOf(ctx: SyncServerConfig): ServerLimits;
169
+ /** Refresh the registry after host authentication and return log continuity. */
170
+ export declare function touchAuthenticatedPartition(ctx: SyncRequestContext): Promise<PartitionRegistryEntry>;
package/dist/context.js CHANGED
@@ -21,3 +21,7 @@ export function clockOf(ctx) {
21
21
  export function limitsOf(ctx) {
22
22
  return { ...DEFAULT_LIMITS, ...ctx.limits };
23
23
  }
24
+ /** Refresh the registry after host authentication and return log continuity. */
25
+ export function touchAuthenticatedPartition(ctx) {
26
+ return ctx.storage.touchPartition(ctx.partition, clockOf(ctx)(), crypto.randomUUID());
27
+ }
@@ -1,5 +1,6 @@
1
+ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
1
2
  import type { CompiledSchema, CompiledTable } from './schema.js';
2
- import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
3
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
3
4
  export interface D1PreparedStatement {
4
5
  bind(...values: unknown[]): D1PreparedStatement;
5
6
  first<T = Record<string, unknown>>(): Promise<T | null>;
@@ -36,12 +37,16 @@ export declare class D1ServerStorage implements ServerStorage {
36
37
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
37
38
  table(name: string): CompiledTable;
38
39
  ensureSchema(schema: CompiledSchema): Promise<void>;
40
+ touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
41
+ rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
42
+ listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
39
43
  begin(partition: string): Promise<StorageTransaction>;
40
44
  getMaxCommitSeq(partition: string): Promise<number>;
41
45
  queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
46
+ getPartitionLogEpoch(partition: string): Promise<string | undefined>;
42
47
  getHorizonSeq(partition: string): Promise<number>;
43
48
  setHorizonSeq(partition: string, seq: number): Promise<void>;
44
- pruneCommitsThrough(partition: string, seq: number): Promise<number>;
49
+ pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
45
50
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
46
51
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
47
52
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
@@ -58,6 +63,7 @@ export declare class D1ServerStorage implements ServerStorage {
58
63
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
59
64
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
60
65
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
66
+ getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
61
67
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
62
68
  listRowsReferencingBlob(partition: string, blobId: string): Promise<{
63
69
  readonly table: string;