@syncular/server 0.15.44 → 0.15.46

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 (93) hide show
  1. package/README.md +135 -5
  2. package/dist/admin.d.ts +12 -6
  3. package/dist/admin.js +11 -1
  4. package/dist/authoritative-query.d.ts +20 -0
  5. package/dist/authoritative-query.js +184 -0
  6. package/dist/blob-store.d.ts +2 -2
  7. package/dist/context.d.ts +11 -2
  8. package/dist/context.js +2 -0
  9. package/dist/d1-storage.d.ts +10 -1
  10. package/dist/d1-storage.js +219 -3
  11. package/dist/errors.d.ts +1 -1
  12. package/dist/errors.js +43 -1
  13. package/dist/events-ring.d.ts +1 -1
  14. package/dist/events-ring.js +1 -1
  15. package/dist/events.d.ts +52 -3
  16. package/dist/handler.js +6 -3
  17. package/dist/index.d.ts +5 -1
  18. package/dist/index.js +5 -1
  19. package/dist/operations-realtime.d.ts +16 -0
  20. package/dist/operations-realtime.js +196 -0
  21. package/dist/operations.d.ts +97 -0
  22. package/dist/operations.js +392 -0
  23. package/dist/pg-executor.d.ts +1 -1
  24. package/dist/pg-executor.js +1 -1
  25. package/dist/postgres-fanout.d.ts +1 -1
  26. package/dist/postgres-storage.d.ts +11 -2
  27. package/dist/postgres-storage.js +223 -3
  28. package/dist/pull.js +1 -1
  29. package/dist/push.d.ts +8 -2
  30. package/dist/push.js +75 -21
  31. package/dist/reactions.d.ts +167 -0
  32. package/dist/reactions.js +442 -0
  33. package/dist/realtime.js +4 -1
  34. package/dist/relational-rows.d.ts +9 -9
  35. package/dist/relational-rows.js +9 -9
  36. package/dist/s3-blob-store.js +1 -1
  37. package/dist/s3-segment-store.js +1 -1
  38. package/dist/schema.d.ts +4 -5
  39. package/dist/schema.js +3 -3
  40. package/dist/seed.js +1 -1
  41. package/dist/segment-store.d.ts +3 -3
  42. package/dist/signed-url.js +2 -2
  43. package/dist/sigv4.d.ts +2 -4
  44. package/dist/sigv4.js +3 -3
  45. package/dist/sqlite-blob-store.d.ts +1 -1
  46. package/dist/sqlite-blob-store.js +1 -1
  47. package/dist/sqlite-dialect.d.ts +3 -4
  48. package/dist/sqlite-dialect.js +21 -1
  49. package/dist/sqlite-image.d.ts +1 -1
  50. package/dist/sqlite-lease-store.d.ts +1 -1
  51. package/dist/sqlite-lease-store.js +1 -1
  52. package/dist/sqlite-segment-store.d.ts +2 -2
  53. package/dist/sqlite-segment-store.js +2 -2
  54. package/dist/sqlite-storage.d.ts +11 -2
  55. package/dist/sqlite-storage.js +219 -4
  56. package/dist/storage.d.ts +112 -3
  57. package/dist/validate.js +1 -0
  58. package/package.json +2 -2
  59. package/src/admin.ts +30 -6
  60. package/src/authoritative-query.ts +218 -0
  61. package/src/blob-store.ts +0 -0
  62. package/src/context.ts +12 -2
  63. package/src/d1-storage.ts +355 -3
  64. package/src/errors.ts +43 -1
  65. package/src/events-ring.ts +1 -1
  66. package/src/events.ts +64 -2
  67. package/src/handler.ts +15 -3
  68. package/src/index.ts +33 -1
  69. package/src/operations-realtime.ts +272 -0
  70. package/src/operations.ts +720 -0
  71. package/src/pg-executor.ts +1 -1
  72. package/src/postgres-fanout.ts +1 -1
  73. package/src/postgres-storage.ts +355 -4
  74. package/src/pull.ts +1 -1
  75. package/src/push.ts +97 -29
  76. package/src/reactions.ts +741 -0
  77. package/src/realtime.ts +7 -1
  78. package/src/relational-rows.ts +9 -9
  79. package/src/s3-blob-store.ts +1 -1
  80. package/src/s3-segment-store.ts +1 -1
  81. package/src/schema.ts +6 -7
  82. package/src/seed.ts +1 -1
  83. package/src/segment-store.ts +3 -3
  84. package/src/signed-url.ts +2 -2
  85. package/src/sigv4.ts +3 -5
  86. package/src/sqlite-blob-store.ts +1 -1
  87. package/src/sqlite-dialect.ts +22 -3
  88. package/src/sqlite-image.ts +1 -1
  89. package/src/sqlite-lease-store.ts +1 -1
  90. package/src/sqlite-segment-store.ts +2 -2
  91. package/src/sqlite-storage.ts +369 -4
  92. package/src/storage.ts +168 -3
  93. package/src/validate.ts +1 -0
package/src/realtime.ts CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  type WakeReason,
32
32
  } from '@syncular/core';
33
33
  import type { SyncRequestContext, SyncServerConfig } from './context';
34
- import { RESOLVER_OUTAGE } from './context';
34
+ import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE } from './context';
35
35
  import { SyncError, syncError } from './errors';
36
36
  import { emitEvent, type SyncularServerEvents } from './events';
37
37
  import { createSyncResponseStream } from './handler';
@@ -963,6 +963,12 @@ export class RealtimeHub {
963
963
  async connect(options: RealtimeConnectOptions): Promise<RealtimeSession> {
964
964
  const { storage } = this.#config;
965
965
  const clock = this.#config.clock ?? Date.now;
966
+ if (options.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
967
+ throw syncError(
968
+ 'sync.invalid_client_id',
969
+ 'clientId uses a reserved server-command namespace (§1.5)',
970
+ );
971
+ }
966
972
  const record = await storage.getClientRecord(
967
973
  options.partition,
968
974
  options.clientId,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Relational current-row storage (DESIGN-relational-server-storage.md).
2
+ * Relational current-row storage.
3
3
  *
4
4
  * Every synced table is a REAL table in the server database — the app's
5
5
  * columns with proper type affinities, queryable with plain SQL/joins/BI —
@@ -65,7 +65,7 @@ export function quoteIdent(name: string): string {
65
65
  return `"${name.replaceAll('"', '""')}"`;
66
66
  }
67
67
 
68
- /** §5.3-style type affinities, per dialect (DESIGN type-mapping table). */
68
+ /** §5.3-style type affinities, per dialect. */
69
69
  export function columnSqlType(
70
70
  column: RowColumn,
71
71
  dialect: RelationalDialect,
@@ -204,11 +204,11 @@ export function physicalIndexName(declaredName: string): string {
204
204
  }
205
205
 
206
206
  /**
207
- * CREATE INDEX IF NOT EXISTS for the table's user-declared indexes
208
- * (DESIGN "user indexes" — the same declared names/columns the client
209
- * materializes; cross-table index-name uniqueness is the user's schema
210
- * concern, exactly as it is client-side). Server-side the physical name
211
- * carries the {@link SYNC_INDEX_PREFIX} ownership marker.
207
+ * CREATE INDEX IF NOT EXISTS for the table's user-declared indexes. These use
208
+ * the same declared names and columns the client materializes. Cross-table
209
+ * index-name uniqueness is the user's schema concern, as it is client-side.
210
+ * Server-side the physical name carries the {@link SYNC_INDEX_PREFIX}
211
+ * ownership marker.
212
212
  */
213
213
  export function createIndexDdl(table: CompiledTable): string[] {
214
214
  // User indexes name app columns — nothing to index without the projection.
@@ -478,8 +478,8 @@ export function deleteRowSql(
478
478
  }
479
479
 
480
480
  /**
481
- * The schema-version marker table gating DDL work (DESIGN "server-side
482
- * schema migration"): `ensureSchema` compares the stored version and skips
481
+ * The schema-version marker table gates DDL work. `ensureSchema` compares the
482
+ * stored version and skips
483
483
  * all introspection/DDL when it matches — one cheap read per storage
484
484
  * instance (relevant for D1's per-request instantiation).
485
485
  *
@@ -98,7 +98,7 @@ function parseStatsJson(json: string): StatsAccumulator {
98
98
  };
99
99
  }
100
100
 
101
- // Runtime-neutral base64url (TODO §4.2): `Buffer` is not present on
101
+ // Runtime-neutral base64url: `Buffer` is not present on
102
102
  // Cloudflare Workers without `nodejs_compat`, so the metadata header is
103
103
  // (de)coded with `btoa`/`atob`, available in every runtime.
104
104
  function utf8ToBase64url(text: string): string {
@@ -112,7 +112,7 @@ function parseStatsJson(json: string): StatsAccumulator {
112
112
  };
113
113
  }
114
114
 
115
- // Runtime-neutral base64url (TODO §4.2): `Buffer` is not present on
115
+ // Runtime-neutral base64url: `Buffer` is not present on
116
116
  // Cloudflare Workers without `nodejs_compat`, so the object-metadata record
117
117
  // header is (de)coded with `btoa`/`atob`, available in every runtime.
118
118
  function utf8ToBase64url(text: string): string {
package/src/schema.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The server is configured with a schema IR: tables, columns with the six
5
5
  * §2.4 column types, scope patterns per §3.1, and a schema version. Codegen
6
- * (B5) will emit this shape later; tests hand-write it.
6
+ * Typegen emits this shape; tests hand-write it.
7
7
  */
8
8
  import {
9
9
  type RowColumn,
@@ -14,9 +14,8 @@ import {
14
14
  export type ScopePatternSpec = string | { pattern: string; column: string };
15
15
 
16
16
  /**
17
- * A user-declared index (the migration subset's CREATE INDEX). With
18
- * relational server storage these apply server-side too (DESIGN
19
- * "user indexes") — the same declaration the client materializes.
17
+ * A user-declared index (the migration subset's CREATE INDEX). Relational
18
+ * server storage applies the same declaration the client materializes.
20
19
  */
21
20
  export interface IndexSchema {
22
21
  readonly name: string;
@@ -35,7 +34,7 @@ export interface TableSchema {
35
34
  /** User indexes (optional) — created on the server's relational tables. */
36
35
  readonly indexes?: readonly IndexSchema[];
37
36
  /**
38
- * Server-side column materialization (DESIGN-relational-server-storage.md
37
+ * Server-side column materialization
39
38
  * "optional materialization"). When `true` (the usual default) the server's
40
39
  * row table carries the app's typed columns as a queryable projection;
41
40
  * when `false` it carries only the `_sync_*` meta columns — same storage
@@ -142,8 +141,8 @@ export function compileSchema(schema: ServerSchema): CompiledSchema {
142
141
  if (tables.has(table.name)) {
143
142
  throw new Error(`duplicate table ${JSON.stringify(table.name)}`);
144
143
  }
145
- // Identifier rules (DESIGN "current-row tables") live in core's
146
- // validatePortableRelationalIdentifier, shared with typegen: app tables
144
+ // Identifier rules live in core's validatePortableRelationalIdentifier,
145
+ // shared with typegen: app tables
147
146
  // share a namespace with the sync infrastructure tables (`sync_*`) and
148
147
  // carry `_sync_*` meta columns, so both prefixes are reserved, and
149
148
  // Postgres silently truncates identifiers over 63 bytes.
package/src/seed.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The public seeding helper (RFC 0002 §2.5): push app-shaped mutations
2
+ * The public seeding helper: push app-shaped mutations
3
3
  * through the REAL push path — one `handleSyncRequest` round built from the
4
4
  * server schema — so demos, dev servers, and ops scripts seed data with one
5
5
  * supported call. This is the same §6 pipeline every client write takes
@@ -44,8 +44,8 @@ export interface SegmentFindKey {
44
44
  }
45
45
 
46
46
  /**
47
- * Coarse store-level counters for the admin/console read surface (TODO
48
- * §2.5). Optional — a store that omits `stats()` simply cannot report them.
47
+ * Coarse store-level counters for the admin/console read surface. Optional:
48
+ * a store that omits `stats()` simply cannot report them.
49
49
  * All counts include expired-but-not-yet-evicted entries (the store's own
50
50
  * bytes on disk), split by media type.
51
51
  */
@@ -80,7 +80,7 @@ export interface SegmentStore {
80
80
  * rebuilding sqlite images while one exists (§5.3).
81
81
  */
82
82
  find(key: SegmentFindKey, nowMs: number): Promise<SegmentRecord | undefined>;
83
- /** Admin/console counters (TODO §2.5) — ADDITIVE, optional. */
83
+ /** Admin/console counters: ADDITIVE, optional. */
84
84
  stats?(): Promise<SegmentStoreStats>;
85
85
  }
86
86
 
package/src/signed-url.ts CHANGED
@@ -198,8 +198,8 @@ export interface SegmentTokenClaims {
198
198
 
199
199
  const encoder = new TextEncoder();
200
200
 
201
- // Runtime-neutral base64url (no `Buffer` Workers/Deno/browser safe; TODO
202
- // §4.2): `btoa`/`atob` operate on binary strings, present in every runtime.
201
+ // Runtime-neutral base64url: `btoa`/`atob` operate on binary strings and are
202
+ // present in every supported runtime.
203
203
  function base64url(bytes: Uint8Array): string {
204
204
  let binary = '';
205
205
  for (const byte of bytes) binary += String.fromCharCode(byte);
package/src/sigv4.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Minimal AWS Signature Version 4 — exactly the subset the S3 segment
3
3
  * store needs (header-signed requests and presigned GET URLs), hand-rolled
4
- * per the dependency-light doctrine (REVISE): SigV4 is small and
4
+ * per the dependency-light doctrine: SigV4 is small and
5
5
  * well-specified, so no SDK.
6
6
  *
7
- * Runtime-neutral (TODO §4.2): the crypto primitives are Web Crypto
7
+ * Runtime-neutral: the crypto primitives are Web Crypto
8
8
  * (`crypto.subtle`), not `node:crypto`, so this module runs unchanged on
9
9
  * Cloudflare Workers / Deno / browsers as well as Bun/Node. `crypto.subtle`
10
10
  * is async, so signing is async throughout; the `S3SegmentStore` consumers
@@ -94,9 +94,7 @@ export function amzTimestamps(nowMs: number): {
94
94
  }
95
95
 
96
96
  /** Sorted, fully re-encoded canonical query string from *decoded* pairs. */
97
- export function canonicalQuery(
98
- params: Iterable<readonly [string, string]>,
99
- ): string {
97
+ function canonicalQuery(params: Iterable<readonly [string, string]>): string {
100
98
  return [...params]
101
99
  .map(([k, v]) => [uriEncode(k, true), uriEncode(v, true)] as const)
102
100
  .sort(([ak, av], [bk, bv]) =>
@@ -3,7 +3,7 @@
3
3
  * dependency-free). Bun-specific by design (top-level `bun:sqlite` import),
4
4
  * so it lives in its own module — the runtime-neutral `BlobStore` interface,
5
5
  * `MemoryBlobStore`, `blobIdFor`, and `isBlobId` stay in `blob-store.ts` for
6
- * the Workers/edge core (TODO §4.2 neutrality discipline; enforced by
6
+ * the Workers/edge core (runtime neutrality is enforced by
7
7
  * `test/runtime-neutrality.test.ts`).
8
8
  */
9
9
  import { Database } from 'bun:sqlite';
@@ -11,8 +11,7 @@
11
11
  * db.prepare(sql).bind(...).all()`); a shared execution layer would have to
12
12
  * pick one calling convention and adapt the other, which is uglier than two
13
13
  * thin storage classes that each speak their driver's native shape while
14
- * importing the same SQL text and codecs from here (TODO §4.2 judgment
15
- * call — "clean parallel implementation against the storage contract").
14
+ * importing the same SQL text and codecs from here.
16
15
  * Both classes run the identical `test/storage-contract.ts`, so the
17
16
  * behavior is held key-for-key regardless.
18
17
  */
@@ -35,7 +34,7 @@ import type {
35
34
  * The inverted scope index carries the ordered column (`commit_seq` /
36
35
  * `row_id`) last in the PRIMARY KEY, so the candidate scan is an index
37
36
  * range that returns already-ordered rows — the same covering-index shape
38
- * the Postgres storage documents (§3.1, REVISE B2 performance-by-
37
+ * the Postgres storage documents (§3.1, performance-by-
39
38
  * construction).
40
39
  */
41
40
  export const SQLITE_DDL = `
@@ -75,6 +74,26 @@ CREATE TABLE IF NOT EXISTS sync_push_results(
75
74
  client_commit_id TEXT NOT NULL, result TEXT NOT NULL,
76
75
  PRIMARY KEY(partition, client_id, client_commit_id)
77
76
  );
77
+ CREATE TABLE IF NOT EXISTS sync_reactions(
78
+ partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,
79
+ type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,
80
+ source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,
81
+ source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,
82
+ available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,
83
+ attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,
84
+ lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,
85
+ last_failure TEXT,
86
+ PRIMARY KEY(partition, idempotency_key),
87
+ CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))
88
+ );
89
+ CREATE INDEX IF NOT EXISTS sync_reactions_due
90
+ ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);
91
+ CREATE INDEX IF NOT EXISTS sync_reactions_lease
92
+ ON sync_reactions(partition, status, lease_expires_at_ms);
93
+ CREATE INDEX IF NOT EXISTS sync_reactions_completed
94
+ ON sync_reactions(partition, status, completed_at_ms, idempotency_key);
95
+ CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
96
+ ON sync_reactions(partition, status, available_at_ms, idempotency_key);
78
97
  CREATE TABLE IF NOT EXISTS sync_clients(
79
98
  partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
80
99
  cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,
@@ -62,7 +62,7 @@ export interface SqliteImageInput {
62
62
 
63
63
  /**
64
64
  * The §5.3 image-builder capability, injected through
65
- * `SyncServerConfig.sqliteImageBuilder` (TODO §4.2). Building an image needs
65
+ * `SyncServerConfig.sqliteImageBuilder`. Building an image needs
66
66
  * a real SQLite engine (`bun:sqlite` here), which is not available on every
67
67
  * runtime — Cloudflare Workers has none. So the core takes the builder as an
68
68
  * optional capability rather than importing `bun:sqlite` on the pull path:
@@ -3,7 +3,7 @@
3
3
  * dependency-free). Bun-specific by design (top-level `bun:sqlite` import),
4
4
  * so it lives in its own module — the runtime-neutral `LeaseStore` interface,
5
5
  * `LeaseRecord`, and `MemoryLeaseStore` stay in `lease-store.ts` for the
6
- * Workers/edge core (TODO §4.2 neutrality discipline; enforced by
6
+ * Workers/edge core (runtime neutrality is enforced by
7
7
  * `test/runtime-neutrality.test.ts`).
8
8
  */
9
9
  import { Database } from 'bun:sqlite';
@@ -4,8 +4,8 @@
4
4
  * top level, so it lives in its own module — importing it opts into the Bun
5
5
  * runtime. The runtime-neutral `SegmentStore` interface, `MemorySegmentStore`,
6
6
  * and `segmentIdFor` stay in `segment-store.ts` so the Workers/edge core can
7
- * import them without pulling in `bun:sqlite` (TODO §4.2 neutrality
8
- * discipline; enforced by `test/runtime-neutrality.test.ts`).
7
+ * import them without pulling in `bun:sqlite` (runtime neutrality is enforced
8
+ * by `test/runtime-neutrality.test.ts`).
9
9
  */
10
10
  import { Database } from 'bun:sqlite';
11
11
  import {