@syncular/server 0.1.3 → 0.2.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.
- package/README.md +773 -13
- package/dist/admin.d.ts +136 -0
- package/dist/admin.js +168 -0
- package/dist/blob-handlers.d.ts +69 -0
- package/dist/blob-handlers.js +245 -0
- package/dist/blob-store.d.ts +111 -0
- package/dist/blob-store.js +0 -0
- package/dist/content-encoding.d.ts +27 -0
- package/dist/content-encoding.js +69 -0
- package/dist/context.d.ts +151 -0
- package/dist/context.js +21 -0
- package/dist/crdt-merger.d.ts +28 -0
- package/dist/crdt-merger.js +11 -0
- package/dist/d1-storage.d.ts +50 -0
- package/dist/d1-storage.js +0 -0
- package/dist/errors.d.ts +35 -9
- package/dist/errors.js +220 -11
- package/dist/events-ring.d.ts +55 -0
- package/dist/events-ring.js +96 -0
- package/dist/events.d.ts +231 -0
- package/dist/events.js +22 -0
- package/dist/frame-bytes.d.ts +20 -0
- package/dist/frame-bytes.js +75 -0
- package/dist/handler.d.ts +9 -0
- package/dist/handler.js +490 -0
- package/dist/index.d.ts +42 -33
- package/dist/index.js +45 -27
- package/dist/lease-store.d.ts +50 -0
- package/dist/lease-store.js +0 -0
- package/dist/pg-executor-pglite.d.ts +27 -0
- package/dist/pg-executor-pglite.js +33 -0
- package/dist/pg-executor.d.ts +67 -0
- package/dist/pg-executor.js +56 -0
- package/dist/postgres-fanout.d.ts +83 -0
- package/dist/postgres-fanout.js +71 -0
- package/dist/postgres-storage.d.ts +69 -0
- package/dist/postgres-storage.js +766 -0
- package/dist/prune.d.ts +24 -39
- package/dist/prune.js +44 -146
- package/dist/pull.d.ts +42 -67
- package/dist/pull.js +387 -1187
- package/dist/push.d.ts +31 -66
- package/dist/push.js +406 -655
- package/dist/realtime.d.ts +197 -0
- package/dist/realtime.js +848 -0
- package/dist/relational-rows.d.ts +229 -0
- package/dist/relational-rows.js +514 -0
- package/dist/s3-blob-store.d.ts +142 -0
- package/dist/s3-blob-store.js +458 -0
- package/dist/s3-segment-store.d.ts +95 -0
- package/dist/s3-segment-store.js +380 -0
- package/dist/schema.d.ts +82 -286
- package/dist/schema.js +133 -9
- package/dist/scopes.d.ts +48 -0
- package/dist/scopes.js +98 -0
- package/dist/segment-download.d.ts +14 -0
- package/dist/segment-download.js +125 -0
- package/dist/segment-store.d.ts +81 -0
- package/dist/segment-store.js +63 -0
- package/dist/signed-url.d.ts +139 -0
- package/dist/signed-url.js +143 -0
- package/dist/sigv4.d.ts +97 -0
- package/dist/sigv4.js +161 -0
- package/dist/sqlite-blob-store.d.ts +22 -0
- package/dist/sqlite-blob-store.js +86 -0
- package/dist/sqlite-dialect.d.ts +101 -0
- package/dist/sqlite-dialect.js +250 -0
- package/dist/sqlite-image.d.ts +27 -0
- package/dist/sqlite-image.js +83 -0
- package/dist/sqlite-lease-store.d.ts +21 -0
- package/dist/sqlite-lease-store.js +94 -0
- package/dist/sqlite-segment-store.d.ts +25 -0
- package/dist/sqlite-segment-store.js +115 -0
- package/dist/sqlite-storage.d.ts +47 -0
- package/dist/sqlite-storage.js +477 -0
- package/dist/storage.d.ts +252 -0
- package/dist/storage.js +1 -0
- package/dist/validate.d.ts +83 -0
- package/dist/validate.js +44 -0
- package/package.json +17 -325
- package/src/admin.ts +312 -0
- package/src/blob-handlers.ts +340 -0
- package/src/blob-store.ts +0 -0
- package/src/content-encoding.ts +94 -0
- package/src/context.ts +176 -0
- package/src/crdt-merger.ts +33 -0
- package/src/d1-storage.ts +0 -0
- package/src/errors.ts +241 -19
- package/src/events-ring.ts +116 -0
- package/src/events.ts +291 -0
- package/src/frame-bytes.ts +94 -0
- package/src/handler.ts +678 -0
- package/src/index.ts +44 -27
- package/src/lease-store.ts +0 -0
- package/src/pg-executor-pglite.ts +63 -0
- package/src/pg-executor.ts +87 -0
- package/src/postgres-fanout.ts +143 -0
- package/src/postgres-storage.ts +1182 -0
- package/src/prune.ts +63 -289
- package/src/pull.ts +505 -1805
- package/src/push.ts +623 -1006
- package/src/realtime.ts +1082 -0
- package/src/relational-rows.ts +633 -0
- package/src/s3-blob-store.ts +616 -0
- package/src/s3-segment-store.ts +519 -0
- package/src/schema.ts +242 -281
- package/src/scopes.ts +127 -0
- package/src/segment-download.ts +169 -0
- package/src/segment-store.ts +154 -0
- package/src/signed-url.ts +308 -0
- package/src/sigv4.ts +267 -0
- package/src/sqlite-blob-store.ts +125 -0
- package/src/sqlite-dialect.ts +337 -0
- package/src/sqlite-image.ts +124 -0
- package/src/sqlite-lease-store.ts +144 -0
- package/src/sqlite-segment-store.ts +204 -0
- package/src/sqlite-storage.ts +792 -0
- package/src/storage.ts +316 -0
- package/src/validate.ts +123 -0
- package/dist/auth-leases.d.ts +0 -85
- package/dist/auth-leases.d.ts.map +0 -1
- package/dist/auth-leases.js +0 -368
- package/dist/auth-leases.js.map +0 -1
- package/dist/better-sqlite3.d.ts +0 -22
- package/dist/better-sqlite3.d.ts.map +0 -1
- package/dist/better-sqlite3.js +0 -16
- package/dist/better-sqlite3.js.map +0 -1
- package/dist/blobs/access.d.ts +0 -35
- package/dist/blobs/access.d.ts.map +0 -1
- package/dist/blobs/access.js +0 -149
- package/dist/blobs/access.js.map +0 -1
- package/dist/blobs/adapters/database.d.ts +0 -100
- package/dist/blobs/adapters/database.d.ts.map +0 -1
- package/dist/blobs/adapters/database.js +0 -247
- package/dist/blobs/adapters/database.js.map +0 -1
- package/dist/blobs/index.d.ts +0 -9
- package/dist/blobs/index.d.ts.map +0 -1
- package/dist/blobs/index.js +0 -9
- package/dist/blobs/index.js.map +0 -1
- package/dist/blobs/manager.d.ts +0 -220
- package/dist/blobs/manager.d.ts.map +0 -1
- package/dist/blobs/manager.js +0 -625
- package/dist/blobs/manager.js.map +0 -1
- package/dist/blobs/migrate.d.ts +0 -27
- package/dist/blobs/migrate.d.ts.map +0 -1
- package/dist/blobs/migrate.js +0 -127
- package/dist/blobs/migrate.js.map +0 -1
- package/dist/blobs/types.d.ts +0 -58
- package/dist/blobs/types.d.ts.map +0 -1
- package/dist/blobs/types.js +0 -5
- package/dist/blobs/types.js.map +0 -1
- package/dist/bun-sqlite.d.ts +0 -19
- package/dist/bun-sqlite.d.ts.map +0 -1
- package/dist/bun-sqlite.js +0 -19
- package/dist/bun-sqlite.js.map +0 -1
- package/dist/clients.d.ts +0 -15
- package/dist/clients.d.ts.map +0 -1
- package/dist/clients.js +0 -7
- package/dist/clients.js.map +0 -1
- package/dist/cloudflare/durable-object.d.ts +0 -93
- package/dist/cloudflare/durable-object.d.ts.map +0 -1
- package/dist/cloudflare/durable-object.js +0 -210
- package/dist/cloudflare/durable-object.js.map +0 -1
- package/dist/cloudflare/index.d.ts +0 -22
- package/dist/cloudflare/index.d.ts.map +0 -1
- package/dist/cloudflare/index.js +0 -22
- package/dist/cloudflare/index.js.map +0 -1
- package/dist/cloudflare/r2.d.ts +0 -180
- package/dist/cloudflare/r2.d.ts.map +0 -1
- package/dist/cloudflare/r2.js +0 -258
- package/dist/cloudflare/r2.js.map +0 -1
- package/dist/cloudflare/scope-cache.d.ts +0 -54
- package/dist/cloudflare/scope-cache.d.ts.map +0 -1
- package/dist/cloudflare/scope-cache.js +0 -223
- package/dist/cloudflare/scope-cache.js.map +0 -1
- package/dist/cloudflare/sentry.d.ts +0 -47
- package/dist/cloudflare/sentry.d.ts.map +0 -1
- package/dist/cloudflare/sentry.js +0 -163
- package/dist/cloudflare/sentry.js.map +0 -1
- package/dist/cloudflare/worker.d.ts +0 -46
- package/dist/cloudflare/worker.d.ts.map +0 -1
- package/dist/cloudflare/worker.js +0 -63
- package/dist/cloudflare/worker.js.map +0 -1
- package/dist/commit-integrity.d.ts +0 -38
- package/dist/commit-integrity.d.ts.map +0 -1
- package/dist/commit-integrity.js +0 -260
- package/dist/commit-integrity.js.map +0 -1
- package/dist/compaction.d.ts +0 -27
- package/dist/compaction.d.ts.map +0 -1
- package/dist/compaction.js +0 -49
- package/dist/compaction.js.map +0 -1
- package/dist/crdt-yjs/index.d.ts +0 -99
- package/dist/crdt-yjs/index.d.ts.map +0 -1
- package/dist/crdt-yjs/index.js +0 -629
- package/dist/crdt-yjs/index.js.map +0 -1
- package/dist/d1.d.ts +0 -13
- package/dist/d1.d.ts.map +0 -1
- package/dist/d1.js +0 -14
- package/dist/d1.js.map +0 -1
- package/dist/dialect/base.d.ts +0 -93
- package/dist/dialect/base.d.ts.map +0 -1
- package/dist/dialect/base.js +0 -181
- package/dist/dialect/base.js.map +0 -1
- package/dist/dialect/helpers.d.ts +0 -16
- package/dist/dialect/helpers.d.ts.map +0 -1
- package/dist/dialect/helpers.js +0 -83
- package/dist/dialect/helpers.js.map +0 -1
- package/dist/dialect/index.d.ts +0 -7
- package/dist/dialect/index.d.ts.map +0 -1
- package/dist/dialect/index.js +0 -7
- package/dist/dialect/index.js.map +0 -1
- package/dist/dialect/types.d.ts +0 -187
- package/dist/dialect/types.d.ts.map +0 -1
- package/dist/dialect/types.js +0 -8
- package/dist/dialect/types.js.map +0 -1
- package/dist/encrypted-crdt.d.ts +0 -67
- package/dist/encrypted-crdt.d.ts.map +0 -1
- package/dist/encrypted-crdt.js +0 -426
- package/dist/encrypted-crdt.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/filesystem/index.d.ts +0 -44
- package/dist/filesystem/index.d.ts.map +0 -1
- package/dist/filesystem/index.js +0 -164
- package/dist/filesystem/index.js.map +0 -1
- package/dist/handlers/collection.d.ts +0 -20
- package/dist/handlers/collection.d.ts.map +0 -1
- package/dist/handlers/collection.js +0 -42
- package/dist/handlers/collection.js.map +0 -1
- package/dist/handlers/create-handler.d.ts +0 -156
- package/dist/handlers/create-handler.d.ts.map +0 -1
- package/dist/handlers/create-handler.js +0 -626
- package/dist/handlers/create-handler.js.map +0 -1
- package/dist/handlers/index.d.ts +0 -4
- package/dist/handlers/index.d.ts.map +0 -1
- package/dist/handlers/index.js +0 -4
- package/dist/handlers/index.js.map +0 -1
- package/dist/handlers/types.d.ts +0 -295
- package/dist/handlers/types.d.ts.map +0 -1
- package/dist/handlers/types.js +0 -2
- package/dist/handlers/types.js.map +0 -1
- package/dist/helpers/conflict.d.ts +0 -52
- package/dist/helpers/conflict.d.ts.map +0 -1
- package/dist/helpers/conflict.js +0 -49
- package/dist/helpers/conflict.js.map +0 -1
- package/dist/helpers/emitted-change.d.ts +0 -56
- package/dist/helpers/emitted-change.d.ts.map +0 -1
- package/dist/helpers/emitted-change.js +0 -46
- package/dist/helpers/emitted-change.js.map +0 -1
- package/dist/helpers/index.d.ts +0 -12
- package/dist/helpers/index.d.ts.map +0 -1
- package/dist/helpers/index.js +0 -12
- package/dist/helpers/index.js.map +0 -1
- package/dist/helpers/paginate.d.ts +0 -49
- package/dist/helpers/paginate.d.ts.map +0 -1
- package/dist/helpers/paginate.js +0 -54
- package/dist/helpers/paginate.js.map +0 -1
- package/dist/helpers/scope-authorization.d.ts +0 -7
- package/dist/helpers/scope-authorization.d.ts.map +0 -1
- package/dist/helpers/scope-authorization.js +0 -19
- package/dist/helpers/scope-authorization.js.map +0 -1
- package/dist/helpers/scope-commit-index.d.ts +0 -12
- package/dist/helpers/scope-commit-index.d.ts.map +0 -1
- package/dist/helpers/scope-commit-index.js +0 -35
- package/dist/helpers/scope-commit-index.js.map +0 -1
- package/dist/helpers/scope-strings.d.ts +0 -74
- package/dist/helpers/scope-strings.d.ts.map +0 -1
- package/dist/helpers/scope-strings.js +0 -82
- package/dist/helpers/scope-strings.js.map +0 -1
- package/dist/hono/api-key-auth.d.ts +0 -49
- package/dist/hono/api-key-auth.d.ts.map +0 -1
- package/dist/hono/api-key-auth.js +0 -108
- package/dist/hono/api-key-auth.js.map +0 -1
- package/dist/hono/audit-redaction.d.ts +0 -20
- package/dist/hono/audit-redaction.d.ts.map +0 -1
- package/dist/hono/audit-redaction.js +0 -85
- package/dist/hono/audit-redaction.js.map +0 -1
- package/dist/hono/blobs.d.ts +0 -75
- package/dist/hono/blobs.d.ts.map +0 -1
- package/dist/hono/blobs.js +0 -586
- package/dist/hono/blobs.js.map +0 -1
- package/dist/hono/console/gateway.d.ts +0 -42
- package/dist/hono/console/gateway.d.ts.map +0 -1
- package/dist/hono/console/gateway.js +0 -2158
- package/dist/hono/console/gateway.js.map +0 -1
- package/dist/hono/console/live-auth.d.ts +0 -7
- package/dist/hono/console/live-auth.d.ts.map +0 -1
- package/dist/hono/console/live-auth.js +0 -39
- package/dist/hono/console/live-auth.js.map +0 -1
- package/dist/hono/console/route-descriptor.d.ts +0 -6
- package/dist/hono/console/route-descriptor.d.ts.map +0 -1
- package/dist/hono/console/route-descriptor.js +0 -16
- package/dist/hono/console/route-descriptor.js.map +0 -1
- package/dist/hono/console/routes/api-keys.d.ts +0 -8
- package/dist/hono/console/routes/api-keys.d.ts.map +0 -1
- package/dist/hono/console/routes/api-keys.js +0 -575
- package/dist/hono/console/routes/api-keys.js.map +0 -1
- package/dist/hono/console/routes/clients.d.ts +0 -8
- package/dist/hono/console/routes/clients.d.ts.map +0 -1
- package/dist/hono/console/routes/clients.js +0 -322
- package/dist/hono/console/routes/clients.js.map +0 -1
- package/dist/hono/console/routes/commits.d.ts +0 -8
- package/dist/hono/console/routes/commits.d.ts.map +0 -1
- package/dist/hono/console/routes/commits.js +0 -882
- package/dist/hono/console/routes/commits.js.map +0 -1
- package/dist/hono/console/routes/context.d.ts +0 -188
- package/dist/hono/console/routes/context.d.ts.map +0 -1
- package/dist/hono/console/routes/context.js +0 -633
- package/dist/hono/console/routes/context.js.map +0 -1
- package/dist/hono/console/routes/events.d.ts +0 -8
- package/dist/hono/console/routes/events.d.ts.map +0 -1
- package/dist/hono/console/routes/events.js +0 -501
- package/dist/hono/console/routes/events.js.map +0 -1
- package/dist/hono/console/routes/maintenance.d.ts +0 -8
- package/dist/hono/console/routes/maintenance.d.ts.map +0 -1
- package/dist/hono/console/routes/maintenance.js +0 -341
- package/dist/hono/console/routes/maintenance.js.map +0 -1
- package/dist/hono/console/routes/shared.d.ts +0 -174
- package/dist/hono/console/routes/shared.d.ts.map +0 -1
- package/dist/hono/console/routes/shared.js +0 -583
- package/dist/hono/console/routes/shared.js.map +0 -1
- package/dist/hono/console/routes/stats.d.ts +0 -8
- package/dist/hono/console/routes/stats.d.ts.map +0 -1
- package/dist/hono/console/routes/stats.js +0 -295
- package/dist/hono/console/routes/stats.js.map +0 -1
- package/dist/hono/console/routes/storage.d.ts +0 -8
- package/dist/hono/console/routes/storage.d.ts.map +0 -1
- package/dist/hono/console/routes/storage.js +0 -121
- package/dist/hono/console/routes/storage.js.map +0 -1
- package/dist/hono/console/routes.d.ts +0 -36
- package/dist/hono/console/routes.d.ts.map +0 -1
- package/dist/hono/console/routes.js +0 -112
- package/dist/hono/console/routes.js.map +0 -1
- package/dist/hono/console/schema-errors.d.ts +0 -2
- package/dist/hono/console/schema-errors.d.ts.map +0 -1
- package/dist/hono/console/schema-errors.js +0 -17
- package/dist/hono/console/schema-errors.js.map +0 -1
- package/dist/hono/console/schemas.d.ts +0 -1515
- package/dist/hono/console/schemas.d.ts.map +0 -1
- package/dist/hono/console/schemas.js +0 -661
- package/dist/hono/console/schemas.js.map +0 -1
- package/dist/hono/console/types.d.ts +0 -213
- package/dist/hono/console/types.d.ts.map +0 -1
- package/dist/hono/console/types.js +0 -2
- package/dist/hono/console/types.js.map +0 -1
- package/dist/hono/console/ui.d.ts +0 -38
- package/dist/hono/console/ui.d.ts.map +0 -1
- package/dist/hono/console/ui.js +0 -43
- package/dist/hono/console/ui.js.map +0 -1
- package/dist/hono/create-server.d.ts +0 -71
- package/dist/hono/create-server.d.ts.map +0 -1
- package/dist/hono/create-server.js +0 -121
- package/dist/hono/create-server.js.map +0 -1
- package/dist/hono/errors.d.ts +0 -13
- package/dist/hono/errors.d.ts.map +0 -1
- package/dist/hono/errors.js +0 -21
- package/dist/hono/errors.js.map +0 -1
- package/dist/hono/index.d.ts +0 -20
- package/dist/hono/index.d.ts.map +0 -1
- package/dist/hono/index.js +0 -31
- package/dist/hono/index.js.map +0 -1
- package/dist/hono/openapi.d.ts +0 -72
- package/dist/hono/openapi.d.ts.map +0 -1
- package/dist/hono/openapi.js +0 -99
- package/dist/hono/openapi.js.map +0 -1
- package/dist/hono/proxy/connection-manager.d.ts +0 -78
- package/dist/hono/proxy/connection-manager.d.ts.map +0 -1
- package/dist/hono/proxy/connection-manager.js +0 -251
- package/dist/hono/proxy/connection-manager.js.map +0 -1
- package/dist/hono/proxy/index.d.ts +0 -8
- package/dist/hono/proxy/index.d.ts.map +0 -1
- package/dist/hono/proxy/index.js +0 -8
- package/dist/hono/proxy/index.js.map +0 -1
- package/dist/hono/proxy/routes.d.ts +0 -86
- package/dist/hono/proxy/routes.d.ts.map +0 -1
- package/dist/hono/proxy/routes.js +0 -183
- package/dist/hono/proxy/routes.js.map +0 -1
- package/dist/hono/rate-limit.d.ts +0 -101
- package/dist/hono/rate-limit.d.ts.map +0 -1
- package/dist/hono/rate-limit.js +0 -184
- package/dist/hono/rate-limit.js.map +0 -1
- package/dist/hono/realtime-sync-packs.d.ts +0 -43
- package/dist/hono/realtime-sync-packs.d.ts.map +0 -1
- package/dist/hono/realtime-sync-packs.js +0 -219
- package/dist/hono/realtime-sync-packs.js.map +0 -1
- package/dist/hono/routes/audit.d.ts +0 -12
- package/dist/hono/routes/audit.d.ts.map +0 -1
- package/dist/hono/routes/audit.js +0 -385
- package/dist/hono/routes/audit.js.map +0 -1
- package/dist/hono/routes/auth-leases.d.ts +0 -8
- package/dist/hono/routes/auth-leases.d.ts.map +0 -1
- package/dist/hono/routes/auth-leases.js +0 -88
- package/dist/hono/routes/auth-leases.js.map +0 -1
- package/dist/hono/routes/combined.d.ts +0 -8
- package/dist/hono/routes/combined.d.ts.map +0 -1
- package/dist/hono/routes/combined.js +0 -392
- package/dist/hono/routes/combined.js.map +0 -1
- package/dist/hono/routes/context.d.ts +0 -314
- package/dist/hono/routes/context.d.ts.map +0 -1
- package/dist/hono/routes/context.js +0 -1147
- package/dist/hono/routes/context.js.map +0 -1
- package/dist/hono/routes/health.d.ts +0 -8
- package/dist/hono/routes/health.d.ts.map +0 -1
- package/dist/hono/routes/health.js +0 -16
- package/dist/hono/routes/health.js.map +0 -1
- package/dist/hono/routes/realtime.d.ts +0 -9
- package/dist/hono/routes/realtime.d.ts.map +0 -1
- package/dist/hono/routes/realtime.js +0 -626
- package/dist/hono/routes/realtime.js.map +0 -1
- package/dist/hono/routes/shared.d.ts +0 -689
- package/dist/hono/routes/shared.d.ts.map +0 -1
- package/dist/hono/routes/shared.js +0 -972
- package/dist/hono/routes/shared.js.map +0 -1
- package/dist/hono/routes/snapshots.d.ts +0 -10
- package/dist/hono/routes/snapshots.d.ts.map +0 -1
- package/dist/hono/routes/snapshots.js +0 -281
- package/dist/hono/routes/snapshots.js.map +0 -1
- package/dist/hono/routes.d.ts +0 -19
- package/dist/hono/routes.d.ts.map +0 -1
- package/dist/hono/routes.js +0 -37
- package/dist/hono/routes.js.map +0 -1
- package/dist/hono/validation.d.ts +0 -5
- package/dist/hono/validation.d.ts.map +0 -1
- package/dist/hono/validation.js +0 -47
- package/dist/hono/validation.js.map +0 -1
- package/dist/hono/websocket-origin.d.ts +0 -9
- package/dist/hono/websocket-origin.d.ts.map +0 -1
- package/dist/hono/websocket-origin.js +0 -96
- package/dist/hono/websocket-origin.js.map +0 -1
- package/dist/hono/ws.d.ts +0 -341
- package/dist/hono/ws.d.ts.map +0 -1
- package/dist/hono/ws.js +0 -711
- package/dist/hono/ws.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/libsql.d.ts +0 -29
- package/dist/libsql.d.ts.map +0 -1
- package/dist/libsql.js +0 -25
- package/dist/libsql.js.map +0 -1
- package/dist/migrate.d.ts +0 -14
- package/dist/migrate.d.ts.map +0 -1
- package/dist/migrate.js +0 -13
- package/dist/migrate.js.map +0 -1
- package/dist/neon.d.ts +0 -21
- package/dist/neon.d.ts.map +0 -1
- package/dist/neon.js +0 -22
- package/dist/neon.js.map +0 -1
- package/dist/notify.d.ts +0 -77
- package/dist/notify.d.ts.map +0 -1
- package/dist/notify.js +0 -233
- package/dist/notify.js.map +0 -1
- package/dist/pglite.d.ts +0 -37
- package/dist/pglite.d.ts.map +0 -1
- package/dist/pglite.js +0 -37
- package/dist/pglite.js.map +0 -1
- package/dist/plugins/index.d.ts +0 -2
- package/dist/plugins/index.d.ts.map +0 -1
- package/dist/plugins/index.js +0 -2
- package/dist/plugins/index.js.map +0 -1
- package/dist/plugins/types.d.ts +0 -73
- package/dist/plugins/types.d.ts.map +0 -1
- package/dist/plugins/types.js +0 -30
- package/dist/plugins/types.js.map +0 -1
- package/dist/postgres/index.d.ts +0 -68
- package/dist/postgres/index.d.ts.map +0 -1
- package/dist/postgres/index.js +0 -924
- package/dist/postgres/index.js.map +0 -1
- package/dist/proxy/collection.d.ts +0 -7
- package/dist/proxy/collection.d.ts.map +0 -1
- package/dist/proxy/collection.js +0 -6
- package/dist/proxy/collection.js.map +0 -1
- package/dist/proxy/handler.d.ts +0 -42
- package/dist/proxy/handler.d.ts.map +0 -1
- package/dist/proxy/handler.js +0 -102
- package/dist/proxy/handler.js.map +0 -1
- package/dist/proxy/index.d.ts +0 -9
- package/dist/proxy/index.d.ts.map +0 -1
- package/dist/proxy/index.js +0 -14
- package/dist/proxy/index.js.map +0 -1
- package/dist/proxy/mutation-detector.d.ts +0 -35
- package/dist/proxy/mutation-detector.d.ts.map +0 -1
- package/dist/proxy/mutation-detector.js +0 -246
- package/dist/proxy/mutation-detector.js.map +0 -1
- package/dist/proxy/oplog.d.ts +0 -30
- package/dist/proxy/oplog.d.ts.map +0 -1
- package/dist/proxy/oplog.js +0 -137
- package/dist/proxy/oplog.js.map +0 -1
- package/dist/proxy/types.d.ts +0 -44
- package/dist/proxy/types.d.ts.map +0 -1
- package/dist/proxy/types.js +0 -7
- package/dist/proxy/types.js.map +0 -1
- package/dist/prune.d.ts.map +0 -1
- package/dist/prune.js.map +0 -1
- package/dist/pull.d.ts.map +0 -1
- package/dist/pull.js.map +0 -1
- package/dist/push.d.ts.map +0 -1
- package/dist/push.js.map +0 -1
- package/dist/realtime/in-memory.d.ts +0 -13
- package/dist/realtime/in-memory.d.ts.map +0 -1
- package/dist/realtime/in-memory.js +0 -28
- package/dist/realtime/in-memory.js.map +0 -1
- package/dist/realtime/index.d.ts +0 -4
- package/dist/realtime/index.d.ts.map +0 -1
- package/dist/realtime/index.js +0 -3
- package/dist/realtime/index.js.map +0 -1
- package/dist/realtime/types.d.ts +0 -62
- package/dist/realtime/types.d.ts.map +0 -1
- package/dist/realtime/types.js +0 -19
- package/dist/realtime/types.js.map +0 -1
- package/dist/relay/client-role/forward-engine.d.ts +0 -63
- package/dist/relay/client-role/forward-engine.d.ts.map +0 -1
- package/dist/relay/client-role/forward-engine.js +0 -267
- package/dist/relay/client-role/forward-engine.js.map +0 -1
- package/dist/relay/client-role/index.d.ts +0 -9
- package/dist/relay/client-role/index.d.ts.map +0 -1
- package/dist/relay/client-role/index.js +0 -9
- package/dist/relay/client-role/index.js.map +0 -1
- package/dist/relay/client-role/pull-engine.d.ts +0 -72
- package/dist/relay/client-role/pull-engine.d.ts.map +0 -1
- package/dist/relay/client-role/pull-engine.js +0 -249
- package/dist/relay/client-role/pull-engine.js.map +0 -1
- package/dist/relay/client-role/sequence-mapper.d.ts +0 -65
- package/dist/relay/client-role/sequence-mapper.d.ts.map +0 -1
- package/dist/relay/client-role/sequence-mapper.js +0 -161
- package/dist/relay/client-role/sequence-mapper.js.map +0 -1
- package/dist/relay/evaluation/relay-paths.d.ts +0 -41
- package/dist/relay/evaluation/relay-paths.d.ts.map +0 -1
- package/dist/relay/evaluation/relay-paths.js +0 -504
- package/dist/relay/evaluation/relay-paths.js.map +0 -1
- package/dist/relay/evaluation/rust-boundary.d.ts +0 -47
- package/dist/relay/evaluation/rust-boundary.d.ts.map +0 -1
- package/dist/relay/evaluation/rust-boundary.js +0 -220
- package/dist/relay/evaluation/rust-boundary.js.map +0 -1
- package/dist/relay/index.d.ts +0 -37
- package/dist/relay/index.d.ts.map +0 -1
- package/dist/relay/index.js +0 -44
- package/dist/relay/index.js.map +0 -1
- package/dist/relay/migrate.d.ts +0 -18
- package/dist/relay/migrate.d.ts.map +0 -1
- package/dist/relay/migrate.js +0 -99
- package/dist/relay/migrate.js.map +0 -1
- package/dist/relay/mode-manager.d.ts +0 -60
- package/dist/relay/mode-manager.d.ts.map +0 -1
- package/dist/relay/mode-manager.js +0 -114
- package/dist/relay/mode-manager.js.map +0 -1
- package/dist/relay/realtime.d.ts +0 -90
- package/dist/relay/realtime.d.ts.map +0 -1
- package/dist/relay/realtime.js +0 -147
- package/dist/relay/realtime.js.map +0 -1
- package/dist/relay/relay.d.ts +0 -190
- package/dist/relay/relay.d.ts.map +0 -1
- package/dist/relay/relay.js +0 -320
- package/dist/relay/relay.js.map +0 -1
- package/dist/relay/schema.d.ts +0 -158
- package/dist/relay/schema.d.ts.map +0 -1
- package/dist/relay/schema.js +0 -7
- package/dist/relay/schema.js.map +0 -1
- package/dist/relay/server-role/index.d.ts +0 -56
- package/dist/relay/server-role/index.d.ts.map +0 -1
- package/dist/relay/server-role/index.js +0 -193
- package/dist/relay/server-role/index.js.map +0 -1
- package/dist/relay/server-role/pull.d.ts +0 -27
- package/dist/relay/server-role/pull.d.ts.map +0 -1
- package/dist/relay/server-role/pull.js +0 -24
- package/dist/relay/server-role/pull.js.map +0 -1
- package/dist/relay/server-role/push.d.ts +0 -29
- package/dist/relay/server-role/push.d.ts.map +0 -1
- package/dist/relay/server-role/push.js +0 -122
- package/dist/relay/server-role/push.js.map +0 -1
- package/dist/s3/index.d.ts +0 -83
- package/dist/s3/index.d.ts.map +0 -1
- package/dist/s3/index.js +0 -223
- package/dist/s3/index.js.map +0 -1
- package/dist/schema.d.ts.map +0 -1
- package/dist/schema.js.map +0 -1
- package/dist/service-worker/index.d.ts +0 -114
- package/dist/service-worker/index.d.ts.map +0 -1
- package/dist/service-worker/index.js +0 -487
- package/dist/service-worker/index.js.map +0 -1
- package/dist/snapshot-artifacts/sqlite-bun.d.ts +0 -15
- package/dist/snapshot-artifacts/sqlite-bun.d.ts.map +0 -1
- package/dist/snapshot-artifacts/sqlite-bun.js +0 -128
- package/dist/snapshot-artifacts/sqlite-bun.js.map +0 -1
- package/dist/snapshot-artifacts.d.ts +0 -206
- package/dist/snapshot-artifacts.d.ts.map +0 -1
- package/dist/snapshot-artifacts.js +0 -545
- package/dist/snapshot-artifacts.js.map +0 -1
- package/dist/snapshot-chunks/db-metadata.d.ts +0 -56
- package/dist/snapshot-chunks/db-metadata.d.ts.map +0 -1
- package/dist/snapshot-chunks/db-metadata.js +0 -360
- package/dist/snapshot-chunks/db-metadata.js.map +0 -1
- package/dist/snapshot-chunks/index.d.ts +0 -8
- package/dist/snapshot-chunks/index.d.ts.map +0 -1
- package/dist/snapshot-chunks/index.js +0 -8
- package/dist/snapshot-chunks/index.js.map +0 -1
- package/dist/snapshot-chunks/types.d.ts +0 -80
- package/dist/snapshot-chunks/types.d.ts.map +0 -1
- package/dist/snapshot-chunks/types.js +0 -8
- package/dist/snapshot-chunks/types.js.map +0 -1
- package/dist/snapshot-chunks.d.ts +0 -90
- package/dist/snapshot-chunks.d.ts.map +0 -1
- package/dist/snapshot-chunks.js +0 -304
- package/dist/snapshot-chunks.js.map +0 -1
- package/dist/sqlite/index.d.ts +0 -53
- package/dist/sqlite/index.d.ts.map +0 -1
- package/dist/sqlite/index.js +0 -795
- package/dist/sqlite/index.js.map +0 -1
- package/dist/sqlite3.d.ts +0 -22
- package/dist/sqlite3.d.ts.map +0 -1
- package/dist/sqlite3.js +0 -99
- package/dist/sqlite3.js.map +0 -1
- package/dist/stats.d.ts +0 -28
- package/dist/stats.d.ts.map +0 -1
- package/dist/stats.js +0 -93
- package/dist/stats.js.map +0 -1
- package/dist/subscriptions/cache.d.ts +0 -58
- package/dist/subscriptions/cache.d.ts.map +0 -1
- package/dist/subscriptions/cache.js +0 -250
- package/dist/subscriptions/cache.js.map +0 -1
- package/dist/subscriptions/index.d.ts +0 -3
- package/dist/subscriptions/index.d.ts.map +0 -1
- package/dist/subscriptions/index.js +0 -3
- package/dist/subscriptions/index.js.map +0 -1
- package/dist/subscriptions/resolve.d.ts +0 -40
- package/dist/subscriptions/resolve.d.ts.map +0 -1
- package/dist/subscriptions/resolve.js +0 -275
- package/dist/subscriptions/resolve.js.map +0 -1
- package/dist/sync.d.ts +0 -24
- package/dist/sync.d.ts.map +0 -1
- package/dist/sync.js +0 -27
- package/dist/sync.js.map +0 -1
- package/src/auth-leases.ts +0 -649
- package/src/better-sqlite3.ts +0 -35
- package/src/blobs/access.ts +0 -244
- package/src/blobs/adapters/database.ts +0 -397
- package/src/blobs/index.ts +0 -9
- package/src/blobs/manager.ts +0 -901
- package/src/blobs/migrate.ts +0 -158
- package/src/blobs/types.ts +0 -74
- package/src/bun-sqlite-ambient.d.ts +0 -19
- package/src/bun-sqlite.ts +0 -27
- package/src/clients.ts +0 -22
- package/src/cloudflare/durable-object.ts +0 -289
- package/src/cloudflare/index.ts +0 -22
- package/src/cloudflare/r2.ts +0 -526
- package/src/cloudflare/scope-cache.ts +0 -341
- package/src/cloudflare/sentry.ts +0 -230
- package/src/cloudflare/worker.ts +0 -77
- package/src/commit-integrity.ts +0 -371
- package/src/compaction.ts +0 -77
- package/src/crdt-yjs/index.ts +0 -931
- package/src/d1.ts +0 -16
- package/src/dialect/base.ts +0 -360
- package/src/dialect/helpers.ts +0 -92
- package/src/dialect/index.ts +0 -7
- package/src/dialect/types.ts +0 -247
- package/src/encrypted-crdt.ts +0 -786
- package/src/filesystem/index.ts +0 -262
- package/src/handlers/collection.ts +0 -121
- package/src/handlers/create-handler.ts +0 -1134
- package/src/handlers/index.ts +0 -3
- package/src/handlers/types.ts +0 -403
- package/src/helpers/conflict.ts +0 -64
- package/src/helpers/emitted-change.ts +0 -69
- package/src/helpers/index.ts +0 -12
- package/src/helpers/paginate.ts +0 -82
- package/src/helpers/scope-authorization.ts +0 -27
- package/src/helpers/scope-commit-index.ts +0 -52
- package/src/helpers/scope-strings.ts +0 -101
- package/src/hono/api-key-auth.ts +0 -177
- package/src/hono/audit-redaction.ts +0 -135
- package/src/hono/blobs.ts +0 -851
- package/src/hono/console/gateway.ts +0 -3046
- package/src/hono/console/live-auth.ts +0 -46
- package/src/hono/console/route-descriptor.ts +0 -22
- package/src/hono/console/routes/api-keys.ts +0 -721
- package/src/hono/console/routes/clients.ts +0 -447
- package/src/hono/console/routes/commits.ts +0 -1137
- package/src/hono/console/routes/context.ts +0 -956
- package/src/hono/console/routes/events.ts +0 -669
- package/src/hono/console/routes/maintenance.ts +0 -461
- package/src/hono/console/routes/shared.ts +0 -816
- package/src/hono/console/routes/stats.ts +0 -392
- package/src/hono/console/routes/storage.ts +0 -159
- package/src/hono/console/routes.ts +0 -146
- package/src/hono/console/schema-errors.ts +0 -23
- package/src/hono/console/schemas.ts +0 -914
- package/src/hono/console/types.ts +0 -223
- package/src/hono/console/ui.ts +0 -100
- package/src/hono/create-server.ts +0 -230
- package/src/hono/errors.ts +0 -50
- package/src/hono/index.ts +0 -54
- package/src/hono/openapi.ts +0 -139
- package/src/hono/proxy/connection-manager.ts +0 -340
- package/src/hono/proxy/index.ts +0 -8
- package/src/hono/proxy/routes.ts +0 -272
- package/src/hono/rate-limit.ts +0 -319
- package/src/hono/realtime-sync-packs.ts +0 -354
- package/src/hono/routes/audit.ts +0 -499
- package/src/hono/routes/auth-leases.ts +0 -119
- package/src/hono/routes/combined.ts +0 -583
- package/src/hono/routes/context.ts +0 -1629
- package/src/hono/routes/health.ts +0 -26
- package/src/hono/routes/realtime.ts +0 -808
- package/src/hono/routes/shared.ts +0 -1626
- package/src/hono/routes/snapshots.ts +0 -345
- package/src/hono/routes.ts +0 -68
- package/src/hono/validation.ts +0 -81
- package/src/hono/websocket-origin.ts +0 -131
- package/src/hono/ws.ts +0 -1134
- package/src/libsql.ts +0 -51
- package/src/migrate.ts +0 -20
- package/src/neon.ts +0 -28
- package/src/notify.ts +0 -341
- package/src/pglite.ts +0 -68
- package/src/plugins/index.ts +0 -1
- package/src/plugins/types.ts +0 -144
- package/src/postgres/index.ts +0 -1291
- package/src/proxy/collection.ts +0 -17
- package/src/proxy/handler.ts +0 -159
- package/src/proxy/index.ts +0 -21
- package/src/proxy/mutation-detector.ts +0 -281
- package/src/proxy/oplog.ts +0 -181
- package/src/proxy/types.ts +0 -46
- package/src/realtime/in-memory.ts +0 -33
- package/src/realtime/index.ts +0 -7
- package/src/realtime/types.ts +0 -90
- package/src/relay/bun-types.d.ts +0 -50
- package/src/relay/client-role/forward-engine.ts +0 -355
- package/src/relay/client-role/index.ts +0 -9
- package/src/relay/client-role/pull-engine.ts +0 -329
- package/src/relay/client-role/sequence-mapper.ts +0 -201
- package/src/relay/evaluation/relay-paths.ts +0 -699
- package/src/relay/evaluation/rust-boundary.ts +0 -464
- package/src/relay/index.ts +0 -50
- package/src/relay/migrate.ts +0 -113
- package/src/relay/mode-manager.ts +0 -142
- package/src/relay/realtime.ts +0 -207
- package/src/relay/relay.ts +0 -431
- package/src/relay/schema.ts +0 -171
- package/src/relay/server-role/index.ts +0 -338
- package/src/relay/server-role/pull.ts +0 -43
- package/src/relay/server-role/push.ts +0 -164
- package/src/s3/index.ts +0 -346
- package/src/service-worker/index.ts +0 -773
- package/src/snapshot-artifacts/sqlite-bun.ts +0 -168
- package/src/snapshot-artifacts.ts +0 -896
- package/src/snapshot-chunks/db-metadata.ts +0 -537
- package/src/snapshot-chunks/index.ts +0 -8
- package/src/snapshot-chunks/types.ts +0 -105
- package/src/snapshot-chunks.ts +0 -453
- package/src/sqlite/index.ts +0 -1064
- package/src/sqlite3.ts +0 -137
- package/src/stats.ts +0 -180
- package/src/subscriptions/cache.ts +0 -376
- package/src/subscriptions/index.ts +0 -2
- package/src/subscriptions/resolve.ts +0 -357
- package/src/sync.ts +0 -111
package/README.md
CHANGED
|
@@ -1,24 +1,784 @@
|
|
|
1
1
|
# @syncular/server
|
|
2
2
|
|
|
3
|
-
Framework-
|
|
3
|
+
Framework-free embeddable SSP2 protocol library. Core surface:
|
|
4
|
+
`handleSyncRequest(bytes, ctx) → bytes` over host-provided storage /
|
|
5
|
+
scope-resolution / segment-store interfaces, plus the transport-agnostic
|
|
6
|
+
realtime hub (§8), the direct segment download handler (§5.5), commit-log
|
|
7
|
+
pruning (§4.6), and signed-URL token issuance (§5.4). `SPEC.md` is
|
|
8
|
+
normative for everything on the wire; this README covers the **host
|
|
9
|
+
surface** — in particular the ops seam and the pruning runbook.
|
|
4
10
|
|
|
5
|
-
|
|
11
|
+
## Deployment matrix (runtime adapters, TODO §4.2)
|
|
6
12
|
|
|
7
|
-
|
|
13
|
+
The server core is **runtime-neutral TypeScript** — `handleSyncRequest` and
|
|
14
|
+
the realtime session speak only Web `Request`/`Response`/`fetch`/Web-Crypto,
|
|
15
|
+
no Bun- or Node-only builtin (enforced by a static import-graph scan,
|
|
16
|
+
`test/runtime-neutrality.test.ts`). Adapters wire that core to a runtime.
|
|
17
|
+
The supported set, and what deliberately does **not** get an adapter:
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
|
|
19
|
+
| Runtime | Adapter | Transport | Storage | Status |
|
|
20
|
+
| --- | --- | --- | --- | --- |
|
|
21
|
+
| **Bun / Node** | `@syncular/server-hono` | HTTP (`POST /sync`, segments, blobs) **+ WS realtime** (§8, host-driven upgrade) | any: `SqliteServerStorage`, `PostgresServerStorage`, memory | **Supported now** — the reference deployment; runs the full conformance catalog on both bindings. |
|
|
22
|
+
| **Cloudflare Workers** | `@syncular/server-workers` | HTTP binding via Hono (Workers-native) **+ WS realtime** (§8, Durable Object host with hibernation) | `D1ServerStorage` (D1); R2-as-S3 for segments/blobs (§5.4 delegated presign) | **Supported now** — this rung. Realtime rides a **Durable Object** (`SyncularRealtimeDO`), opt-in; HTTP-only is also fully conformant (below). |
|
|
23
|
+
| Raw Deno / edge-misc | — | — | — | **Not adapted** (policy below). |
|
|
24
|
+
|
|
25
|
+
**The policy for "not adapted".** Untested ≠ unsupported forever. The core
|
|
26
|
+
is runtime-neutral TS, so Deno/edge would very likely run it — but an adapter
|
|
27
|
+
is only *supported* where the conformance catalog can run against it. We ship
|
|
28
|
+
adapters for the runtimes where we run conformance (Bun/Node fully; Workers
|
|
29
|
+
HTTP via the fetch-handler round-trip tests), and we do not claim runtimes we
|
|
30
|
+
do not test. Deno is a plausible future adapter the day someone runs the
|
|
31
|
+
catalog on it; until then it is neutral-core-friendly, not supported.
|
|
32
|
+
|
|
33
|
+
**Workers realtime — the Durable Object.** SPEC §1.1's two bindings are two
|
|
34
|
+
framings of one handler; an **HTTP-only deployment is fully conformant**
|
|
35
|
+
(clients that cannot open the socket sync over `POST /sync`, identical
|
|
36
|
+
semantics — a smaller complete deployment, not a degraded one), so realtime on
|
|
37
|
+
Workers is opt-in. When enabled it rides a **Durable Object**
|
|
38
|
+
(`SyncularRealtimeDO`): one DO per partition hosting the `RealtimeHub` (the DO
|
|
39
|
+
id derived from the partition, so a partition's sockets and its commit fan-out
|
|
40
|
+
are co-located and single-threaded — also the natural per-partition write
|
|
41
|
+
serialization point the D1 storage wants); WebSocket **hibernation** so idle
|
|
42
|
+
sockets don't bill wall time (the existing `RealtimeSession` is the
|
|
43
|
+
per-connection state machine, driven from the hibernation callbacks and
|
|
44
|
+
rehydrated from a minimal socket attachment + the D1 client record on wake);
|
|
45
|
+
storage via the same **D1** binding so realtime rounds and `POST /sync` rounds
|
|
46
|
+
share one commit log and one segment store; commit fan-out (§8.2) runs in-DO
|
|
47
|
+
(no LISTEN/NOTIFY needed — writes and sockets are co-located), and an HTTP push
|
|
48
|
+
landing in a plain isolate wakes the partition's DO (the in-platform
|
|
49
|
+
LISTEN/NOTIFY analogue). Full shape, wiring, hibernation semantics, and the
|
|
50
|
+
manual real-workerd smoke recipe in `@syncular/server-workers/README.md`.
|
|
51
|
+
|
|
52
|
+
**Relay does not return (decision).** v1 shipped a *relay* — a bridge that let
|
|
53
|
+
a self-hosted server forward realtime to a managed realtime service, because
|
|
54
|
+
v1's realtime was a separate socketed subsystem the self-hosted core couldn't
|
|
55
|
+
serve on its own. v2 has no such gap: realtime is the **second binding of the
|
|
56
|
+
same handler** (§8.7, Direction decision 1 — the WS-native loop), so any host
|
|
57
|
+
that runs the core serves realtime directly; multi-instance fanout is covered
|
|
58
|
+
by **LISTEN/NOTIFY** on Postgres (below), and the Workers case is covered by
|
|
59
|
+
the **DO design** (writes and sockets co-located per partition). Every job the
|
|
60
|
+
relay did is now done by a binding of the core or by in-database fanout —
|
|
61
|
+
reintroducing a relay would add a hop, a second protocol surface, and a
|
|
62
|
+
managed dependency for zero capability the core lacks. So it is retired, not
|
|
63
|
+
ported.
|
|
64
|
+
|
|
65
|
+
## Structured events (the ops seam)
|
|
66
|
+
|
|
67
|
+
One optional interface, `SyncularServerEvents`, carries every
|
|
68
|
+
operator-relevant signal as a typed, JSON-able, stable-shaped event:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { consoleJsonEvents, type SyncServerConfig } from '@syncular/server';
|
|
72
|
+
|
|
73
|
+
const config: SyncServerConfig = {
|
|
74
|
+
schema, storage, segments, resolveScopes,
|
|
75
|
+
events: consoleJsonEvents(), // one JSON line per event on stdout
|
|
76
|
+
};
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
There is no logger dependency and no formatting — emission only. The same
|
|
80
|
+
shapes feed one-line JSON logs, metrics counters, and error trackers; a
|
|
81
|
+
Sentry adapter is a ~20-line `emit` implementation over this seam. The
|
|
82
|
+
events sink rides on `SyncServerConfig`, so the Hono adapter (and any
|
|
83
|
+
other adapter that spreads the config into the request context) passes it
|
|
84
|
+
through with no extra wiring. The realtime hub and `pruneCommitLog` take
|
|
85
|
+
the same sink via their own config/options (they run outside the request
|
|
86
|
+
context). The demo server wires it behind `SYNCULAR_DEMO_EVENTS=1`.
|
|
87
|
+
|
|
88
|
+
### Guarantees
|
|
89
|
+
|
|
90
|
+
- **Never throws through.** Emission is fire-and-forget: a throwing
|
|
91
|
+
`emit` is swallowed at the seam and cannot affect request processing,
|
|
92
|
+
realtime delivery, or pruning. (Tested.)
|
|
93
|
+
- **Zero cost when off.** With no sink configured, no event object is
|
|
94
|
+
ever built — every call site checks the sink before constructing the
|
|
95
|
+
event. The benches run with events unset.
|
|
96
|
+
- **Stable, JSON-able shapes.** Flat objects, no `undefined` values, no
|
|
97
|
+
classes; `JSON.stringify` round-trips every event. Shapes and `type`
|
|
98
|
+
strings are append-only surface.
|
|
99
|
+
- **Virtual-clock clean.** All timestamps and durations come from the ctx
|
|
100
|
+
clock (`clock` on the config / hub; `nowMs` for prune), so conformance
|
|
101
|
+
and tests under a virtual clock stay deterministic. Wall clock is never
|
|
102
|
+
read behind the host's back.
|
|
103
|
+
|
|
104
|
+
### Event catalog
|
|
105
|
+
|
|
106
|
+
| Event | When | Key fields |
|
|
107
|
+
| --- | --- | --- |
|
|
108
|
+
| `request.handled` | Once per `POST /sync`, after the response bytes are fully produced (or the request was rejected up front) | `kind` (`sync`), `partition`, `actorId`, `durationMs`, `bytesIn`, `bytesOut`, `outcome` (`ok` \| `schema_floor` \| `rejected` \| `error`), `errorCode?`, `pushCommits`, `pulled`, `subscriptions` |
|
|
109
|
+
| `push.applied` | A `PUSH_COMMIT` applied, or replayed from the idempotency cache (§2.3) | `clientId`, `clientCommitId`, `operations`, `commitSeq?`, `replay` |
|
|
110
|
+
| `push.rejected` | A commit rejected (§6.3) | `clientId`, `clientCommitId`, `operations`, `code` (§10.2), `opIndex` |
|
|
111
|
+
| `push.conflicted` | A commit terminated by a version conflict (§6.2) | `clientId`, `clientCommitId`, `operations`, `opIndex` |
|
|
112
|
+
| `pull.served` | Once per served pull half, after all sections streamed | `clientId`, `subscriptions[]`: `{id, table, status, mode` (`bootstrap` \| `incremental` \| `none`)`, fromCursor, nextCursor, commits, changes, segments[]}`; each segment: `{mediaType` (`rows` \| `sqlite`)`, delivery` (`inline` \| `ref`)`, origin` (`built` \| `reused`)`, bytes, rows}` |
|
|
113
|
+
| `segment.downloaded` | Every direct segment download (§5.5), success or failure | `segmentId`, `outcome` (`ok` \| `error`), `errorCode?`, `mediaType?`, `bytes?`, `durationMs` |
|
|
114
|
+
| `blob.swept` | Every `sweepOrphanBlobs` pass (§5.9.2 orphan GC) | `partition`, `swept` (deleted count), `referenced` (keep-set size), `graceMs` |
|
|
115
|
+
| `realtime.opened` | A socket registered with the hub and got `hello` (§8.1) | `sessionId`, `clientId`, `registrations`, `cursor`, `latestSeq` |
|
|
116
|
+
| `realtime.closed` | A session left the hub (once per session) | `sessionId`, `durationMs` |
|
|
117
|
+
| `realtime.delta` | A delta message pushed over the socket (§8.2) | `sessionId`, `commitSeq`, `bytes`, `changes` |
|
|
118
|
+
| `realtime.wake` | A `sync` wake-up sent (§8.3) | `sessionId`, `reason` (`catchup-required` \| `delta-too-large` \| `reset-required`) |
|
|
119
|
+
| `prune.completed` | Every `pruneCommitLog` pass, moved or not | `partition`, `previousHorizonSeq`, `horizonSeq`, `advanced`, `removedCommits` |
|
|
120
|
+
| `scopes.resolve_failed` | The host `resolveScopes` callback threw — the §3.2/§3.4 fail-loud path | `phase` (`request` \| `realtime` \| `segment-download`), `message` |
|
|
121
|
+
|
|
122
|
+
All events also carry `type`, `atMs`, and (where a request identity
|
|
123
|
+
exists) `partition` / `actorId`.
|
|
124
|
+
|
|
125
|
+
## Admin / console surface (`SyncularAdmin`)
|
|
126
|
+
|
|
127
|
+
The operator-facing read surface over the server core. It is a module in
|
|
128
|
+
this package — **not** a separate UI package — and adds **zero** wire
|
|
129
|
+
protocol: SPEC.md says nothing about it, because authorization for these
|
|
130
|
+
reads is entirely the host's. It is the v2 answer to v1's full React
|
|
131
|
+
console app: the same 80% operator value (who's connected, what's flowing,
|
|
132
|
+
horizon health, the event tail) as a handful of read-only, partition-scoped,
|
|
133
|
+
JSON-able queries.
|
|
134
|
+
|
|
135
|
+
### The event ring (the "event stream")
|
|
136
|
+
|
|
137
|
+
`RingBufferEvents` is a `SyncularServerEvents` sink that retains the last N
|
|
138
|
+
events in memory (bounded — oldest dropped when full) with a
|
|
139
|
+
`query({type?, sinceMs?, limit})`. It is the event stream without any
|
|
140
|
+
infrastructure dependency. Compose it with any other sink so the console
|
|
141
|
+
tail and your logs/metrics see the same emissions:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import {
|
|
145
|
+
RingBufferEvents, composeEvents, consoleJsonEvents, SyncularAdmin,
|
|
146
|
+
} from '@syncular/server';
|
|
147
|
+
|
|
148
|
+
const ring = new RingBufferEvents({ capacity: 1000 });
|
|
149
|
+
const config: SyncServerConfig = {
|
|
150
|
+
schema, storage, segments, resolveScopes,
|
|
151
|
+
events: composeEvents(ring, consoleJsonEvents()), // both see every event
|
|
152
|
+
};
|
|
153
|
+
const admin = SyncularAdmin.fromConfig(config, { ring });
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Query surface
|
|
157
|
+
|
|
158
|
+
Every method is read-only and partition-scoped:
|
|
159
|
+
|
|
160
|
+
| Method | Returns |
|
|
161
|
+
| --- | --- |
|
|
162
|
+
| `listClients(partition)` | Known clients: `clientId`, `actorId`, `cursor`, `updatedAtMs`, `subscriptions[]`, and an `active` flag (cursor touched within the §4.6 active window). |
|
|
163
|
+
| `listCommits(partition, {afterSeq?, limit?, table?})` | Commit-log **metadata** (never payloads), newest first: `commitSeq`, `clientId`, `clientCommitId`, `actorId`, `createdAtMs`, `changeCount`, `tables[]`. |
|
|
164
|
+
| `inspectRow(partition, table, rowId)` | `{exists, serverVersion?, scopes?}` — current row version + stored scopes, payload **not** decoded. |
|
|
165
|
+
| `scopeActivity(partition, {variable, value}, {limit?})` | Recent commits touching one scope key, via the §3.1 change-scope index (never a log scan). |
|
|
166
|
+
| `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`. |
|
|
167
|
+
| `segmentStats()` / `blobStats(partition)` / `stats(partition)` | Counts/bytes where the stores expose them (segments split rows/sqlite). `undefined` when a store omits `stats()`. |
|
|
168
|
+
| `events({type?, sinceMs?, limit?})` | The ring tail, newest first. Empty when no ring is wired (`hasEventStream` reports which). |
|
|
169
|
+
|
|
170
|
+
The query surface leans on **additive, optional** storage/store methods
|
|
171
|
+
(`ServerStorage.listClientRecords` / `listCommitMetadata` / `scopeActivity` /
|
|
172
|
+
`getRowScopes`; `SegmentStore.stats`; `BlobStore.stats`) — the established
|
|
173
|
+
optional-method pattern. `SqliteServerStorage`, `PostgresServerStorage`,
|
|
174
|
+
`D1ServerStorage`, and the memory/sqlite stores implement them; the shared
|
|
175
|
+
`ServerStorage` contract suite exercises them on all backends. A backend that
|
|
176
|
+
omits one makes the corresponding admin read fail loud (it never returns a
|
|
177
|
+
silently-empty console). The `S3SegmentStore` **does** report `stats()` — from
|
|
178
|
+
a LIST-free pointer-object accumulator (see "S3 stats" below) — but its
|
|
179
|
+
counters are marked `approximate: true`, an additive field the admin surface
|
|
180
|
+
carries through so the console can label them honestly. The exact in-process
|
|
181
|
+
stores (memory/sqlite) omit the marker.
|
|
182
|
+
|
|
183
|
+
### HTTP routes + the single console page
|
|
184
|
+
|
|
185
|
+
`@syncular/server-hono` exports `createSyncularAdminRoutes(admin, opts)`,
|
|
186
|
+
a mountable Hono sub-app. **The auth seam is required**: the factory throws
|
|
187
|
+
if you omit the `authorize` guard — there is no default-open admin. Every
|
|
188
|
+
endpoint (including the page) runs the guard first; a falsy result is a 401.
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
import { createSyncularAdminRoutes } from '@syncular/server-hono';
|
|
192
|
+
|
|
193
|
+
const routes = createSyncularAdminRoutes(admin, {
|
|
194
|
+
defaultPartition: 'main',
|
|
195
|
+
authorize: ({ request }) => isOperator(request), // YOUR check — mandatory
|
|
196
|
+
});
|
|
197
|
+
app.route('/admin', routes);
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
| Route | Mirrors |
|
|
201
|
+
| --- | --- |
|
|
202
|
+
| `GET /` | The console page (see below). |
|
|
203
|
+
| `GET /clients` | `listClients` |
|
|
204
|
+
| `GET /commits?afterSeq&limit&table` | `listCommits` |
|
|
205
|
+
| `GET /rows/:table/:rowId` | `inspectRow` |
|
|
206
|
+
| `GET /scope-activity?variable&value&limit` | `scopeActivity` |
|
|
207
|
+
| `GET /horizon` | `horizonStatus` |
|
|
208
|
+
| `GET /stats` | `stats` |
|
|
209
|
+
| `GET /events?type&sinceMs&limit` | `events` (ring tail) |
|
|
210
|
+
|
|
211
|
+
`?partition=` selects the partition (falls back to `defaultPartition`).
|
|
212
|
+
|
|
213
|
+
`GET /` (or `/admin`) serves a **single static HTML page** — zero
|
|
214
|
+
framework, no build step, no React. It fetches the sibling JSON endpoints
|
|
215
|
+
(relative to its own mount path, so it works under any prefix and the same
|
|
216
|
+
guard covers its XHRs), renders tables for horizon, store stats, clients,
|
|
217
|
+
recent commits, and the event tail, with an auto-refresh toggle (2 s poll).
|
|
218
|
+
This is the ~300-line answer to v1's console app: 5% of the code, the 80%
|
|
219
|
+
operator value.
|
|
220
|
+
|
|
221
|
+
**No SSE (yet).** `GET /events` is a polled ring query; the page's
|
|
222
|
+
auto-refresh polls it. Server-Sent-Events streaming was deliberately
|
|
223
|
+
skipped for this rung — the ring is pull-only, so SSE would need a
|
|
224
|
+
push-notification path from the sink into open connections (extra
|
|
225
|
+
machinery for marginal benefit at admin cadence). Polling is the right
|
|
226
|
+
rung; SSE is a noted follow-up.
|
|
227
|
+
|
|
228
|
+
The demo server (`apps/demo`) mounts the admin behind a dev guard:
|
|
229
|
+
`SYNCULAR_DEMO_ADMIN=1` enables `/admin` (optionally token-gated with
|
|
230
|
+
`SYNCULAR_DEMO_ADMIN_TOKEN`), so the console is inspectable live.
|
|
231
|
+
|
|
232
|
+
> Docs-site coverage of the console is a follow-up: the docs app is owned
|
|
233
|
+
> by a concurrent workstream this round (the schema-bump page), so this
|
|
234
|
+
> README is the console's documentation home for now.
|
|
235
|
+
|
|
236
|
+
## Segment storage on S3 / R2 (`S3SegmentStore`)
|
|
237
|
+
|
|
238
|
+
Three `SegmentStore` backends ship in-tree and pass one shared contract
|
|
239
|
+
suite (`test/segment-store-contract.ts`): `MemorySegmentStore` (tests,
|
|
240
|
+
single process), `SqliteSegmentStore` (single node), and
|
|
241
|
+
`S3SegmentStore` — the production backend for any S3-compatible object
|
|
242
|
+
store (AWS S3, Cloudflare R2, MinIO). It is dependency-free: SigV4 is
|
|
243
|
+
hand-rolled over `fetch` (`sigv4.ts`, pinned by the published AWS test
|
|
244
|
+
vectors).
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
import { S3SegmentStore, s3PresignedUrls } from '@syncular/server';
|
|
248
|
+
|
|
249
|
+
const segments = new S3SegmentStore({
|
|
250
|
+
endpoint: 'https://s3.eu-central-1.amazonaws.com', // origin only, no bucket
|
|
251
|
+
region: 'eu-central-1',
|
|
252
|
+
bucket: 'my-app-segments',
|
|
253
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
254
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
255
|
+
keyPrefix: 'syncular/', // optional namespace inside the bucket
|
|
256
|
+
ttlMs: 24 * 60 * 60 * 1000, // §5.1 default
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const config: SyncServerConfig = {
|
|
260
|
+
schema, storage, resolveScopes,
|
|
261
|
+
segments,
|
|
262
|
+
signedUrls: s3PresignedUrls(segments, { ttlSeconds: 900 }), // §5.4 delegated presign
|
|
263
|
+
};
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**Cloudflare R2 specifics.** The endpoint is your account's S3 API host
|
|
267
|
+
and the region is always `auto`:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
const segments = new S3SegmentStore({
|
|
271
|
+
endpoint: 'https://<account-id>.r2.cloudflarestorage.com',
|
|
272
|
+
region: 'auto',
|
|
273
|
+
bucket: 'my-app-segments',
|
|
274
|
+
accessKeyId: R2_ACCESS_KEY_ID, // R2 API token pair
|
|
275
|
+
secretAccessKey: R2_SECRET_ACCESS_KEY,
|
|
276
|
+
});
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
MinIO works the same way (`endpoint: 'http://127.0.0.1:9000'`, any
|
|
280
|
+
region string). Requests are path-style (`{endpoint}/{bucket}/{key}`),
|
|
281
|
+
which all three providers accept.
|
|
282
|
+
|
|
283
|
+
**Key layout.** Deterministic, so every lookup is a GET/HEAD — never a
|
|
284
|
+
LIST:
|
|
285
|
+
|
|
286
|
+
- `{keyPrefix}seg/sha256/{hex}` — the segment bytes, verbatim (the
|
|
287
|
+
object body is exactly the content-addressed bytes, so presigned GETs
|
|
288
|
+
serve them directly and the client's §5.1 hash check passes). The
|
|
289
|
+
record metadata rides in object user metadata
|
|
290
|
+
(`x-amz-meta-syncular-record`, base64url JSON), so `get` is one GET.
|
|
291
|
+
- `{keyPrefix}find/{sha256(reuse key)}.json` — the §5.3 whole-table
|
|
292
|
+
reuse pointer, written only for `rowCursor: null` segments; `find` is
|
|
293
|
+
one GET plus a HEAD to confirm the segment object still exists.
|
|
294
|
+
|
|
295
|
+
**TTL and lifecycle.** Expiry is store-side and authoritative:
|
|
296
|
+
`expiresAtMs` (`put` time + `ttlMs`, default 24 h) is recorded with the
|
|
297
|
+
record; `get` returns expired records so the §5.5 endpoint can answer
|
|
298
|
+
the precise, retryable `sync.segment_expired`, and `find` filters them
|
|
299
|
+
itself. Bucket lifecycle expiration is *garbage collection only* — set
|
|
300
|
+
it comfortably **above** `ttlMs` (e.g. 2 days for the 24 h default) and
|
|
301
|
+
never below it. After lifecycle deletes an object, clients see
|
|
302
|
+
`sync.not_found` instead of `sync.segment_expired`; both recover by
|
|
303
|
+
re-pulling, but the former loses the "just re-pull, this is normal"
|
|
304
|
+
signal, so keep the GC margin generous.
|
|
305
|
+
|
|
306
|
+
### S3 stats — a LIST-free, approximate accumulator
|
|
307
|
+
|
|
308
|
+
`S3SegmentStore.stats()` reports store-wide `{count, bytes, rowsSegments,
|
|
309
|
+
sqliteSegments}` for the admin console **without a bucket LIST** — a LIST
|
|
310
|
+
would defeat the store's whole every-lookup-is-a-GET/HEAD design and cost
|
|
311
|
+
real money at scale. Instead the store keeps a tiny counter object under a
|
|
312
|
+
fixed key (`{keyPrefix}stats/segments.json`) and folds each new segment into
|
|
313
|
+
it read-modify-write on `put`. A `HEAD` before the segment PUT detects an
|
|
314
|
+
idempotent re-put (same content address ⇒ same key), so each distinct
|
|
315
|
+
segment is counted once.
|
|
316
|
+
|
|
317
|
+
The accumulator write is guarded by an **ETag compare-and-swap** — `If-Match`
|
|
318
|
+
against the ETag we read (or `If-None-Match: *` to create) — and the store
|
|
319
|
+
retries on a `412 PreconditionFailed`, so two writers folding concurrently do
|
|
320
|
+
not silently clobber each other's increment. AWS S3 and Cloudflare R2 both
|
|
321
|
+
honor these conditional headers.
|
|
322
|
+
|
|
323
|
+
Even so, the counters are **approximate**, and `stats()` marks them
|
|
324
|
+
`approximate: true` (an additive field the admin surface carries through, so
|
|
325
|
+
`admin.segmentStats()` / `admin.stats()` expose it and the console labels the
|
|
326
|
+
numbers honestly). They can drift: a crash between the segment PUT and the
|
|
327
|
+
accumulator CAS, lifecycle GC deleting objects the accumulator still counts,
|
|
328
|
+
or enough concurrent writers to exhaust the CAS retry budget. They are a
|
|
329
|
+
health gauge, not an invoice — reconcile against a periodic inventory report
|
|
330
|
+
if you need an exact number. The exact in-process stores (memory / sqlite)
|
|
331
|
+
count on demand and omit the marker.
|
|
332
|
+
|
|
333
|
+
The blob store's `stats()` uses the same accumulator + `approximate: true`
|
|
334
|
+
marker (see "Blob bytes on S3 / R2" below).
|
|
335
|
+
|
|
336
|
+
## Blob bytes on S3 / R2 (`S3BlobStore`)
|
|
337
|
+
|
|
338
|
+
File-attachment bytes (§5.9) get the same object-store backend as segments.
|
|
339
|
+
`S3BlobStore` is the blob twin of `S3SegmentStore` — same hand-rolled SigV4,
|
|
340
|
+
same content-addressed key layout, same LIST-free-on-the-hot-path stats
|
|
341
|
+
accumulator — for AWS S3, Cloudflare R2, or MinIO. It closes the
|
|
342
|
+
"attachments are SQLite-only" gap: a Workers/edge or horizontally-scaled
|
|
343
|
+
deployment can now store blobs durably in an object store instead of the
|
|
344
|
+
database.
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
import {
|
|
348
|
+
S3BlobStore,
|
|
349
|
+
s3PresignedBlobUploads,
|
|
350
|
+
s3PresignedBlobUrls,
|
|
351
|
+
} from '@syncular/server';
|
|
352
|
+
|
|
353
|
+
const blobs = new S3BlobStore({
|
|
354
|
+
endpoint: 'https://s3.us-east-1.amazonaws.com',
|
|
355
|
+
region: 'us-east-1',
|
|
356
|
+
bucket: 'my-attachments',
|
|
357
|
+
accessKeyId: AWS_ACCESS_KEY_ID,
|
|
358
|
+
secretAccessKey: AWS_SECRET_ACCESS_KEY,
|
|
359
|
+
keyPrefix: 'syncular/', // optional namespace inside the bucket
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const config: SyncServerConfig = {
|
|
363
|
+
// …schema, storage, segments, resolveScopes…
|
|
364
|
+
blobs,
|
|
365
|
+
// Presigned DOWNLOAD (always-issue): serve blob downloads as provider
|
|
366
|
+
// presigned GET URLs (§5.9.5). The client fetches bytes straight from the
|
|
367
|
+
// object store — the sync server exits the download egress path.
|
|
368
|
+
blobSignedUrls: s3PresignedBlobUrls(blobs, { ttlSeconds: 900 }),
|
|
369
|
+
// Presigned UPLOAD (direct-to-storage): mint single presigned PUT URLs so
|
|
370
|
+
// clients upload straight to the object store, bypassing the server upload
|
|
371
|
+
// bandwidth path (§5.9.3). Optional — absent ⇒ clients stream through the
|
|
372
|
+
// direct `PUT /blobs/{blobId}` endpoint (a capability, never a fallback).
|
|
373
|
+
blobUploadUrls: s3PresignedBlobUploads(blobs, { ttlSeconds: 900 }),
|
|
374
|
+
};
|
|
11
375
|
```
|
|
12
376
|
|
|
13
|
-
|
|
377
|
+
### Presigned blobs, end to end (§5.9.3 / §5.9.5)
|
|
14
378
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- Server overview: https://syncular.dev/docs/server/overview
|
|
379
|
+
Two independent presign switches let the sync server step out of the blob
|
|
380
|
+
byte path in both directions:
|
|
18
381
|
|
|
19
|
-
|
|
382
|
+
**Download — `blobSignedUrls` (always-issue).** After the §5.9.5 row-derived
|
|
383
|
+
authorization check passes, `GET /blobs/{blobId}` returns
|
|
384
|
+
`{ url, urlExpiresAtMs }` and **no bytes**; the client fetches the URL directly
|
|
385
|
+
(no host auth — the URL is the entire grant) and re-verifies the content
|
|
386
|
+
address. **Always-issue, not accept-bit negotiation** (the pinned decision,
|
|
387
|
+
§5.9.5): unlike segments — where a descriptor rides the pull stream to a client
|
|
388
|
+
that may be unable to fetch a bare URL, so issuance is gated on accept bit 3 —
|
|
389
|
+
a blob download is a plain request/response, so the response simply carries the
|
|
390
|
+
URL and a client that cannot consume it re-requests. Always-issue is *harmless*
|
|
391
|
+
(the authorized endpoint is the same route; nothing is stuck on a stream) and
|
|
392
|
+
*simpler* (no accept-bit plumbing on a non-pull path). Recovery mirrors §5.4:
|
|
393
|
+
a failed or expired URL fetch **re-requests** the endpoint (which re-authorizes
|
|
394
|
+
and mints a fresh URL) — never a fall-through.
|
|
20
395
|
|
|
21
|
-
|
|
22
|
-
-
|
|
396
|
+
**Upload — `blobUploadUrls` (the grant flow).** `POST
|
|
397
|
+
/blobs/{blobId}/upload-grant` (host-authenticated, body
|
|
398
|
+
`{ byteLength, mediaType? }`) mints a single presigned PUT; the client PUTs
|
|
399
|
+
bytes straight to the object store. **Upload authz is host-authentication only**
|
|
400
|
+
(any authenticated actor may obtain a grant within the size cap) — uploading
|
|
401
|
+
bytes is not a scope-bearing act, because the content address discloses nothing
|
|
402
|
+
and integrity is enforced at *reference time* (the §5.9.6 push existence check
|
|
403
|
+
+ every download's content-address verify), never by a store-side hash
|
|
404
|
+
recompute. The **size cap is enforced up front** against the declared
|
|
405
|
+
`byteLength`, before any URL is minted (the object-store hop cannot re-check the
|
|
406
|
+
streamed byte count). An already-present blob returns `{ present: true }` (skip
|
|
407
|
+
the PUT, idempotent §5.9.3). A **single PUT only** — never a multipart or chunk
|
|
408
|
+
protocol; resumable upload, when it lands, is provider multipart behind this
|
|
409
|
+
same grant. Absent config ⇒ the client streams through the direct
|
|
410
|
+
host-authenticated `PUT /blobs/{blobId}` endpoint — a capability choice, not a
|
|
411
|
+
fallback (that endpoint was always the other path).
|
|
23
412
|
|
|
24
|
-
|
|
413
|
+
**Cloudflare R2.** Identical to the segment store — point `endpoint` at
|
|
414
|
+
`https://<account-id>.r2.cloudflarestorage.com`, `region: 'auto'`, and use an
|
|
415
|
+
R2 API-token access-key pair. R2 honors the SigV4 header/query auth and the
|
|
416
|
+
`ListObjectsV2` the sweep uses.
|
|
417
|
+
|
|
418
|
+
**Key layout.** `{keyPrefix}blob/{partition}/sha256/{hex}` — content-addressed
|
|
419
|
+
and **partition-scoped** (the same bytes uploaded under two partitions are two
|
|
420
|
+
objects; a partition cannot read another's attachment by guessing a content
|
|
421
|
+
address). The object body is the blob bytes **verbatim**, so a presigned GET
|
|
422
|
+
serves exactly the content-addressed bytes and the client's §5.9.1 hash check
|
|
423
|
+
passes. `byteLength` + optional `mediaType` + `createdAtMs` ride along as
|
|
424
|
+
object user metadata (`x-amz-meta-syncular-blob` = base64url(JSON)), so `get`
|
|
425
|
+
is a single GET.
|
|
426
|
+
|
|
427
|
+
### Durability, not TTL — the difference from segments
|
|
428
|
+
|
|
429
|
+
This is the honest interface difference from `S3SegmentStore`. **Segments are
|
|
430
|
+
TTL cache entries; blobs are durable.** A blob referenced by a live row must
|
|
431
|
+
stay downloadable **indefinitely** (§5.9.5 B3). So `S3BlobStore` writes **no
|
|
432
|
+
`expiresAtMs`, has no `ttlMs` config, and maps to no S3 lifecycle-expiration
|
|
433
|
+
rule.** Reclamation is **reference-driven, not time-driven**: the only thing
|
|
434
|
+
that deletes a blob is the orphan sweep, and it deletes only blobs *no live
|
|
435
|
+
row references*.
|
|
436
|
+
|
|
437
|
+
> **Do NOT put an S3/R2 lifecycle-expiration rule on the `blob/` prefix.** It
|
|
438
|
+
> would delete still-referenced attachments out from under live rows. This is
|
|
439
|
+
> the exact opposite of the segment guidance (where lifecycle expiration above
|
|
440
|
+
> `ttlMs` is *encouraged* as GC). Blobs are cleaned by the sweep below.
|
|
441
|
+
|
|
442
|
+
### Orphan sweep (the GC runbook)
|
|
443
|
+
|
|
444
|
+
Nothing reclaims blobs automatically — the host schedules the sweep, the blob
|
|
445
|
+
analogue of `pruneCommitLog`. `sweepOrphanBlobs(storage, blobStore, partition,
|
|
446
|
+
{ graceMs })` reads the live keep-set from the §5.9.4 reference index
|
|
447
|
+
(`storage.listReferencedBlobIds`) and deletes every blob that is **both**
|
|
448
|
+
unreferenced **and** older than the grace period. It emits one `blob.swept`
|
|
449
|
+
ops event (`{ swept, referenced, graceMs }`) and returns the deleted ids.
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
import { sweepOrphanBlobs } from '@syncular/server';
|
|
453
|
+
|
|
454
|
+
// A periodic per-partition GC job (hourly to daily is sensible).
|
|
455
|
+
const { swept } = await sweepOrphanBlobs(storage, blobs, partition, {
|
|
456
|
+
graceMs: 24 * 60 * 60 * 1000, // default; see the race note below
|
|
457
|
+
events,
|
|
458
|
+
});
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
**The grace period is not optional cleverness — it is the correctness
|
|
462
|
+
mechanism.** Uploads are *content-addressed and land before the referencing
|
|
463
|
+
push* (§5.9.2 upload-before-reference): a client `PUT`s the bytes, then pushes
|
|
464
|
+
the row. Between those two steps the blob is legitimately **unreferenced**. If
|
|
465
|
+
the sweep ran with no grace it would delete that fresh upload before its push
|
|
466
|
+
arrived. So the grace period must comfortably exceed any sane upload→push
|
|
467
|
+
latency — the **default is 24 h**, deliberately far above any push window.
|
|
468
|
+
Lower it only if you fully understand your clients' outbox latency; there is
|
|
469
|
+
no upside to a tight grace and a real data-loss risk. The sweep compares
|
|
470
|
+
against the blob's **upload time** (`createdAtMs` from object metadata), and an
|
|
471
|
+
idempotent re-upload does **not** reset that clock.
|
|
472
|
+
|
|
473
|
+
`sweepOrphanBlobs` **requires** `storage.listReferencedBlobIds` (the §5.9.4
|
|
474
|
+
reference index — SQLite, D1, and Postgres all implement it). Against a
|
|
475
|
+
storage without it, the helper throws rather than sweep with an empty keep-set
|
|
476
|
+
(which would delete everything). The `S3BlobStore` sweep is the store's **only
|
|
477
|
+
LISTing operation** — it pages `ListObjectsV2` over the partition's `blob/`
|
|
478
|
+
prefix, an admin/GC path off the hot path; every point lookup is still a
|
|
479
|
+
GET/HEAD.
|
|
480
|
+
|
|
481
|
+
### Blob stats — the same approximate accumulator
|
|
482
|
+
|
|
483
|
+
`S3BlobStore.stats()` reports store-wide `{count, bytes}` from a fixed counter
|
|
484
|
+
object (`{keyPrefix}stats/blobs.json`) folded read-modify-write on `put` under
|
|
485
|
+
the same ETag compare-and-swap as segments, and marks the result
|
|
486
|
+
`approximate: true` (the marker already present on `BlobStoreStats`, carried
|
|
487
|
+
through `admin.blobStats()`). A `HEAD` before the PUT detects an idempotent
|
|
488
|
+
re-upload so distinct blobs count once; the sweep decrements on delete. Same
|
|
489
|
+
caveats as segment stats — a health gauge, not an invoice. The in-process
|
|
490
|
+
stores (memory / sqlite) count on demand and omit the marker.
|
|
491
|
+
|
|
492
|
+
### Presigned blob downloads (§5.9.5)
|
|
493
|
+
|
|
494
|
+
Setting `blobSignedUrls: s3PresignedBlobUrls(blobs)` makes the server issue a
|
|
495
|
+
provider-presigned GET URL for a blob download — **but only after the
|
|
496
|
+
row-derived authorization check passes** (`handleBlobDownload` resolves the
|
|
497
|
+
actor's scopes and tests the referencing rows first; a `blobId` is never a
|
|
498
|
+
bearer capability minted from the id alone). The signed object key embeds the
|
|
499
|
+
`blobId`, TTL SHOULD be ≤ 15 min (default 900 s), and the URL is a short-lived
|
|
500
|
+
grant to exactly those immutable bytes. The issued `url`/`urlExpiresAtMs` ride
|
|
501
|
+
additively on `BlobDownloadResult` alongside the bytes; the mounted §5.9.5
|
|
502
|
+
direct-download endpoint stays the default serving path. Client consumption of
|
|
503
|
+
the presigned URL (following it instead of streaming bytes through the sync
|
|
504
|
+
server) is a later rung — the server-side issuance ships now.
|
|
505
|
+
|
|
506
|
+
### Native HMAC vs delegated presign (§5.4)
|
|
507
|
+
|
|
508
|
+
`SyncServerConfig.signedUrls` accepts either scheme; the pull emits
|
|
509
|
+
`SEGMENT_REF.url`/`urlExpiresAtMs` identically for both (issuance always
|
|
510
|
+
happens inside the pull, immediately after scope resolution), and
|
|
511
|
+
clients cannot tell them apart.
|
|
512
|
+
|
|
513
|
+
- **Native HMAC (`SignedUrlConfig`)** — you serve the segment bytes
|
|
514
|
+
yourself (or from something that delegates auth to you, e.g. a CDN
|
|
515
|
+
worker calling `verifySegmentToken` at the edge). The `st` token binds
|
|
516
|
+
segment + scope digest + partition audience. Choose this when segments
|
|
517
|
+
live in `SqliteSegmentStore` or when you want claim-level binding at
|
|
518
|
+
your own edge.
|
|
519
|
+
- **Delegated presign (`DelegatedPresignConfig`, via
|
|
520
|
+
`s3PresignedUrls(store)`)** — the object store enforces the grant; the
|
|
521
|
+
sync server never proxies segment bytes (zero egress through it — the
|
|
522
|
+
bootstrap-storm answer). The §5.4 equivalence rule holds by
|
|
523
|
+
construction: the signed object key embeds exactly one `segmentId`,
|
|
524
|
+
and the expiry obeys the same ≤ 15 min TTL guidance (default 900 s for
|
|
525
|
+
both schemes).
|
|
526
|
+
|
|
527
|
+
Either way, keep the §5.5 direct-download endpoint mounted: it is the
|
|
528
|
+
mandatory fallback for expired/failed URLs and for clients that never
|
|
529
|
+
advertised accept bit 3.
|
|
530
|
+
|
|
531
|
+
### CDN in front
|
|
532
|
+
|
|
533
|
+
Segment URLs are safe to cache *by content*: the object key is the
|
|
534
|
+
content address (`seg/sha256/{hex}`), the bytes are immutable for a
|
|
535
|
+
given key, and the client verifies the hash after download (§5.1) — so
|
|
536
|
+
a CDN can cache segment objects keyed on the path alone and can never
|
|
537
|
+
serve wrong bytes, only stale-but-correct ones. Two rules:
|
|
538
|
+
|
|
539
|
+
- **Strip the query from the cache key, never from the auth check.**
|
|
540
|
+
Presigned query parameters (or the native `st` token) differ per
|
|
541
|
+
client; the path is the content address. Configure the CDN to cache on
|
|
542
|
+
the path while still forwarding the query for origin authorization
|
|
543
|
+
(or validate at the edge: `verifySegmentToken` for native tokens).
|
|
544
|
+
Never cache the *authorization decision*.
|
|
545
|
+
- **Align the CDN TTL with the store TTL.** Cache lifetime at or below
|
|
546
|
+
`ttlMs` keeps the CDN from serving objects the store already declared
|
|
547
|
+
expired (harmless — the client would still verify and apply — but it
|
|
548
|
+
masks the §5.1 cache-entry semantics and can hide lifecycle GC).
|
|
549
|
+
Content-addressing makes over-caching safe, not useful.
|
|
550
|
+
|
|
551
|
+
The §5.5 endpoint responses stay `Cache-Control: private, max-age=0`
|
|
552
|
+
— only segment-object URLs are CDN-cacheable, never the re-authorized
|
|
553
|
+
download path.
|
|
554
|
+
|
|
555
|
+
## Horizon & pruning: operational guidance
|
|
556
|
+
|
|
557
|
+
The commit log grows forever unless you prune it. `pruneCommitLog`
|
|
558
|
+
(SPEC §4.6) advances the per-partition `horizonSeq` and deletes commits
|
|
559
|
+
at or below it. Nothing prunes automatically — the host schedules it.
|
|
560
|
+
|
|
561
|
+
**When to run.** A periodic job per partition — hourly to daily is the
|
|
562
|
+
sensible range; there is no benefit below the granularity of your
|
|
563
|
+
`activeWindowMs`. Prune is cheap when there is nothing to do (one cursor
|
|
564
|
+
scan + two point reads), so err on the side of running it often rather
|
|
565
|
+
than letting a backlog build. Pass `events` to get `prune.completed`
|
|
566
|
+
per pass.
|
|
567
|
+
|
|
568
|
+
**The retention floors (§4.6, encoded in `RetentionPolicy`).** The
|
|
569
|
+
horizon never advances past `min(cursor)` of *active* clients — clients
|
|
570
|
+
whose cursor record was touched within `activeWindowMs` (default 14
|
|
571
|
+
days). Two escape hatches keep laggards from pinning the log forever:
|
|
572
|
+
commits older than `ageForceMs` (default 30 days) may be pruned
|
|
573
|
+
regardless, and at least the newest `minRetainedCommits` (default 1000)
|
|
574
|
+
commits are always kept. Defaults are the v1 production values; raise
|
|
575
|
+
them freely, lower them with care.
|
|
576
|
+
|
|
577
|
+
**What `sync.cursor_expired` means operationally.** A client whose
|
|
578
|
+
cursor fell behind the horizon gets `SUB_START.status = reset` and
|
|
579
|
+
re-bootstraps from scratch (§4.7). That is correct behavior, not an
|
|
580
|
+
error — but its *rate* is your pruning health signal. A steady trickle
|
|
581
|
+
means devices returning from >30-day absences (expected). A spike means
|
|
582
|
+
you pruned faster than your fleet syncs: `ageForceMs` or
|
|
583
|
+
`activeWindowMs` is too tight for real usage, and you are paying for it
|
|
584
|
+
in bootstrap load (full re-scans + segment builds), not just in resets.
|
|
585
|
+
Observe it via `pull.served` subscriptions with `status: "reset"`.
|
|
586
|
+
|
|
587
|
+
**Segment TTL interplay.** Segments are cache entries, not durable state
|
|
588
|
+
(§5.1; default TTL 24 h). A bootstrap that resumes past segment expiry
|
|
589
|
+
answers `sync.segment_expired` and the client re-pulls for fresh
|
|
590
|
+
descriptors — again correct, again a cost signal. Keep the segment TTL
|
|
591
|
+
comfortably longer than the slowest plausible bootstrap (a multi-page
|
|
592
|
+
bootstrap must finish while its segments live), and note that pruning
|
|
593
|
+
and segment expiry compound: a reset storm triggers a bootstrap storm,
|
|
594
|
+
which the §5.3 image-reuse rule absorbs only while images stay
|
|
595
|
+
unexpired. If you see `origin: "built"` dominating `"reused"` for the
|
|
596
|
+
same table+scope during a storm, your TTL is shorter than the storm.
|
|
597
|
+
|
|
598
|
+
**What to alert on.**
|
|
599
|
+
|
|
600
|
+
- `push.rejected` rate, by `code` — a rising `sync.forbidden` share
|
|
601
|
+
usually means an authorization regression, not misbehaving clients.
|
|
602
|
+
(`push.conflicted` is normal offline-first traffic; alert only on
|
|
603
|
+
gross shifts.)
|
|
604
|
+
- `scopes.resolve_failed` — any nonzero rate. This is the fail-loud
|
|
605
|
+
path: every occurrence revokes subscriptions or rejects writes for a
|
|
606
|
+
real request, and it is almost always a host bug or a dead dependency
|
|
607
|
+
of the resolver.
|
|
608
|
+
- `request.handled` with `outcome: "error"` and `errorCode: "internal"`
|
|
609
|
+
— storage failures surfacing mid-stream.
|
|
610
|
+
- Reset rate (`pull.served` → `status: "reset"`) — see above; alert on
|
|
611
|
+
spikes relative to fleet size.
|
|
612
|
+
- Prune backlog: `prune.completed` with `advanced: false` for many
|
|
613
|
+
consecutive passes *while the log grows* means one laggard cursor
|
|
614
|
+
inside the active window is pinning retention — inspect
|
|
615
|
+
`listClientCursors` for the offender; the §4.6 floors bound the damage
|
|
616
|
+
to `ageForceMs`.
|
|
617
|
+
- `realtime.wake` with `reason: "delta-too-large"` — sustained
|
|
618
|
+
occurrences mean commits routinely exceed `maxDeltaBytes` and clients
|
|
619
|
+
are falling back to HTTP pulls; raise the limit or shrink commits.
|
|
620
|
+
|
|
621
|
+
## Postgres storage (the production database path)
|
|
622
|
+
|
|
623
|
+
`SqliteServerStorage` (bun:sqlite) is the dev-speed default. For
|
|
624
|
+
production, `PostgresServerStorage` implements the same `ServerStorage`
|
|
625
|
+
contract against Postgres, with the inverted scope index carried through
|
|
626
|
+
as **covering indexes** so scope fanout is an index range scan, never a
|
|
627
|
+
scan-before-LIMIT (REVISE B2 — this was v1's production wound). The
|
|
628
|
+
schema (`POSTGRES_DDL`) and its index design live in
|
|
629
|
+
`src/postgres-storage.ts`; `storage.migrate()` applies it idempotently
|
|
630
|
+
(every DDL is `CREATE … IF NOT EXISTS`, run statement-by-statement, so
|
|
631
|
+
`migrate()` is safe to call on every boot).
|
|
632
|
+
|
|
633
|
+
**Blobs (§5.9.4) on Postgres.** `PostgresServerStorage` implements the
|
|
634
|
+
optional blob-reference index — `setBlobRefs` (in the commit transaction)
|
|
635
|
+
plus `listRowsReferencingBlob` / `listReferencedBlobIds` — at full parity
|
|
636
|
+
with the SQLite and D1 storages. The `sync_blob_refs` table keys
|
|
637
|
+
`(partition, tbl, row_id, blob_id)` and carries a secondary
|
|
638
|
+
`(partition, blob_id)` index that drives the §5.9.5 download-authorization
|
|
639
|
+
candidate set as an index range (asserted in `postgres-explain.test.ts`, same
|
|
640
|
+
no-`Seq Scan` doctrine as the scope indexes). So a Bun/Node **or** Workers
|
|
641
|
+
deployment on Postgres supports file attachments end-to-end: push writes the
|
|
642
|
+
row's references atomically with the commit, and the blob-download handler
|
|
643
|
+
authorizes via the reference index. The shared `ServerStorage` contract runs
|
|
644
|
+
its blob section on pglite alongside sqlite and D1.
|
|
645
|
+
|
|
646
|
+
### The `PgExecutor` seam (zero runtime deps)
|
|
647
|
+
|
|
648
|
+
The server library never imports a Postgres driver. `PostgresServerStorage`
|
|
649
|
+
is written against the minimal `PgExecutor` interface (`query(text, params)`
|
|
650
|
+
plus a `transaction(fn)` scope) — you wire your driver of choice:
|
|
651
|
+
|
|
652
|
+
**Bun.sql** (built into bun):
|
|
653
|
+
|
|
654
|
+
```ts
|
|
655
|
+
import {
|
|
656
|
+
PostgresServerStorage,
|
|
657
|
+
type PgExecutor,
|
|
658
|
+
type PgQueryable,
|
|
659
|
+
} from '@syncular/server';
|
|
660
|
+
|
|
661
|
+
function bunSqlExecutor(sql: import('bun').SQL): PgExecutor {
|
|
662
|
+
const over = (h: any): PgQueryable => ({
|
|
663
|
+
async query(text, params) {
|
|
664
|
+
const rows = await h.unsafe(text, params ? [...params] : []);
|
|
665
|
+
return { rows, rowCount: rows.length };
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
return {
|
|
669
|
+
query: over(sql).query,
|
|
670
|
+
transaction: (fn) => sql.begin((tx: any) => fn(over(tx))),
|
|
671
|
+
close: () => sql.end(),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const storage = new PostgresServerStorage(
|
|
676
|
+
bunSqlExecutor(new Bun.SQL(process.env.DATABASE_URL!)),
|
|
677
|
+
);
|
|
678
|
+
await storage.migrate();
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
**node-postgres** (`pg`) — adapt a `Pool`:
|
|
682
|
+
|
|
683
|
+
```ts
|
|
684
|
+
import { Pool, type PoolClient } from 'pg';
|
|
685
|
+
import { PostgresServerStorage, type PgExecutor } from '@syncular/server';
|
|
686
|
+
|
|
687
|
+
function pgPoolExecutor(pool: Pool): PgExecutor {
|
|
688
|
+
const over = (c: Pool | PoolClient) => ({
|
|
689
|
+
query: (text: string, params?: readonly unknown[]) =>
|
|
690
|
+
c.query(text, params ? [...params] : []),
|
|
691
|
+
});
|
|
692
|
+
return {
|
|
693
|
+
query: over(pool).query,
|
|
694
|
+
async transaction(fn) {
|
|
695
|
+
const client = await pool.connect();
|
|
696
|
+
try {
|
|
697
|
+
await client.query('BEGIN');
|
|
698
|
+
const result = await fn(over(client));
|
|
699
|
+
await client.query('COMMIT');
|
|
700
|
+
return result;
|
|
701
|
+
} catch (error) {
|
|
702
|
+
await client.query('ROLLBACK');
|
|
703
|
+
throw error;
|
|
704
|
+
} finally {
|
|
705
|
+
client.release();
|
|
706
|
+
}
|
|
707
|
+
},
|
|
708
|
+
close: () => pool.end(),
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
**Type-parser note.** `commit_seq`/`server_version` are `int8`. Drivers
|
|
714
|
+
decode `int8` differently (node-postgres → `string`, Bun.sql → `bigint`,
|
|
715
|
+
pglite → `number`); the storage layer coerces every sequence read through
|
|
716
|
+
`Number(...)`, so no driver-specific type-parser config is required.
|
|
717
|
+
`bytea` must decode to `Uint8Array`/`Buffer` (all three do).
|
|
718
|
+
|
|
719
|
+
**Tests** wire `@electric-sql/pglite` (embedded WASM Postgres, a
|
|
720
|
+
devDependency — hermetic, no docker) via `pgliteExecutor` from
|
|
721
|
+
`@syncular/server/pglite`. Both backends run the shared
|
|
722
|
+
`ServerStorage` contract (`test/storage-contract.ts`), and
|
|
723
|
+
`test/postgres-explain.test.ts` asserts via `EXPLAIN` that the fanout
|
|
724
|
+
candidate scans are index-driven so the scan-before-LIMIT regression
|
|
725
|
+
cannot silently return.
|
|
726
|
+
|
|
727
|
+
### commitSeq allocation under concurrency
|
|
728
|
+
|
|
729
|
+
Per-partition `commitSeq` is dense and gap-free (§2.1). `appendCommit`
|
|
730
|
+
allocates it with `UPDATE sync_partitions SET max_commit_seq =
|
|
731
|
+
max_commit_seq + 1 … RETURNING`, which takes a row-level write lock on the
|
|
732
|
+
partition row for the transaction's duration — concurrent pushes to the
|
|
733
|
+
same partition serialize on that row; cross-partition pushes never
|
|
734
|
+
contend. A Postgres `SEQUENCE` is deliberately **not** used: it would leave
|
|
735
|
+
gaps on rollback, which the §4.5 pull-window arithmetic does not tolerate.
|
|
736
|
+
|
|
737
|
+
### Multi-instance fanout (LISTEN/NOTIFY)
|
|
738
|
+
|
|
739
|
+
Behind a load balancer, a commit applied on instance A fans out to A's
|
|
740
|
+
local realtime sessions in-memory, but a client whose socket lives on
|
|
741
|
+
instance B never sees it. `PostgresFanout` bridges the gap: after a commit
|
|
742
|
+
lands, the originating instance `NOTIFY`s `syncular_commit` with a
|
|
743
|
+
`<partition>:<commitSeq>` payload; every instance runs a `listen()` loop
|
|
744
|
+
that, on a notification, calls `hub.wake(partition, 'catchup-required')` —
|
|
745
|
+
remote sessions then pull the delta from the shared Postgres storage they
|
|
746
|
+
already read from (§8.3). NOTIFY payloads are capped (~8 KB) and are not an
|
|
747
|
+
ordered delta channel, so we wake rather than re-broadcast bytes; only
|
|
748
|
+
cross-instance delivery pays the re-pull. Single-instance deployments
|
|
749
|
+
install no fanout at all.
|
|
750
|
+
|
|
751
|
+
```ts
|
|
752
|
+
import { PostgresFanout, type PgNotificationConnection } from '@syncular/server';
|
|
753
|
+
|
|
754
|
+
// node-postgres: a dedicated Client for LISTEN + the pool for NOTIFY.
|
|
755
|
+
const conn: PgNotificationConnection = {
|
|
756
|
+
async listen(channel, handler) {
|
|
757
|
+
const client = await pool.connect(); // long-lived, NOT released
|
|
758
|
+
client.on('notification', (m) => m.payload && handler(m.payload));
|
|
759
|
+
await client.query(`LISTEN ${channel}`);
|
|
760
|
+
},
|
|
761
|
+
notify: (channel, payload) =>
|
|
762
|
+
pool.query('SELECT pg_notify($1, $2)', [channel, payload]).then(() => {}),
|
|
763
|
+
};
|
|
764
|
+
const fanout = new PostgresFanout(conn);
|
|
765
|
+
await fanout.install(hub); // start the LISTEN loop
|
|
766
|
+
// after a push commit lands:
|
|
767
|
+
await fanout.notifyCommit(partition, commitSeq);
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
pglite is single-connection and cannot exercise cross-connection NOTIFY,
|
|
771
|
+
so the fanout integration test is env-gated on `SYNCULAR_PG_URL` (it wires
|
|
772
|
+
Bun.sql as a worked example) and skips cleanly; the payload encode/parse
|
|
773
|
+
and wake wiring are unit-tested hermetically.
|
|
774
|
+
|
|
775
|
+
### Bench lane
|
|
776
|
+
|
|
777
|
+
`bench` has an env-gated Postgres lane measuring 100k bootstrap +
|
|
778
|
+
propagation on the production path. It runs only with `SYNCULAR_PG_URL`
|
|
779
|
+
set and is **never** part of `bench:ci` budgets (those stay on the
|
|
780
|
+
deterministic in-process sqlite loopback):
|
|
781
|
+
|
|
782
|
+
```sh
|
|
783
|
+
SYNCULAR_PG_URL=postgres://user:pass@localhost:5432/db bun run bench
|
|
784
|
+
```
|