experimental-a2 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/dist/actor-D_54lz_1.d.ts +310 -0
- package/dist/actor-D_54lz_1.d.ts.map +1 -0
- package/dist/actor-client.d.ts +13 -4
- package/dist/actor-client.d.ts.map +1 -1
- package/dist/actor-client.js +63 -7
- package/dist/actor-client.js.map +1 -1
- package/dist/actor-react.d.ts +5 -4
- package/dist/actor-react.d.ts.map +1 -1
- package/dist/actor-react.js +16 -2
- package/dist/actor-react.js.map +1 -1
- package/dist/actor.d.ts +2 -176
- package/dist/actor.js +13 -2
- package/dist/actor.js.map +1 -1
- package/dist/ai-server.d.ts +1 -1
- package/dist/ai-server.js +2 -2
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/client-Bf6uSEAk.js +1342 -0
- package/dist/client-Bf6uSEAk.js.map +1 -0
- package/dist/client-P_NNNRM-.d.ts +243 -0
- package/dist/client-P_NNNRM-.d.ts.map +1 -0
- package/dist/client.d.ts +2 -202
- package/dist/client.js +2 -1026
- package/dist/errors-DCk6ch5n.js.map +1 -1
- package/dist/errors-DvhSXnxk.d.ts +28 -0
- package/dist/errors-DvhSXnxk.d.ts.map +1 -0
- package/dist/index.d.ts +3 -35
- package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
- package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
- package/dist/platform-B4TnJtWu.js +34 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +3 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +2 -1
- package/dist/react.js.map +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -2
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-CBET-jSz.js → server-Dkz2a84E.js} +295 -133
- package/dist/server-Dkz2a84E.js.map +1 -0
- package/dist/{server-CKY3_lbw.d.ts → server-DwPrMqHB.d.ts} +4 -2
- package/dist/server-DwPrMqHB.d.ts.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
- package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +1 -1
- package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
- package/dist/store-polling-CmxUbV93.js.map +1 -0
- package/dist/store-postgres.d.ts +6 -4
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +701 -53
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-C7-XZyW9.js +94 -0
- package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
- package/dist/store-redis-http.d.ts +2 -2
- package/dist/store-redis-http.d.ts.map +1 -1
- package/dist/store-redis-http.js +184 -38
- package/dist/store-redis-http.js.map +1 -1
- package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
- package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
- package/dist/store-redis.d.ts +5 -13
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +27 -271
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +1 -1
- package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
- package/dist/wire-BO5wWCb1.js.map +1 -0
- package/docs/actors/04-routes.mdx +40 -8
- package/docs/guides/03-react.mdx +52 -10
- package/docs/guides/05-production.mdx +54 -5
- package/docs/guides/09-presence.mdx +12 -7
- package/docs/guides/10-transports.mdx +85 -22
- package/docs/reference/01-api.mdx +90 -28
- package/docs/reference/02-errors.mdx +45 -11
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/src/actor-client.ts +114 -14
- package/src/actor-react.ts +18 -3
- package/src/actor.ts +16 -1
- package/src/client-errors.ts +51 -0
- package/src/client.ts +577 -175
- package/src/errors.ts +4 -9
- package/src/index.ts +1 -1
- package/src/internal.ts +1 -1
- package/src/postgres-notification-scope.ts +134 -0
- package/src/postgres-notifications.ts +244 -0
- package/src/postgres-pool.ts +65 -0
- package/src/postgres-resources.ts +185 -0
- package/src/presence-recovery.ts +120 -0
- package/src/react.ts +3 -0
- package/src/redis-http-subscriptions.ts +199 -0
- package/src/server-fetch.ts +51 -23
- package/src/server.ts +146 -19
- package/src/session-socket.ts +241 -88
- package/src/sse.ts +17 -0
- package/src/store-polling.ts +32 -12
- package/src/store-postgres.ts +292 -75
- package/src/store-presence-polling.ts +125 -0
- package/src/store-redis-core.ts +37 -30
- package/src/store-redis-http.ts +51 -45
- package/src/store-redis-notify.ts +464 -0
- package/src/store-redis.ts +9 -364
- package/src/store.ts +3 -4
- package/src/stream-activity.ts +32 -0
- package/src/wire.ts +39 -15
- package/dist/actor-shared-BACubf4x.d.ts +0 -136
- package/dist/actor-shared-BACubf4x.d.ts.map +0 -1
- package/dist/actor.d.ts.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/server-CBET-jSz.js.map +0 -1
- package/dist/server-CKY3_lbw.d.ts.map +0 -1
- package/dist/store-polling-6DW7F1DT.js.map +0 -1
- package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
- package/dist/wire--yji6mO3.js.map +0 -1
package/dist/store-redis.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store-redis.js","names":[],"sources":["../src/store-redis.ts"],"sourcesContent":["/**\n * experimental-a2/store-redis — the Redis-protocol store backend, on Redis\n * Streams. The storage semantics live in store-redis-core.ts (shared\n * with experimental-a2/store-redis-http); this module owns the connection and\n * the live feed.\n *\n * `stream()` is notify-driven: writes to watched sessions fire a\n * disposable PUBLISH wake-up (feeds hold a TTL'd presence marker the\n * write script checks, so unwatched sessions cost no wake-up), one\n * shared subscriber connection per backend serves every local feed,\n * and each wake triggers an `XRANGE` catch-up read. The notification\n * only decides when to read, never what — a lost one is healed by a\n * safety re-read (NOTIFY_TIMINGS), so delivery never depends on\n * pub/sub. Connections scale with processes (one command client plus\n * one subscriber), not with concurrent viewers.\n *\n * Works with any Redis-protocol server on a single instance or a\n * non-cluster provider (Upstash — durable by default — Redis, Valkey).\n * Cluster mode is out: the atomic scripts span keys. `ioredis` is an\n * optional peer dependency; pass `url`, or inject any client exposing\n * `call`/`duplicate`/`on`/`disconnect`.\n */\n\nimport type { PresencePatch } from './contract.ts'\nimport { A2Error } from './errors.ts'\nimport { NOTIFY_TIMINGS } from './internal.ts'\nimport { defaultSleep, type Sleeper } from './store-polling.ts'\nimport { createRedisStoreCore } from './store-redis-core.ts'\nimport { retryableLazy } from './retryable-lazy.ts'\nimport {\n RANDOM_IDS,\n SYSTEM_CLOCK,\n type A2Store,\n type Clock,\n type Event,\n type IdSource,\n} from './store.ts'\n\n/**\n * The minimal client this backend needs — `ioredis` matches it\n * structurally. `call` issues any command; `duplicate` opens the one\n * shared subscriber connection; `on` delivers its pub/sub messages.\n *\n * The client must restore its subscriptions after a reconnect\n * (`ioredis` does). One that doesn't stays correct — the safety\n * re-read delivers everything — but every live feed silently degrades\n * to safety-read latency from that point on.\n */\nexport type RedisConnection = {\n call(command: string, ...args: Array<string | number>): Promise<unknown>\n duplicate(): RedisConnection\n on(\n event: 'message',\n listener: (channel: string, message: string) => void,\n ): unknown\n disconnect(): void\n}\n\nexport type RedisStoreOptions = {\n /** Creates an `ioredis` client lazily (optional peer dep `ioredis`). */\n url?: string | undefined\n /** Bring your own client — anything `call`/`duplicate`/`on`/`disconnect`. */\n client?: RedisConnection\n /** Key prefix — isolates multiple apps on one Redis. Default `'a2'`. */\n keyPrefix?: string\n /** Injectable clock — every stored timestamp comes from here. */\n clock?: Clock\n /** Injectable id source for generated event ids. */\n ids?: IdSource\n}\n\nexport type RedisStore = A2Store & {\n /** Disconnect the command client and the shared subscriber. */\n close(): Promise<void>\n}\n\n/** A published presence patch: the patch fields plus the publishing\n * instance's tag, so its own loopback delivery is not repeated. */\ntype PresenceWireMessage = {\n src: string\n participant: string\n values: Record<string, unknown>\n seen: number\n at: number\n}\n\nexport function redis(options: RedisStoreOptions = {}): RedisStore {\n const clock = options.clock ?? SYSTEM_CLOCK\n const ids = options.ids ?? RANDOM_IDS\n const prefix = options.keyPrefix ?? 'a2'\n\n if (!options.client && !options.url) {\n throw new TypeError('redis() needs a url or an injected client')\n }\n\n // Lazy init — constructing the backend does no I/O.\n const connection = retryableLazy(async () => {\n if (options.client) return options.client\n const mod = await import('ioredis').catch(() => {\n throw new A2Error(\n 'STORE_NOT_CONFIGURED',\n \"experimental-a2/store-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client\",\n )\n })\n return new mod.default(options.url as string) as RedisConnection\n })\n const client = connection.get\n\n const notifyChannel = (sessionId: string): string =>\n `${prefix}:${sessionId}:notify`\n const presenceChannel = (ns: string): string => `${prefix}:${ns}:presence`\n\n const core = createRedisStoreCore({\n call: async (command, ...args) => {\n const c = await client()\n return c.call(command, ...args)\n },\n clock,\n ids,\n keyPrefix: prefix,\n // Fire-and-forget: the wake-up is disposable, so a failed PUBLISH\n // must never fail the write it follows.\n notify: (sessionId, count) => {\n void client()\n .then((c) => c.call('publish', notifyChannel(sessionId), count))\n .catch(() => {})\n },\n })\n\n // ── the shared subscriber ──────────────────────────────────────────\n // One duplicated connection serves every local feed: subscriber mode\n // monopolizes a connection, so it cannot be the command client.\n\n let severed = false\n let subscriber: Promise<RedisConnection> | null = null\n /** channel → parked-feed wake-ups, armed before each catch-up read. */\n const wakers = new Map<string, Set<() => void>>()\n /** channel → refcounted subscription held for each live iterator. */\n const subscriptions = new Map<\n string,\n { refs: number; ready: Promise<unknown> }\n >()\n /**\n * channel → last presence-marker refresh, throttling the SET.\n * Deliberately real time (`Date.now()`), not the injected clock:\n * the marker's PX expiry runs on server real time, and the throttle\n * must tick with the TTL it refreshes or time-traveling tests would\n * desync the two. Process-local and never stored, so the injected\n * clock's every-stored-timestamp pledge is untouched.\n */\n const watchedRefreshedAt = new Map<string, number>()\n /**\n * `:presence` channel → live listeners. Same-instance patches are\n * delivered here synchronously by `presence.set`, so subscribe/set\n * ordering never depends on the SUBSCRIBE command settling; the\n * channel carries only other instances' patches, with own echoes\n * skipped by `src`.\n */\n const presenceListeners = new Map<\n string,\n Set<(patch: PresencePatch) => void>\n >()\n const instanceId = crypto.randomUUID()\n\n const deliverRemotePresence = (\n listeners: Set<(patch: PresencePatch) => void>,\n message: string,\n ): void => {\n let parsed: PresenceWireMessage\n try {\n parsed = JSON.parse(message) as PresenceWireMessage\n } catch {\n return\n }\n if (parsed.src === instanceId) return\n const patch: PresencePatch = {\n participant: parsed.participant,\n values: parsed.values,\n seen: parsed.seen,\n at: new Date(parsed.at),\n }\n for (const listener of listeners) listener(patch)\n }\n\n const getSubscriber = (): Promise<RedisConnection> => {\n subscriber ??= (async () => {\n const conn = (await client()).duplicate()\n conn.on('message', (channel, message) => {\n // wake() only resolves a promise; removal happens later via\n // disarm(), so iterating the live set is safe.\n const set = wakers.get(channel)\n if (set) {\n for (const wake of set) wake()\n }\n const listeners = presenceListeners.get(channel)\n if (listeners) deliverRemotePresence(listeners, message)\n })\n return conn\n })()\n return subscriber\n }\n\n /**\n * The subscriber, gated on connection readiness — for channel\n * commands only (`close` uses the ungated promise, so it can never\n * hang on an unreachable server). SUBSCRIBE carries redis's\n * ok-loading flag, so ioredis writes it mid-handshake, ahead of the\n * ready check's INFO — which then finds the connection already in\n * subscriber mode and fails the check (a spurious error event plus\n * a reconnect). ECHO has no such flag: it resolves only once the\n * connection is ready, so every channel command chained behind it\n * lands after the check.\n */\n let subscriberGate: Promise<RedisConnection> | null = null\n const gatedSubscriber = (): Promise<RedisConnection> => {\n subscriberGate ??= getSubscriber().then(async (conn) => {\n await conn.call('echo', 'ready')\n return conn\n })\n return subscriberGate\n }\n\n const acquireChannel = async (channel: string): Promise<void> => {\n const existing = subscriptions.get(channel)\n if (existing) {\n existing.refs += 1\n try {\n await existing.ready\n } catch (err) {\n existing.refs -= 1\n throw err\n }\n return\n }\n const lease = {\n refs: 1,\n // Lowercase on purpose: ioredis keys its subscriber-mode\n // bookkeeping (and reconnect resubscription) on the exact\n // command name.\n ready: gatedSubscriber().then((conn) => conn.call('subscribe', channel)),\n }\n subscriptions.set(channel, lease)\n try {\n await lease.ready\n } catch (err) {\n if (subscriptions.get(channel) === lease) subscriptions.delete(channel)\n throw err\n }\n }\n\n const releaseChannel = (channel: string): void => {\n const lease = subscriptions.get(channel)\n if (!lease) return\n lease.refs -= 1\n if (lease.refs > 0) return\n subscriptions.delete(channel)\n watchedRefreshedAt.delete(channel)\n void gatedSubscriber()\n .then((conn) => conn.call('unsubscribe', channel))\n .catch(() => {})\n }\n\n const armWaker = (\n channel: string,\n ): { wakeup: Promise<void>; wake: () => void; disarm: () => void } => {\n let wake!: () => void\n const wakeup = new Promise<void>((resolve) => {\n wake = resolve // assigned synchronously by the executor\n })\n let set = wakers.get(channel)\n if (!set) {\n set = new Set()\n wakers.set(channel, set)\n }\n set.add(wake)\n return {\n wakeup,\n wake,\n disarm: () => {\n set.delete(wake)\n if (set.size === 0) wakers.delete(channel)\n },\n }\n }\n\n return {\n append: core.append,\n read: core.read,\n claimAvailable: core.claimAvailable,\n renewClaims: core.renewClaims,\n completeAttempt: core.completeAttempt,\n failAttempt: core.failAttempt,\n readState: core.readState,\n putSnapshots: core.putSnapshots,\n\n presence: {\n async set(ns, participant, values, meta) {\n const patch = await core.presence.set(ns, participant, values, meta)\n if (!patch) return\n const channel = presenceChannel(ns)\n const listeners = presenceListeners.get(channel)\n if (listeners) {\n for (const listener of listeners) listener(patch)\n }\n // Broadcast for other instances — best-effort like the notify\n // PUBLISH (a failed one must never fail the applied write),\n // but awaited so a patch can never reach a subscribe() that\n // starts after this set resolved.\n const message = JSON.stringify({\n src: instanceId,\n participant: patch.participant,\n values: patch.values,\n seen: patch.seen,\n at: patch.at.getTime(),\n } satisfies PresenceWireMessage)\n await client()\n .then((c) => c.call('publish', channel, message))\n .catch(() => {})\n },\n\n read: core.presence.read,\n\n subscribe(ns, onPatch) {\n const channel = presenceChannel(ns)\n let listeners = presenceListeners.get(channel)\n if (!listeners) {\n listeners = new Set()\n presenceListeners.set(channel, listeners)\n }\n listeners.add(onPatch)\n // Fire-and-forget on the shared refcounted subscription: it\n // only carries remote patches, so settling late (or failing)\n // degrades cross-instance latency, never same-process\n // delivery.\n void acquireChannel(channel).catch(() => {})\n let stopped = false\n return () => {\n if (stopped) return\n stopped = true\n listeners.delete(onPatch)\n if (listeners.size === 0) presenceListeners.delete(channel)\n releaseChannel(channel)\n }\n },\n },\n\n stream(sessionId, opts) {\n const startAfter = opts?.startAfter ?? 0\n const channel = notifyChannel(sessionId)\n\n return {\n [Symbol.asyncIterator](): AsyncIterator<Event> {\n let last = startAfter\n let buffer: Event[] = []\n let closed = false\n let subscribed = false\n let pending: Sleeper | null = null\n let interrupt: (() => void) | null = null\n\n const release = (): void => {\n if (!subscribed) return\n subscribed = false\n releaseChannel(channel)\n }\n\n return {\n async next(): Promise<IteratorResult<Event>> {\n for (;;) {\n if (closed) {\n release()\n return { value: undefined, done: true }\n }\n const row = buffer.shift()\n if (row) {\n last = row.index\n return { value: row, done: false }\n }\n if (severed) {\n release()\n throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')\n }\n const waker = armWaker(channel)\n interrupt = waker.wake\n try {\n if (!subscribed) {\n // Subscribe before reading: anything appended\n // after the read lands as a wake-up, so nothing\n // can slip between catch-up and park.\n // oxlint-disable-next-line no-await-in-loop -- one-time setup\n await acquireChannel(channel)\n subscribed = true\n }\n // Mark watched before reading, for the same reason:\n // a write after this read sees the marker and\n // publishes. Throttled — one SET per safety period\n // per session per process, shared across feeds.\n const markedAt = watchedRefreshedAt.get(channel) ?? 0\n if (Date.now() - markedAt >= NOTIFY_TIMINGS.safetyReadMs) {\n watchedRefreshedAt.set(channel, Date.now())\n // oxlint-disable-next-line no-await-in-loop -- live-feed loop\n await core.markWatched(\n sessionId,\n NOTIFY_TIMINGS.safetyReadMs * 3,\n )\n }\n // oxlint-disable-next-line no-await-in-loop -- live-feed loop\n buffer = await core.readEvents(sessionId, last)\n if (buffer.length === 0 && !closed && !severed) {\n pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs)\n // oxlint-disable-next-line no-await-in-loop -- live-feed loop\n await Promise.race([waker.wakeup, pending.promise])\n pending.cancel()\n pending = null\n }\n } catch (err) {\n release()\n if (closed) return { value: undefined, done: true }\n if (err instanceof A2Error) throw err\n throw new A2Error(\n 'STORE_UNAVAILABLE',\n 'redis store operation failed',\n { cause: err },\n )\n } finally {\n waker.disarm()\n interrupt = null\n }\n }\n },\n async return(): Promise<IteratorResult<Event>> {\n closed = true\n pending?.cancel()\n interrupt?.() // wakes an in-flight next() immediately\n release()\n return { value: undefined, done: true }\n },\n }\n },\n }\n },\n\n async close() {\n severed = true\n for (const set of wakers.values()) {\n for (const wake of set) wake()\n }\n if (subscriber) {\n const sub = await subscriber.catch(() => null)\n sub?.disconnect()\n }\n const current = connection.peek()\n if (!current) return\n const c = await current.catch(() => null)\n c?.disconnect()\n },\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"store-redis.js","names":[],"sources":["../src/store-redis.ts"],"sourcesContent":["/**\n * experimental-a2/store-redis — the Redis-protocol store backend, on Redis\n * Streams. The storage semantics live in store-redis-core.ts (shared\n * with experimental-a2/store-redis-http); this module owns the connection and\n * the live feed.\n *\n * `stream()` is notify-driven: writes to watched sessions fire a\n * disposable PUBLISH wake-up (feeds hold a TTL'd presence marker the\n * write script checks, so unwatched sessions cost no wake-up), one\n * shared subscriber connection per backend serves every local feed,\n * and each wake triggers an `XRANGE` catch-up read. The notification\n * only decides when to read, never what — a lost one is healed by a\n * safety re-read (NOTIFY_TIMINGS), so delivery never depends on\n * pub/sub. Connections scale with processes (one command client plus\n * one subscriber), not with concurrent viewers.\n *\n * Works with any Redis-protocol server on a single instance or a\n * non-cluster provider (Upstash — durable by default — Redis, Valkey).\n * Cluster mode is out: the atomic scripts span keys. `ioredis` is an\n * optional peer dependency; pass `url`, or inject any client exposing\n * `call`/`duplicate`/`on`/`disconnect`.\n */\n\nimport { A2Error } from './errors.ts'\nimport {\n createRedisNotifyStore,\n type RedisConnection,\n} from './store-redis-notify.ts'\nimport { retryableLazy } from './retryable-lazy.ts'\nimport {\n RANDOM_IDS,\n SYSTEM_CLOCK,\n type A2Store,\n type Clock,\n type IdSource,\n} from './store.ts'\n\n/**\n * The minimal client this backend needs — `ioredis` matches it\n * structurally. `call` issues any command; `duplicate` opens the one\n * shared subscriber connection; `on` delivers its pub/sub messages.\n *\n * The client must restore its subscriptions after a reconnect\n * (`ioredis` does). One that doesn't stays correct — the safety\n * re-read delivers everything — but every live feed silently degrades\n * to safety-read latency from that point on.\n */\nexport type { RedisConnection } from './store-redis-notify.ts'\n\nexport type RedisStoreOptions = {\n /** Creates an `ioredis` client lazily (optional peer dep `ioredis`). */\n url?: string | undefined\n /** Bring your own client — anything `call`/`duplicate`/`on`/`disconnect`. */\n client?: RedisConnection\n /** Key prefix — isolates multiple apps on one Redis. Default `'a2'`. */\n keyPrefix?: string\n /** Injectable clock — every stored timestamp comes from here. */\n clock?: Clock\n /** Injectable id source for generated event ids. */\n ids?: IdSource\n}\n\nexport type RedisStore = A2Store & {\n /** Disconnect the command client and the shared subscriber. */\n close(): Promise<void>\n}\n\nexport function redis(options: RedisStoreOptions = {}): RedisStore {\n const clock = options.clock ?? SYSTEM_CLOCK\n const ids = options.ids ?? RANDOM_IDS\n const prefix = options.keyPrefix ?? 'a2'\n\n if (!options.client && !options.url) {\n throw new TypeError('redis() needs a url or an injected client')\n }\n\n // Lazy init — constructing the backend does no I/O.\n const connection = retryableLazy(async () => {\n if (options.client) return options.client\n const mod = await import('ioredis').catch(() => {\n throw new A2Error(\n 'STORE_NOT_CONFIGURED',\n \"experimental-a2/store-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client\",\n )\n })\n return new mod.default(options.url as string) as RedisConnection\n })\n const client = connection.get\n\n return createRedisNotifyStore({\n client,\n clock,\n ids,\n keyPrefix: prefix,\n async closeClient() {\n const current = connection.peek()\n if (!current) return\n const c = await current.catch(() => null)\n c?.disconnect()\n },\n })\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AAmEA,SAAgB,MAAM,UAA6B,CAAC,GAAe;CACjE,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,MAAM,QAAQ,OAAO;CAC3B,MAAM,SAAS,QAAQ,aAAa;CAEpC,IAAI,CAAC,QAAQ,UAAU,CAAC,QAAQ,KAC9B,MAAM,IAAI,UAAU,2CAA2C;CAIjE,MAAM,aAAa,cAAc,YAAY;EAC3C,IAAI,QAAQ,QAAQ,OAAO,QAAQ;EAOnC,OAAO,KAAI,OANO,OAAO,UAAU,CAAC,YAAY;GAC9C,MAAM,IAAI,QACR,wBACA,gIACF;EACF,CAAC,GAAA,CACc,QAAQ,QAAQ,GAAa;CAC9C,CAAC;CACD,MAAM,SAAS,WAAW;CAE1B,OAAO,uBAAuB;EAC5B;EACA;EACA;EACA,WAAW;EACX,MAAM,cAAc;GAClB,MAAM,UAAU,WAAW,KAAK;GAChC,IAAI,CAAC,SAAS;GAEd,CAAA,MADgB,QAAQ,YAAY,IAAI,EAAA,EACrC,WAAW;EAChB;CACF,CAAC;AACH"}
|
package/dist/store-sqlite.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as Clock, s as IdSource, t as A2Store } from "./store-
|
|
1
|
+
import { r as Clock, s as IdSource, t as A2Store } from "./store-DtDOWLSn.js";
|
|
2
2
|
//#region src/store-sqlite.d.ts
|
|
3
3
|
type SqliteStoreOptions = {
|
|
4
4
|
/** Database file path. Defaults to `.a2/dev.db`; `:memory:` works. */
|
package/dist/store-sqlite.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as A2Error } from "./errors-DCk6ch5n.js";
|
|
2
|
-
import { n as pollingStream } from "./store-polling-
|
|
2
|
+
import { n as pollingStream } from "./store-polling-CmxUbV93.js";
|
|
3
3
|
import { t as idempotentReplay } from "./idempotent-replay-DVOlyYbx.js";
|
|
4
4
|
import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
|
|
5
5
|
import { t as decodeReturnedEventIds } from "./store-codec-DTG0Ftek.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as A2Error } from "./errors-DCk6ch5n.js";
|
|
2
|
-
import { h as nullProtoRecord } from "./internal-
|
|
2
|
+
import { h as nullProtoRecord } from "./internal-Dq2qYxou.js";
|
|
3
3
|
//#region src/wire.ts
|
|
4
4
|
/**
|
|
5
5
|
* The wire format shared by server.fetch and experimental-a2/client
|
|
@@ -100,22 +100,23 @@ const ERROR_STATUS = {
|
|
|
100
100
|
PRESENCE_NOT_SUPPORTED: 500
|
|
101
101
|
};
|
|
102
102
|
function errorStatus(code) {
|
|
103
|
-
return ERROR_STATUS[code];
|
|
103
|
+
return ERROR_STATUS[isServerErrorCode(code) ? code : "STORE_UNAVAILABLE"];
|
|
104
104
|
}
|
|
105
105
|
function errorToWire(error) {
|
|
106
|
+
const failure = asA2Error(error);
|
|
106
107
|
const body = { error: {
|
|
107
|
-
code:
|
|
108
|
-
message:
|
|
108
|
+
code: failure.code,
|
|
109
|
+
message: failure.message
|
|
109
110
|
} };
|
|
110
|
-
if (
|
|
111
|
+
if (failure.details !== void 0) body.error.details = failure.details;
|
|
111
112
|
return body;
|
|
112
113
|
}
|
|
113
|
-
/** Wrap an arbitrary thrown value for the wire:
|
|
114
|
+
/** Wrap an arbitrary thrown value for the wire: known server codes pass through,
|
|
114
115
|
* anything else becomes STORE_UNAVAILABLE — from the client's
|
|
115
116
|
* perspective an unknown server failure is retryable-once, not a
|
|
116
117
|
* protocol contract. */
|
|
117
118
|
function asA2Error(error) {
|
|
118
|
-
return error instanceof A2Error ? error : new A2Error("STORE_UNAVAILABLE", "internal error", { cause: error });
|
|
119
|
+
return error instanceof A2Error && isServerErrorCode(error.code) ? error : new A2Error("STORE_UNAVAILABLE", "internal error", { cause: error });
|
|
119
120
|
}
|
|
120
121
|
/** Rebuild an A2Error from a wire body; null if the body isn't one. */
|
|
121
122
|
function errorFromWire(body) {
|
|
@@ -123,9 +124,10 @@ function errorFromWire(body) {
|
|
|
123
124
|
const err = body.error;
|
|
124
125
|
if (err === null || typeof err !== "object") return null;
|
|
125
126
|
const { code, message, details } = err;
|
|
126
|
-
if (typeof code !== "string" || !
|
|
127
|
+
if (typeof code !== "string" || !isServerErrorCode(code)) return null;
|
|
127
128
|
return new A2Error(code, String(message ?? code), { details });
|
|
128
129
|
}
|
|
130
|
+
const isServerErrorCode = (code) => Object.hasOwn(ERROR_STATUS, code);
|
|
129
131
|
const SOCKET_PING_FRAME = JSON.stringify({ kind: "ping" });
|
|
130
132
|
/** One stream item as a socket frame — `sseResponse`'s framing over
|
|
131
133
|
* the same codecs, with `kind` instead of an SSE event name. On a
|
|
@@ -172,11 +174,12 @@ function socketSubscribedFor(sessionId) {
|
|
|
172
174
|
}
|
|
173
175
|
/** The subscription-over notice: with a `reason` the server rejected
|
|
174
176
|
* or lost the session's stream, without one it ended cleanly. */
|
|
175
|
-
function socketUnsubscribedFor(sessionId, reason) {
|
|
177
|
+
function socketUnsubscribedFor(sessionId, reason, error) {
|
|
176
178
|
return JSON.stringify({
|
|
177
179
|
kind: "unsubscribed",
|
|
178
180
|
sessionId,
|
|
179
|
-
...reason === void 0 ? {} : { reason }
|
|
181
|
+
...reason === void 0 ? {} : { reason },
|
|
182
|
+
...error === void 0 ? {} : errorToWire(error)
|
|
180
183
|
});
|
|
181
184
|
}
|
|
182
185
|
function parseSocketFrame(data) {
|
|
@@ -238,13 +241,12 @@ function parseSocketFrame(data) {
|
|
|
238
241
|
const sessionId = frame["sessionId"];
|
|
239
242
|
if (typeof sessionId !== "string") return null;
|
|
240
243
|
const reason = frame["reason"];
|
|
241
|
-
|
|
244
|
+
const error = errorFromWire(parsed) ?? ("error" in frame ? new A2Error("STORE_UNAVAILABLE", "unintelligible subscription error") : null);
|
|
245
|
+
return {
|
|
242
246
|
kind: "unsubscribed",
|
|
243
247
|
sessionId,
|
|
244
|
-
reason
|
|
245
|
-
|
|
246
|
-
kind: "unsubscribed",
|
|
247
|
-
sessionId
|
|
248
|
+
...typeof reason === "string" ? { reason } : {},
|
|
249
|
+
...error === null ? {} : { error }
|
|
248
250
|
};
|
|
249
251
|
}
|
|
250
252
|
case "ack": {
|
|
@@ -282,4 +284,4 @@ const sessionTag = (frame) => {
|
|
|
282
284
|
//#endregion
|
|
283
285
|
export { socketErrorAckFor as _, errorToWire as a, socketUnsubscribedFor as b, isWireEvent as c, parseSocketFrame as d, presencePatchFromWire as f, socketAckFor as g, presenceSnapshotToWire as h, errorStatus as i, isWirePresencePatch as l, presenceSnapshotFromWire as m, asA2Error as n, eventFromWire as o, presencePatchToWire as p, errorFromWire as r, eventToWire as s, SOCKET_PING_FRAME as t, isWirePresenceSnapshot as u, socketFrameFor as v, socketSubscribedFor as y };
|
|
284
286
|
|
|
285
|
-
//# sourceMappingURL=wire
|
|
287
|
+
//# sourceMappingURL=wire-BO5wWCb1.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wire-BO5wWCb1.js","names":[],"sources":["../src/wire.ts"],"sourcesContent":["/**\n * The wire format shared by server.fetch and experimental-a2/client\n * (browser side): events as JSON with ISO timestamps, and the A2Error\n * envelope. Internal module — both entry points re-export what's\n * public.\n */\n\nimport { A2Error, type A2ErrorCode, type A2ServerErrorCode } from './errors.ts'\nimport { nullProtoRecord } from './internal.ts'\nimport type { Event } from './store.ts'\nimport type {\n PresenceMap,\n PresencePatch,\n PresenceSnapshot,\n} from './contract.ts'\n\nexport type WireEvent = {\n id: string\n type: string\n payload: unknown\n index: number\n sessionId: string\n /** ISO 8601 — revived to a Date on the client so reducers see the\n * same shape on both sides of the wire. */\n createdAt: string\n}\n\nexport function eventToWire(event: Event): WireEvent {\n return {\n id: event.id,\n type: event.type,\n payload: event.payload,\n index: event.index,\n sessionId: event.sessionId,\n createdAt: event.createdAt.toISOString(),\n }\n}\n\nexport function eventFromWire(wire: WireEvent): Event {\n return {\n id: wire.id,\n type: wire.type,\n payload: wire.payload,\n index: wire.index,\n sessionId: wire.sessionId,\n createdAt: new Date(wire.createdAt),\n }\n}\n\nexport function isWireEvent(value: unknown): value is WireEvent {\n if (value === null || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n return (\n typeof v['id'] === 'string' &&\n typeof v['type'] === 'string' &&\n typeof v['index'] === 'number' &&\n typeof v['sessionId'] === 'string' &&\n typeof v['createdAt'] === 'string'\n )\n}\n\n// ── the presence wire pair ───────────────────────────────────────────\n\n/** The `event: presence` SSE frame body: one patch as JSON. */\nexport type WirePresencePatch = {\n participant: string\n values: Record<string, unknown>\n seen: number\n /** ISO 8601 — revived to a Date on the client. */\n at: string\n}\n\n/**\n * The `event: presence-snapshot` SSE frame body: the in-memory\n * `PresenceSnapshot` shape verbatim, with each per-field `at`\n * serialized to ISO 8601.\n */\nexport type WirePresenceSnapshot = {\n snapshot: {\n [participant: string]: {\n [field: string]: { value: unknown; seen: number; at: string }\n }\n }\n}\n\nexport function presencePatchToWire(patch: PresencePatch): WirePresencePatch {\n return {\n participant: patch.participant,\n values: patch.values,\n seen: patch.seen,\n at: patch.at.toISOString(),\n }\n}\n\nexport function presencePatchFromWire(wire: WirePresencePatch): PresencePatch {\n return {\n participant: wire.participant,\n values: wire.values,\n seen: wire.seen,\n at: new Date(wire.at),\n }\n}\n\n// Participant and field keys come off the wire, so every object keyed\n// by them is built null-prototype — see `nullProtoRecord`.\nexport function presenceSnapshotToWire(\n snapshot: PresenceSnapshot,\n): WirePresenceSnapshot {\n const wire: WirePresenceSnapshot['snapshot'] = nullProtoRecord()\n for (const [participant, fields] of Object.entries(snapshot.snapshot)) {\n const wireFields: WirePresenceSnapshot['snapshot'][string] =\n nullProtoRecord()\n for (const [field, entry] of Object.entries(fields)) {\n if (entry === undefined) continue\n wireFields[field] = {\n value: entry.value,\n seen: entry.seen,\n at: entry.at.toISOString(),\n }\n }\n wire[participant] = wireFields\n }\n return { snapshot: wire }\n}\n\nexport function presenceSnapshotFromWire(\n wire: WirePresenceSnapshot,\n): PresenceSnapshot {\n const map: PresenceMap = nullProtoRecord()\n for (const [participant, fields] of Object.entries(wire.snapshot)) {\n const revived: PresenceMap[string] = nullProtoRecord()\n for (const [field, entry] of Object.entries(fields)) {\n revived[field] = {\n value: entry.value,\n seen: entry.seen,\n at: new Date(entry.at),\n }\n }\n map[participant] = revived\n }\n return { snapshot: map }\n}\n\nexport function isWirePresencePatch(\n value: unknown,\n): value is WirePresencePatch {\n if (value === null || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n return (\n typeof v['participant'] === 'string' &&\n v['values'] !== null &&\n typeof v['values'] === 'object' &&\n !Array.isArray(v['values']) &&\n typeof v['seen'] === 'number' &&\n typeof v['at'] === 'string'\n )\n}\n\nexport function isWirePresenceSnapshot(\n value: unknown,\n): value is WirePresenceSnapshot {\n if (value === null || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n return (\n v['snapshot'] !== null &&\n typeof v['snapshot'] === 'object' &&\n !Array.isArray(v['snapshot'])\n )\n}\n\n// ── the A2Error envelope ─────────────────────────────────────────────\n\nexport type WireError = {\n error: { code: A2ServerErrorCode; message: string; details?: unknown }\n}\n\nconst ERROR_STATUS: Record<A2ServerErrorCode, number> = {\n INVALID_PAYLOAD: 400,\n FORBIDDEN: 403,\n UNKNOWN_EVENT_TYPE: 400,\n PARTIAL_DUPLICATE_BATCH: 400,\n SUPERSEDED_ATTEMPT: 409,\n CLAIM_EXPIRED: 409,\n STORE_UNAVAILABLE: 503,\n STORE_NOT_CONFIGURED: 500,\n UNKNOWN_PRESENCE_FIELD: 400,\n PRESENCE_NOT_SUPPORTED: 500,\n}\n\nexport function errorStatus(code: A2ErrorCode): number {\n return ERROR_STATUS[isServerErrorCode(code) ? code : 'STORE_UNAVAILABLE']\n}\n\nexport function errorToWire(error: A2Error): WireError {\n const failure = asA2Error(error)\n const body: WireError = {\n error: {\n code: failure.code as A2ServerErrorCode,\n message: failure.message,\n },\n }\n if (failure.details !== undefined) body.error.details = failure.details\n return body\n}\n\n/** Wrap an arbitrary thrown value for the wire: known server codes pass through,\n * anything else becomes STORE_UNAVAILABLE — from the client's\n * perspective an unknown server failure is retryable-once, not a\n * protocol contract. */\nexport function asA2Error(error: unknown): A2Error {\n return error instanceof A2Error && isServerErrorCode(error.code)\n ? error\n : new A2Error('STORE_UNAVAILABLE', 'internal error', { cause: error })\n}\n\n/** Rebuild an A2Error from a wire body; null if the body isn't one. */\nexport function errorFromWire(body: unknown): A2Error | null {\n if (body === null || typeof body !== 'object') return null\n const err = (body as { error?: unknown }).error\n if (err === null || typeof err !== 'object') return null\n const { code, message, details } = err as Record<string, unknown>\n if (typeof code !== 'string' || !isServerErrorCode(code)) return null\n return new A2Error(code, String(message ?? code), {\n details,\n })\n}\n\nconst isServerErrorCode = (code: string): code is A2ServerErrorCode =>\n Object.hasOwn(ERROR_STATUS, code)\n\n// ── the ws frame layer ───────────────────────────────────────────────\n// The SSE lanes reframed for a socket (specs/a2-api.md §13): every\n// message is one JSON text frame, and a frame is its wire payload plus\n// a `kind` discriminant (plus `req` where a reply must correlate).\n// Unknown kinds are skipped by both sides — the same\n// forward-compatibility rule as named SSE frames.\n//\n// The multiplexed superset rides the same frames: `subscribe` /\n// `unsubscribe` up-frames open and close per-session lanes on one\n// socket, `sessionId` tags route everything else. Single-session\n// frames (no tags) keep parsing unchanged — a route opts into\n// multiplexing by choosing the multi-session server handler, never by\n// breaking the old protocol.\n\n/**\n * Server → client, parsed: a live-stream item, a push ack, a\n * subscription lifecycle notice, or the heartbeat. `parseSocketFrame`\n * yields these; frames of unknown kind (or ones failing their shape\n * guard) come back `null`. `sessionId` is present on frames from a\n * multiplexed socket and absent on a single-session one.\n */\nexport type SocketDownFrame =\n | { kind: 'event'; event: WireEvent }\n | { kind: 'presence'; patch: WirePresencePatch; sessionId?: string }\n | {\n kind: 'presence-snapshot'\n snapshot: WirePresenceSnapshot\n sessionId?: string\n }\n | { kind: 'ack'; req: number; events: WireEvent[]; sessionId?: string }\n | { kind: 'ack'; req: number; error: A2Error; sessionId?: string }\n | { kind: 'subscribed'; sessionId: string }\n | {\n kind: 'unsubscribed'\n sessionId: string\n reason?: string\n error?: A2Error\n }\n | { kind: 'ping' }\n\n/**\n * Client → server: the push envelope's two planes. `sessionId` is\n * implied by the socket on a single-session connection and required by\n * the multiplexed handler. Plain JSON on the wire; the server parses\n * and validates them through the same seams as `parsePushBody`.\n */\nexport type SocketPushFrame = {\n kind: 'push'\n /** Client-local ack correlator — opaque to the server. */\n req: number\n events: Array<{ type: string; payload: unknown; id?: string }>\n sessionId?: string\n}\n\nexport type SocketPresenceFrame = {\n kind: 'presence'\n participant: string\n values: Record<string, unknown>\n seen?: number\n at?: number\n sessionId?: string\n}\n\n/** Open per-session lanes on a multiplexed socket. `index` is each\n * session's exclusive resume frontier — `stream({ startAfter })`. */\nexport type SocketSubscribeFrame = {\n kind: 'subscribe'\n sessions: Array<{ id: string; index: number }>\n}\n\nexport type SocketUnsubscribeFrame = {\n kind: 'unsubscribe'\n sessions: string[]\n}\n\nexport type SocketUpFrame =\n | SocketPushFrame\n | SocketPresenceFrame\n | SocketSubscribeFrame\n | SocketUnsubscribeFrame\n\nexport const SOCKET_PING_FRAME: string = JSON.stringify({ kind: 'ping' })\n\n/** One stream item as a socket frame — `sseResponse`'s framing over\n * the same codecs, with `kind` instead of an SSE event name. On a\n * multiplexed socket presence frames carry the `sessionId` tag; events\n * already carry theirs in the wire event. */\nexport function socketFrameFor(\n item: Event | PresencePatch | PresenceSnapshot,\n sessionId?: string,\n): string {\n const tag = sessionId === undefined ? {} : { sessionId }\n if ('snapshot' in item) {\n return JSON.stringify({\n kind: 'presence-snapshot',\n ...presenceSnapshotToWire(item),\n ...tag,\n })\n }\n if ('participant' in item) {\n return JSON.stringify({\n kind: 'presence',\n ...presencePatchToWire(item),\n ...tag,\n })\n }\n return JSON.stringify({ kind: 'event', ...eventToWire(item) })\n}\n\nexport function socketAckFor(\n req: number,\n events: Event[],\n sessionId?: string,\n): string {\n return JSON.stringify({\n kind: 'ack',\n req,\n events: events.map(eventToWire),\n ...(sessionId === undefined ? {} : { sessionId }),\n })\n}\n\nexport function socketErrorAckFor(\n req: number,\n error: A2Error,\n sessionId?: string,\n): string {\n return JSON.stringify({\n kind: 'ack',\n req,\n ...errorToWire(error),\n ...(sessionId === undefined ? {} : { sessionId }),\n })\n}\n\nexport function socketSubscribedFor(sessionId: string): string {\n return JSON.stringify({ kind: 'subscribed', sessionId })\n}\n\n/** The subscription-over notice: with a `reason` the server rejected\n * or lost the session's stream, without one it ended cleanly. */\nexport function socketUnsubscribedFor(\n sessionId: string,\n reason?: string,\n error?: A2Error,\n): string {\n return JSON.stringify({\n kind: 'unsubscribed',\n sessionId,\n ...(reason === undefined ? {} : { reason }),\n ...(error === undefined ? {} : errorToWire(error)),\n })\n}\n\nexport function parseSocketFrame(data: string): SocketDownFrame | null {\n let parsed: unknown\n try {\n parsed = JSON.parse(data)\n } catch {\n return null\n }\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\n return null\n }\n const frame = parsed as Record<string, unknown>\n switch (frame['kind']) {\n case 'ping':\n return { kind: 'ping' }\n case 'event': {\n if (!isWireEvent(parsed)) return null\n const { id, type, payload, index, sessionId, createdAt } = parsed\n return {\n kind: 'event',\n event: { id, type, payload, index, sessionId, createdAt },\n }\n }\n case 'presence': {\n if (!isWirePresencePatch(parsed)) return null\n const { participant, values, seen, at } = parsed\n return {\n kind: 'presence',\n patch: { participant, values, seen, at },\n ...sessionTag(frame),\n }\n }\n case 'presence-snapshot': {\n if (!isWirePresenceSnapshot(parsed)) return null\n return {\n kind: 'presence-snapshot',\n snapshot: { snapshot: parsed.snapshot },\n ...sessionTag(frame),\n }\n }\n case 'subscribed': {\n const sessionId = frame['sessionId']\n if (typeof sessionId !== 'string') return null\n return { kind: 'subscribed', sessionId }\n }\n case 'unsubscribed': {\n const sessionId = frame['sessionId']\n if (typeof sessionId !== 'string') return null\n const reason = frame['reason']\n const error =\n errorFromWire(parsed) ??\n ('error' in frame\n ? new A2Error(\n 'STORE_UNAVAILABLE',\n 'unintelligible subscription error',\n )\n : null)\n return {\n kind: 'unsubscribed',\n sessionId,\n ...(typeof reason === 'string' ? { reason } : {}),\n ...(error === null ? {} : { error }),\n }\n }\n case 'ack': {\n const req = frame['req']\n if (typeof req !== 'number') return null\n const tag = sessionTag(frame)\n const events = frame['events']\n if (Array.isArray(events) && events.every(isWireEvent)) {\n return { kind: 'ack', req, events, ...tag }\n }\n const error = errorFromWire(parsed)\n if (error) return { kind: 'ack', req, error, ...tag }\n // A correlatable ack must never be dropped: its waiter would hang\n // forever behind a healthy socket (pings keep the watchdog fed).\n // An outcome this client cannot interpret — an error code from a\n // newer server, an event shape that fails a guard — degrades to\n // a lost ack: retryable, and the client-generated ids make the\n // retry idempotent even if the append actually committed.\n return {\n kind: 'ack',\n req,\n error: new A2Error('STORE_UNAVAILABLE', 'unintelligible ack'),\n ...tag,\n }\n }\n default:\n return null\n }\n}\n\nconst sessionTag = (frame: Record<string, unknown>): { sessionId?: string } => {\n const sessionId = frame['sessionId']\n return typeof sessionId === 'string' ? { sessionId } : {}\n}\n"],"mappings":"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"}
|
|
@@ -83,9 +83,14 @@ the refusal's message when the handler says no, `400` for an undeclared
|
|
|
83
83
|
event. `messageId` is optional; send one to make retrying the request
|
|
84
84
|
idempotent.
|
|
85
85
|
|
|
86
|
+
`GET ?snapshot=1` reads the current `{ state, index }` without opening a
|
|
87
|
+
stream. It passes through `authorize` as `{ type: 'state', id }` and uses
|
|
88
|
+
the same `view` projection as streams and call answers. Snapshot and call
|
|
89
|
+
responses use `Cache-Control: private, no-store`.
|
|
90
|
+
|
|
86
91
|
## Authorize
|
|
87
92
|
|
|
88
|
-
`authorize` is per-operation policy, checked before any write or
|
|
93
|
+
`authorize` is per-operation policy, checked before any read, write, or
|
|
89
94
|
subscription. Authentication (who is asking) stays in your route;
|
|
90
95
|
`authorize` decides whether this operation is allowed.
|
|
91
96
|
|
|
@@ -105,8 +110,9 @@ export async function POST(
|
|
|
105
110
|
}
|
|
106
111
|
```
|
|
107
112
|
|
|
108
|
-
The operation is a discriminated union: `{ type: '
|
|
109
|
-
|
|
113
|
+
The operation is a discriminated union: `{ type: 'state', id }` for
|
|
114
|
+
snapshot reads, `{ type: 'stream', id, startAfter }` for subscriptions,
|
|
115
|
+
`{ type: 'call', id, event, input,
|
|
110
116
|
messageId? }` for calls. Returning `false` answers `403`.
|
|
111
117
|
|
|
112
118
|
## Views: what each mount shows
|
|
@@ -114,8 +120,8 @@ messageId? }` for calls. Returning `false` answers `403`.
|
|
|
114
120
|
State can be mixed-audience: internal bookkeeping next to public
|
|
115
121
|
fields. The definition stays audience-blind; audiences are a route
|
|
116
122
|
concern, like auth. A `view` is a request-time projection applied to
|
|
117
|
-
everything that mount serves: every state frame on the stream
|
|
118
|
-
every call answer (
|
|
123
|
+
everything that mount serves: snapshots, every state frame on the stream,
|
|
124
|
+
and every call answer (they must agree, or the call lane would leak
|
|
119
125
|
what the stream hides).
|
|
120
126
|
|
|
121
127
|
```ts
|
|
@@ -174,6 +180,16 @@ server module never enters a client bundle. On a view-projected mount,
|
|
|
174
180
|
pass the projected shape as the second type argument
|
|
175
181
|
(`createActorClient<typeof vault, PublicVault>`) so answers carry it.
|
|
176
182
|
|
|
183
|
+
`client.state()` reads a fresh snapshot. The optional `onSnapshot`
|
|
184
|
+
callback receives successful state reads and call answers before their
|
|
185
|
+
promises resolve. It receives each answer, including older results from
|
|
186
|
+
idempotent retries; consumers compare indexes before replacing live state.
|
|
187
|
+
|
|
188
|
+
`ActorRequestError` carries an HTTP `status` when available. Network
|
|
189
|
+
failures, timeouts, and server errors do not establish whether a call
|
|
190
|
+
committed. Retry with the same explicit `id` and input to recover its
|
|
191
|
+
original answer. A client does not retry calls automatically.
|
|
192
|
+
|
|
177
193
|
## React
|
|
178
194
|
|
|
179
195
|
One hook. The server component hands down the first fold, the hook
|
|
@@ -229,14 +245,18 @@ export function VaultClient({
|
|
|
229
245
|
|
|
230
246
|
What the hook gives you:
|
|
231
247
|
|
|
232
|
-
- **`state`**: the live view.
|
|
248
|
+
- **`state`**: the live view. Call answers and stream commits update it;
|
|
233
249
|
the fold is library code, so no reducers, schemas, or contracts
|
|
234
250
|
appear in browser code.
|
|
235
251
|
- **`call`**: the same typed surface as `createActorClient`, because it
|
|
236
252
|
is one; `useActor` is a thin React wrapper over the client primitive
|
|
237
253
|
and the stream.
|
|
238
|
-
- **`index`**: the
|
|
239
|
-
feed
|
|
254
|
+
- **`index`**: the latest adopted state index, and **`events`**: the
|
|
255
|
+
state-commit feed observed through the stream, starting after the initial
|
|
256
|
+
snapshot. Call answers and refreshes can advance state before the feed
|
|
257
|
+
catches up; delayed commits still enter the feed once.
|
|
258
|
+
- **`refresh()`**: reads a fresh snapshot and adopts it before resolving.
|
|
259
|
+
Use it after a refusal or when returning to a suspended browser tab.
|
|
240
260
|
- **`connection`**: the same discriminated union as
|
|
241
261
|
[`useSession`](/guides/react#the-client-component), heartbeat
|
|
242
262
|
watchdog included.
|
|
@@ -247,6 +267,18 @@ apply early; a local guess would be wrong exactly when the actor
|
|
|
247
267
|
matters. If a control needs pending UI, `await call.deposit(...)` is a
|
|
248
268
|
promise like any other.
|
|
249
269
|
|
|
270
|
+
Successful calls update the shared client state before their promises
|
|
271
|
+
resolve. Snapshots only advance state: an older call answer or delayed
|
|
272
|
+
stream frame never replaces a newer state. React renders that update on
|
|
273
|
+
its normal schedule. A call still returns its own answer, even if a newer
|
|
274
|
+
state has already arrived. Refusals do not change state or trigger an
|
|
275
|
+
automatic refresh.
|
|
276
|
+
|
|
277
|
+
The stream tracks its own position for deduplication and reconnects.
|
|
278
|
+
Adopting a newer snapshot does not skip stream events: delayed commits
|
|
279
|
+
enter `events` without replacing newer state. Events from before the
|
|
280
|
+
initial snapshot are not loaded into this feed.
|
|
281
|
+
|
|
250
282
|
On a view-projected mount, pass the projected shape as the second type
|
|
251
283
|
argument (`useActor<typeof vault, PublicVault>`); `initial`, `state`,
|
|
252
284
|
and the call answers all carry it.
|
package/docs/guides/03-react.mdx
CHANGED
|
@@ -170,27 +170,29 @@ What the hook gives you:
|
|
|
170
170
|
- **`events`**: the raw events this client has observed or was explicitly
|
|
171
171
|
seeded with. With only the server snapshot, it begins after `initialIndex`.
|
|
172
172
|
Use it for UI that wants the log itself: an activity feed, a debug panel.
|
|
173
|
-
Earlier events are not needed to hydrate `state`.
|
|
173
|
+
Earlier events are not needed to hydrate `state`. Later hydration can
|
|
174
|
+
advance state ahead of this feed; the stream keeps its own resume position
|
|
175
|
+
and adds delayed events without folding them again.
|
|
174
176
|
- **`loadHistory`**: backscroll. `loadHistory({ before?, limit? })`
|
|
175
177
|
fetches a bounded slice of the log from below the frontier (the same
|
|
176
178
|
route, `gte`/`lte` query parameters) and merges it into `events`:
|
|
177
179
|
deduped, ordered, shared across every handle of the session. By
|
|
178
180
|
default each call walks backward 50 events at a time from the oldest
|
|
179
|
-
one loaded
|
|
180
|
-
|
|
181
|
-
the
|
|
182
|
-
|
|
183
|
-
backscrolled events are display data. Calls serialize per session, so
|
|
181
|
+
one loaded, including after a newer snapshot hydrates the session.
|
|
182
|
+
Pass an explicit `before` to read another range. It never touches `state`
|
|
183
|
+
or the optimistic overlay; backscrolled events are display data.
|
|
184
|
+
Calls serialize per session, so
|
|
184
185
|
a double-tap never fetches the same range twice. The `ws` api has no
|
|
185
186
|
history lane; `loadHistory` throws a `TypeError` there.
|
|
186
187
|
- **`history`**: backscroll progress, `{ loading, complete,
|
|
187
188
|
oldestLoaded }`. `complete` means the feed reaches index 1 (or the
|
|
188
189
|
log is empty): nothing older is left, hide the "load older" button.
|
|
189
|
-
- **`index`**: the
|
|
190
|
-
|
|
190
|
+
- **`index`**: the latest server-confirmed state index, advanced by
|
|
191
|
+
snapshots and streamed events. This is the `lastSeenIndex` that makes
|
|
191
192
|
[cancellation](/guides/cancellation) exact.
|
|
192
193
|
- **`connection`**: a discriminated union of `{ status: 'idle' }`,
|
|
193
194
|
`{ status: 'connecting', reconnects, error }`,
|
|
195
|
+
`{ status: 'paused', reconnects, error }`,
|
|
194
196
|
`{ status: 'live', reconnects }`, or `{ status: 'closed' }`. A status
|
|
195
197
|
dot is one ternary away; "reconnecting…" is
|
|
196
198
|
`status === 'connecting' && reconnects > 0`; `error` (why the last
|
|
@@ -206,6 +208,46 @@ drops. Components that only write don't need the hook; `POST` to the
|
|
|
206
208
|
route directly, or call `ordersClient.session(id).push(...)` so a later
|
|
207
209
|
provider sees the same optimistic session.
|
|
208
210
|
|
|
211
|
+
Polling streams start fast and back off to a two-second interval when idle.
|
|
212
|
+
Idle is a polling state; the connection remains live and heartbeats continue.
|
|
213
|
+
When you call `push()` on an idle SSE stream, the client refreshes that stream
|
|
214
|
+
immediately, without reconnect backoff. It resumes from its existing `index`,
|
|
215
|
+
so concurrent events before the write's acknowledged index are still read.
|
|
216
|
+
Overlapping pushes share the refreshed stream. If a slow push is still pending
|
|
217
|
+
when the stream idles again, or its acknowledged events have not arrived, the
|
|
218
|
+
client refreshes again. WebSockets wake the session's attached read loop
|
|
219
|
+
without replacing the socket. This is internal and requires no component
|
|
220
|
+
configuration.
|
|
221
|
+
|
|
222
|
+
Use `reconnect()` after a custom REST request commits a change outside
|
|
223
|
+
`push()`. It is available on the session client and the `useSession()` result.
|
|
224
|
+
It immediately restarts the live subscription from its current `index`, even
|
|
225
|
+
when the stream is active. State, optimistic writes, and connection leases
|
|
226
|
+
are preserved. Without an active lease it does nothing.
|
|
227
|
+
|
|
228
|
+
`reconnect()` returns `void`; observe `connection` and `index` for progress.
|
|
229
|
+
SSE opens a fresh GET. A single-session WebSocket opens a fresh socket; a
|
|
230
|
+
multiplexed WebSocket re-subscribes only this session on its existing socket.
|
|
231
|
+
Other subscribed sessions keep running. Any outstanding write interrupted by
|
|
232
|
+
a socket replacement uses the normal idempotent retry path.
|
|
233
|
+
|
|
234
|
+
The server stops the old session lane before authorizing its replacement.
|
|
235
|
+
That lane stays closed if authorization denies access or fails. Successful
|
|
236
|
+
authorization catches up from the cursor. Already accepted writes can finish,
|
|
237
|
+
and failed subscription attempts follow the normal reconnect retry policy.
|
|
238
|
+
|
|
239
|
+
Configure `createClient({ reconnect })` on a shared client to choose each
|
|
240
|
+
retry's delay or return `false` to pause automatic reconnection. The callback
|
|
241
|
+
receives `{ sessionId, error, attempt }`; `error` is an `A2ClientError`, or
|
|
242
|
+
`null` when the stream ended cleanly. `attempt` starts at 1 and resets when
|
|
243
|
+
the connection becomes live or you call `reconnect()` explicitly.
|
|
244
|
+
|
|
245
|
+
A paused connection keeps the hydrated session, its writer, and its existing
|
|
246
|
+
state. Observe `connection.status === 'paused'` and use the hook's
|
|
247
|
+
`reconnect()` to resume when your application is ready. New consumers and
|
|
248
|
+
pushes do not automatically resume it. Configure the policy where you create
|
|
249
|
+
the client, without changing the hook's `hydrate` input.
|
|
250
|
+
|
|
209
251
|
That identity is an in-memory L1, not another source of truth. Repeated
|
|
210
252
|
`session(id)` calls reuse it while active and for five idle minutes by
|
|
211
253
|
default. A newer server fold advances it, pending pushes stay overlaid,
|
|
@@ -218,8 +260,8 @@ session open, the first lease connects and the last release closes
|
|
|
218
260
|
|
|
219
261
|
One detail worth knowing: every push carries a client-generated event id.
|
|
220
262
|
That id is how the ack finds its optimistic entry, and it makes retrying
|
|
221
|
-
a failed `POST` idempotent
|
|
222
|
-
|
|
263
|
+
a failed `POST` idempotent. `push` retries `STORE_UNAVAILABLE` and client
|
|
264
|
+
transport failures, with a three-attempt budget.
|
|
223
265
|
|
|
224
266
|
Optimistic pushes are also what make [cancellation](/guides/cancellation)
|
|
225
267
|
feel instant: the `cancelled` event folds locally before the server ever
|
|
@@ -34,13 +34,48 @@ The Postgres backend uses real transactions; appends serialize per
|
|
|
34
34
|
session on an advisory lock, while handler claims remain concurrent. The live
|
|
35
35
|
stream polls the store with an
|
|
36
36
|
activity-adaptive cadence: 25ms while a session is producing events
|
|
37
|
-
(a token stream reads smoothly, not in clumps), backing off to
|
|
38
|
-
when it goes quiet
|
|
39
|
-
any API change). Any Postgres works: Neon, Supabase, RDS, your own
|
|
37
|
+
(a token stream reads smoothly, not in clumps), backing off to two seconds
|
|
38
|
+
when it goes quiet. Any Postgres works: Neon, Supabase, RDS, your own
|
|
40
39
|
box; transaction-mode poolers included, which is exactly why polling
|
|
41
40
|
is the default. `pg` is an optional peer dependency; pass
|
|
42
41
|
`connectionString`, or inject your own pool as `client`.
|
|
43
42
|
|
|
43
|
+
To use native Postgres notifications, pass `listenConnectionString` alongside
|
|
44
|
+
`connectionString` (or `client`). It must point to the same database through a
|
|
45
|
+
direct connection. Stores with matching connection strings, Postgres environment
|
|
46
|
+
settings, and driver defaults share one query pool and one dedicated listener
|
|
47
|
+
within the same JavaScript runtime, including across separately loaded copies
|
|
48
|
+
of A2. Owned pools also share schema initialization for each schema revision.
|
|
49
|
+
The listener handles every store's event and presence channels.
|
|
50
|
+
|
|
51
|
+
On Vercel, pools created by A2 use the current request's `waitUntil` internally
|
|
52
|
+
to let idle connections close before the instance suspends. Nearby requests
|
|
53
|
+
reuse those connections. This adds no database queries and needs no setup.
|
|
54
|
+
When you supply `client`, you manage its pool's platform lifecycle.
|
|
55
|
+
|
|
56
|
+
Closing a store stops its own subscriptions. The listener closes when its last
|
|
57
|
+
subscriber leaves; the query pool closes when its last store releases it.
|
|
58
|
+
Injected clients are shared by object identity. Each store keeps its own clock,
|
|
59
|
+
ID generator, cursors, and presence state. Different serverless instances still
|
|
60
|
+
have separate pools and listeners, so the direct connection needs database
|
|
61
|
+
connection headroom.
|
|
62
|
+
|
|
63
|
+
Enabling this option installs transactional notification triggers on A2's event
|
|
64
|
+
and presence tables. They also cover writes from instances that use only the
|
|
65
|
+
pooled connection. The triggers remain installed when listeners disconnect.
|
|
66
|
+
Notifications are delivered after commit; a reconnect catches up from the
|
|
67
|
+
cursor. Native event and presence subscriptions also reconcile every ten
|
|
68
|
+
seconds. Without `listenConnectionString`, the store uses adaptive polling.
|
|
69
|
+
|
|
70
|
+
An idle stream stays connected and keeps receiving heartbeats. A new event
|
|
71
|
+
returns polling to its fast cadence. A local `session.push()` refreshes an
|
|
72
|
+
idle SSE stream immediately from its current cursor, even when the write and
|
|
73
|
+
stream routes run in different invocations. WebSocket pushes wake the attached
|
|
74
|
+
stream without reopening the socket. A stream that becomes idle while a push
|
|
75
|
+
is pending, or before observing its acknowledgement, wakes again. An update
|
|
76
|
+
from another client can take up to the idle interval plus query latency to
|
|
77
|
+
arrive after a quiet period.
|
|
78
|
+
|
|
44
79
|
Prefer Redis? `redis({ url })` from `experimental-a2/store-redis` stores each
|
|
45
80
|
session as a Redis Stream and streams push-natively: writes to watched
|
|
46
81
|
sessions publish a disposable wake-up (sessions nobody watches cost no extra
|
|
@@ -51,8 +86,22 @@ Connections scale with your processes, not with your audience.
|
|
|
51
86
|
Works on single instances and non-cluster providers such as Upstash, where
|
|
52
87
|
durability is on by default. When only a REST API is available,
|
|
53
88
|
`redisHttp({ url, token })` from `experimental-a2/store-redis-http` speaks the
|
|
54
|
-
same storage over `fetch
|
|
55
|
-
|
|
89
|
+
same storage over `fetch`. Live events and presence use Upstash’s HTTP
|
|
90
|
+
`SUBSCRIBE` endpoint (SSE), with the same URL and token. Each active channel
|
|
91
|
+
holds one outbound HTTP subscription per store instance, shared by its local
|
|
92
|
+
viewers and closed when the last viewer leaves. Writes to watched sessions
|
|
93
|
+
publish wake-ups inside the same Redis script as the append. Presence writes
|
|
94
|
+
also publish their applied fields inside the script. A rejected notification
|
|
95
|
+
does not fail the applied write. Idle event streams
|
|
96
|
+
read once every ten seconds to recover missed notifications. Subscriptions reconnect automatically and catch up from
|
|
97
|
+
the event cursor. Presence reads wait for subscription readiness; after a
|
|
98
|
+
subscription reconnects, A2 re-reads the current presence fields, clears fields
|
|
99
|
+
deleted during the gap, and may repeat their latest values. Updates received
|
|
100
|
+
during that read take precedence. If the subscription endpoint returns 404, 405, or 501, A2
|
|
101
|
+
checks ordinary Redis commands with `PING` and remembers that the store must
|
|
102
|
+
poll instead. Events and presence then use adaptive polling, from 25ms to two
|
|
103
|
+
seconds while idle. Authentication errors, timeouts, and other failures remain
|
|
104
|
+
errors. No Redis client dependency or extra notifier setup is needed.
|
|
56
105
|
|
|
57
106
|
This configures storage for A2's session histories. It does not connect A2 to your
|
|
58
107
|
application tables or make them part of the append transaction. See
|
|
@@ -245,16 +245,21 @@ otherwise. All built-in stores have it, at two tiers:
|
|
|
245
245
|
|
|
246
246
|
| Backend | Delivery |
|
|
247
247
|
| --- | --- |
|
|
248
|
-
| `store-redis` | push: TTL'd hash plus pub/sub
|
|
248
|
+
| `store-redis` | push: TTL'd hash plus pub/sub patches |
|
|
249
|
+
| `store-redis-http` | push over HTTP subscriptions; adaptive polling when unsupported |
|
|
249
250
|
| `store-memory` | in-process, immediate |
|
|
250
|
-
| `store-postgres
|
|
251
|
+
| `store-postgres` | native notifications with `listenConnectionString`; adaptive polling by default |
|
|
252
|
+
| `store-sqlite` | degraded: bounded TTL'd rows, read on the live feed's poll cadence |
|
|
251
253
|
|
|
252
254
|
Degraded means later, not lost while watched: presence-only traffic
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
255
|
+
uses an adaptive re-read cadence, starting at 25ms and backing off to two
|
|
256
|
+
seconds while unchanged. New presence patches or durable events restore the
|
|
257
|
+
fast cadence. An idle remote viewer may wait up to two seconds plus query
|
|
258
|
+
latency for the first change. Redis Pub/Sub does not proactively broadcast
|
|
259
|
+
TTL expiry. Silent departures keep their last values in connected clients'
|
|
260
|
+
maps until a snapshot reconciles them, so use the render-time expiry above.
|
|
261
|
+
Native Postgres subscriptions reconcile missing or expired fields on their
|
|
262
|
+
ten-second safety reads. Cursors want
|
|
258
263
|
the push tier; typing indicators and progress read fine on either. The presence rows are
|
|
259
264
|
bounded per session and participant, so this is not log growth in
|
|
260
265
|
disguise, but on metered backends every patch is still a network
|