@vielzeug/tandem 0.0.0-stage → 26.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,53 @@
1
- # Temporary Holding Version
1
+ # @vielzeug/tandem
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Offline-first sync engine with rev baselines, tombstoned deletions, and idle-batched pushes
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ pnpm add @vielzeug/tandem
9
+ npm install @vielzeug/tandem
10
+ yarn add @vielzeug/tandem
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ Tandem drives sync between your storage and your server through two interfaces you implement: a `SyncPort` (the server) and a `SyncGateway` (the device). Records are opaque — the engine reads only `id` and a `rev` write counter.
16
+
17
+ ```ts
18
+ import { createSync, type SyncGateway, type SyncPort } from '@vielzeug/tandem';
19
+
20
+ const port: SyncPort = {
21
+ pull: (since) => fetch(`/sync?since=${since ?? ''}`).then((r) => r.json()),
22
+ push: async (records, deletions) => {
23
+ const response = await fetch('/sync', { method: 'POST', body: JSON.stringify({ records, deletions }) });
24
+ if (!response.ok) throw new Error('server diverged'); // the engine pulls to reconcile
25
+ },
26
+ };
27
+
28
+ const gateway: SyncGateway = {
29
+ records: () => localRecords.map((record) => ({ entity: 'docs', record })),
30
+ pendingDeletions: () => readTombstones(),
31
+ applyRecords: (pulled) => upsert(pulled), // the engine pre-filters; you validate untrusted records
32
+ applyDeletions: (deletions) => removeLocal(deletions),
33
+ clearDeletions: (deletions) => dropTombstones(deletions),
34
+ loadState: () => readSyncState(),
35
+ saveState: (state) => writeSyncState(state),
36
+ };
37
+
38
+ const sync = createSync({ gateway, port });
39
+ myStore.subscribe(() => sync.changed()); // a local write — the engine batches and pushes
40
+ await sync.flush(); // pulls remote changes, then pushes dirty records; rejects on failure
41
+ sync.dispose(); // on account switch or teardown
42
+ ```
43
+
44
+ ## Documentation
45
+
46
+ - [Overview](https://vielzeug.dev/tandem/)
47
+ - [Usage Guide](https://vielzeug.dev/tandem/usage)
48
+ - [API Reference](https://vielzeug.dev/tandem/api)
49
+ - [Examples](https://vielzeug.dev/tandem/examples)
50
+
51
+ ## License
52
+
53
+ MIT © [Helmuth Saatkamp](https://github.com/helmuthdu) — part of the [Vielzeug](https://github.com/helmuthdu/vielzeug) monorepo.
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./tandem.cjs");exports.createSync=e.createSync;
@@ -0,0 +1,3 @@
1
+ export { createSync } from './tandem';
2
+ export type { SyncDeletion, SyncEnvelope, SyncGateway, SyncHandle, SyncOptions, SyncPort, SyncPullResult, SyncRecordBase, SyncState, TandemEvent, } from './types';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AACtC,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,UAAU,EACV,WAAW,EACX,QAAQ,EACR,cAAc,EACd,cAAc,EACd,SAAS,EACT,WAAW,GACZ,MAAM,SAAS,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ import{createSync as e}from"./tandem.js";export{e as createSync};
@@ -0,0 +1,2 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});var e=3e3,baseline=(e=null)=>({cursor:e,revs:{}});function createSync(t){let{gateway:n,port:r}=t,i=t.idleDelayMs??e,a=new AbortController,o=new Set,emit=e=>{for(let t of o)try{t(e)}catch{}},warn=e=>{emit({message:e instanceof Error?e.message:String(e),type:`warning`})},s=baseline(),c=null,l=Promise.resolve(),key=e=>`${e.entity}:${e.record.id}`,isDirty=e=>e.record.rev>(s.revs[key(e)]??-1),pull=async()=>{if(a.signal.aborted)return;let{cursor:e,deletions:t,records:i}=await r.pull(s.cursor),o=new Map(n.records().map(e=>[key(e),e.record.rev])),c=new Set((await n.pendingDeletions()).map(e=>`${e.entity}:${e.id}`)),l=new Set(t.map(e=>`${e.entity}:${e.id}`)),u=i.filter(e=>!c.has(key(e))&&!l.has(key(e))&&(o.get(key(e))??-1)<e.record.rev),d=await n.applyRecords(u);d.length&&emit({message:`Skipped invalid remote records: ${d.join(`, `)}.`,skipped:d,type:`invalid`});let f=t.filter(e=>{let t=`${e.entity}:${e.id}`;return(o.get(t)??-1)<=(s.revs[t]??-1)});await n.applyDeletions(f);for(let e of i)s.revs[key(e)]=e.record.rev;s.cursor=e,await n.saveState(s),emit({records:u.length,type:`pull`})},push=async e=>{if(a.signal.aborted)return;let t=n.records().filter(isDirty),i=await n.pendingDeletions();if(t.length||i.length){await r.push(t,i,e?{keepalive:!0}:void 0);for(let e of t)s.revs[key(e)]=e.record.rev;await n.clearDeletions(i),await n.saveState(s),emit({records:t.length,type:`push`})}},enqueue=e=>{let t=l.then(e);return l=t.catch(warn),t},pushJob=e=>()=>push(e).catch(async e=>{throw await pull().catch(()=>{}),e}),cycle=async()=>{await pull(),await pushJob(!1)()},flushSoon=()=>{a.signal.aborted||(c&&clearTimeout(c),c=setTimeout(()=>void enqueue(pushJob(!1)),i))},u=globalThis.document,onVisibility=()=>{u.visibilityState===`hidden`?enqueue(pushJob(!0)):enqueue(cycle)},onPageHide=()=>void enqueue(pushJob(!0));return u?.addEventListener(`visibilitychange`,onVisibility),globalThis.addEventListener?.(`pagehide`,onPageHide),enqueue(async()=>{a.signal.aborted||(s=await n.loadState()??baseline(),await cycle())}),{changed:flushSoon,disposalSignal:a.signal,dispose(){a.signal.aborted||(emit({type:`dispose`}),a.abort(),c&&clearTimeout(c),u?.removeEventListener(`visibilitychange`,onVisibility),globalThis.removeEventListener?.(`pagehide`,onPageHide),o.clear())},get disposed(){return a.signal.aborted},flush:()=>enqueue(cycle),tap(e,t){if(a.signal.aborted||t?.signal?.aborted)return()=>{};let detach=()=>{o.delete(e)};return o.add(e),a.signal.addEventListener(`abort`,detach,{once:!0}),t?.signal?.addEventListener(`abort`,detach,{once:!0}),detach},[Symbol.dispose](){this.dispose()}}}exports.createSync=createSync;
2
+ //# sourceMappingURL=tandem.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tandem.cjs","names":[],"sources":["../src/tandem.ts"],"sourcesContent":["import type { SyncEnvelope, SyncHandle, SyncOptions, SyncRecordBase, SyncState, TandemEvent } from './types';\n\nconst DEFAULT_IDLE_DELAY_MS = 3000;\n\nconst baseline = (cursor: string | null = null): SyncState => ({ cursor, revs: {} });\n\n/**\n * Drives a {@link SyncPort} against a {@link SyncGateway}. The engine never sees\n * individual commands or record shapes: it reads the device's records, and after a\n * quiet period pushes every record whose `rev` is above the last-synced baseline — a\n * burst of edits becomes one batched upload instead of one per edit. Every rev\n * comparison is the engine's: the gateway upserts what it is given and tombstones\n * what it deletes. Flushes serialize (one push in flight), fire immediately when\n * the tab hides or closes, and the tab runs a full cycle on return to the\n * foreground. A rejected push triggers a pull to reconcile.\n *\n * Runs in the browser (the visibility/pagehide hooks are no-ops without a document)\n * and in any storage-backed runtime the gateway can reach.\n */\nexport function createSync<TRecord extends SyncRecordBase = SyncRecordBase>(options: SyncOptions<TRecord>): SyncHandle {\n const { gateway, port } = options;\n const idleDelay = options.idleDelayMs ?? DEFAULT_IDLE_DELAY_MS;\n\n const controller = new AbortController();\n const tappers = new Set<(event: TandemEvent) => void>();\n const emit = (event: TandemEvent): void => {\n for (const tapper of tappers) {\n try {\n tapper(event);\n } catch {\n // Observability must not affect sync behavior.\n }\n }\n };\n const warn = (error: unknown): void => {\n emit({ message: error instanceof Error ? error.message : String(error), type: 'warning' });\n };\n\n let state: SyncState = baseline();\n let timer: ReturnType<typeof setTimeout> | null = null;\n let queue: Promise<void> = Promise.resolve();\n\n const key = (envelope: SyncEnvelope<TRecord>): string => `${envelope.entity}:${envelope.record.id}`;\n const isDirty = (envelope: SyncEnvelope<TRecord>): boolean => envelope.record.rev > (state.revs[key(envelope)] ?? -1);\n\n const pull = async (): Promise<void> => {\n if (controller.signal.aborted) return;\n const { cursor, deletions, records } = await port.pull(state.cursor);\n // One read of the device's records answers every rev question this pull raises.\n const localRevs = new Map(gateway.records().map((envelope) => [key(envelope), envelope.record.rev] as const));\n const entombed = new Set((await gateway.pendingDeletions()).map((d) => `${d.entity}:${d.id}`));\n const deleted = new Set(deletions.map((d) => `${d.entity}:${d.id}`));\n // Apply only records the server is ahead on — a locally tombstoned id or an id\n // deleted in this same pull never resurrects through the apply.\n const ahead = records.filter(\n (envelope) =>\n !entombed.has(key(envelope)) &&\n !deleted.has(key(envelope)) &&\n (localRevs.get(key(envelope)) ?? -1) < envelope.record.rev,\n );\n const skipped = await gateway.applyRecords(ahead);\n if (skipped.length) {\n emit({ message: `Skipped invalid remote records: ${skipped.join(', ')}.`, skipped, type: 'invalid' });\n }\n // A deletion loses to local changes that have not made it to the server yet.\n const doomed = deletions.filter((deletion) => {\n const localKey = `${deletion.entity}:${deletion.id}`;\n return (localRevs.get(localKey) ?? -1) <= (state.revs[localKey] ?? -1);\n });\n await gateway.applyDeletions(doomed);\n for (const envelope of records) state.revs[key(envelope)] = envelope.record.rev;\n state.cursor = cursor;\n await gateway.saveState(state);\n emit({ records: ahead.length, type: 'pull' });\n };\n\n const push = async (keepalive: boolean): Promise<void> => {\n if (controller.signal.aborted) return;\n const records = gateway.records().filter(isDirty);\n const deletions = await gateway.pendingDeletions();\n if (!records.length && !deletions.length) return;\n await port.push(records, deletions, keepalive ? { keepalive: true } : undefined);\n for (const envelope of records) state.revs[key(envelope)] = envelope.record.rev;\n // Only the tombstones this push carried — new ones may have landed mid-flight.\n await gateway.clearDeletions(deletions);\n await gateway.saveState(state);\n emit({ records: records.length, type: 'push' });\n };\n\n // Jobs serialize through one queue. A failure warns through tap and the queue\n // survives; enqueue returns the raw outcome so flush() can reject.\n const enqueue = (job: () => Promise<void>): Promise<void> => {\n const attempt = queue.then(job);\n queue = attempt.catch(warn);\n return attempt;\n };\n const pushJob = (keepalive: boolean) => (): Promise<void> =>\n push(keepalive).catch(async (error: unknown) => {\n // A rejected push usually means the server moved on — pull to reconcile,\n // then surface the failure to whoever is waiting on this job.\n await pull().catch(() => {});\n throw error;\n });\n // A full cycle: absorb remote changes first, then push the merged dirty set.\n const cycle = async (): Promise<void> => {\n await pull();\n await pushJob(false)();\n };\n\n const flushSoon = (): void => {\n if (controller.signal.aborted) return;\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => void enqueue(pushJob(false)), idleDelay);\n };\n\n const doc = globalThis.document;\n const onVisibility = (): void => {\n if (doc.visibilityState === 'hidden') void enqueue(pushJob(true));\n else void enqueue(cycle);\n };\n const onPageHide = (): void => void enqueue(pushJob(true));\n doc?.addEventListener('visibilitychange', onVisibility);\n globalThis.addEventListener?.('pagehide', onPageHide);\n\n // Boot: load the persisted baseline, run one full cycle — pull remote changes,\n // then push anything created or changed while offline. Later flushes chain onto it.\n void enqueue(async () => {\n if (controller.signal.aborted) return;\n state = (await gateway.loadState()) ?? baseline();\n await cycle();\n });\n\n return {\n changed: flushSoon,\n disposalSignal: controller.signal,\n dispose(): void {\n if (controller.signal.aborted) return;\n emit({ type: 'dispose' });\n controller.abort();\n if (timer) clearTimeout(timer);\n doc?.removeEventListener('visibilitychange', onVisibility);\n globalThis.removeEventListener?.('pagehide', onPageHide);\n tappers.clear();\n },\n get disposed(): boolean {\n return controller.signal.aborted;\n },\n flush: () => enqueue(cycle),\n tap(handler, tapOptions): () => void {\n if (controller.signal.aborted || tapOptions?.signal?.aborted) return () => {};\n const detach = (): void => {\n tappers.delete(handler);\n };\n tappers.add(handler);\n controller.signal.addEventListener('abort', detach, { once: true });\n tapOptions?.signal?.addEventListener('abort', detach, { once: true });\n return detach;\n },\n [Symbol.dispose](): void {\n this.dispose();\n },\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,16 @@
1
+ import type { SyncHandle, SyncOptions, SyncRecordBase } from './types';
2
+ /**
3
+ * Drives a {@link SyncPort} against a {@link SyncGateway}. The engine never sees
4
+ * individual commands or record shapes: it reads the device's records, and after a
5
+ * quiet period pushes every record whose `rev` is above the last-synced baseline — a
6
+ * burst of edits becomes one batched upload instead of one per edit. Every rev
7
+ * comparison is the engine's: the gateway upserts what it is given and tombstones
8
+ * what it deletes. Flushes serialize (one push in flight), fire immediately when
9
+ * the tab hides or closes, and the tab runs a full cycle on return to the
10
+ * foreground. A rejected push triggers a pull to reconcile.
11
+ *
12
+ * Runs in the browser (the visibility/pagehide hooks are no-ops without a document)
13
+ * and in any storage-backed runtime the gateway can reach.
14
+ */
15
+ export declare function createSync<TRecord extends SyncRecordBase = SyncRecordBase>(options: SyncOptions<TRecord>): SyncHandle;
16
+ //# sourceMappingURL=tandem.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tandem.d.ts","sourceRoot":"","sources":["../src/tandem.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,UAAU,EAAE,WAAW,EAAE,cAAc,EAA0B,MAAM,SAAS,CAAC;AAM7G;;;;;;;;;;;;GAYG;AACH,wBAAgB,UAAU,CAAC,OAAO,SAAS,cAAc,GAAG,cAAc,EAAE,OAAO,EAAE,WAAW,CAAC,OAAO,CAAC,GAAG,UAAU,CA+IrH"}
@@ -0,0 +1,2 @@
1
+ var Tandem=(function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=3e3,baseline=(e=null)=>({cursor:e,revs:{}});function createSync(e){let{gateway:n,port:r}=e,i=e.idleDelayMs??t,a=new AbortController,o=new Set,emit=e=>{for(let t of o)try{t(e)}catch{}},warn=e=>{emit({message:e instanceof Error?e.message:String(e),type:`warning`})},s=baseline(),c=null,l=Promise.resolve(),key=e=>`${e.entity}:${e.record.id}`,isDirty=e=>e.record.rev>(s.revs[key(e)]??-1),pull=async()=>{if(a.signal.aborted)return;let{cursor:e,deletions:t,records:i}=await r.pull(s.cursor),o=new Map(n.records().map(e=>[key(e),e.record.rev])),c=new Set((await n.pendingDeletions()).map(e=>`${e.entity}:${e.id}`)),l=new Set(t.map(e=>`${e.entity}:${e.id}`)),u=i.filter(e=>!c.has(key(e))&&!l.has(key(e))&&(o.get(key(e))??-1)<e.record.rev),d=await n.applyRecords(u);d.length&&emit({message:`Skipped invalid remote records: ${d.join(`, `)}.`,skipped:d,type:`invalid`});let f=t.filter(e=>{let t=`${e.entity}:${e.id}`;return(o.get(t)??-1)<=(s.revs[t]??-1)});await n.applyDeletions(f);for(let e of i)s.revs[key(e)]=e.record.rev;s.cursor=e,await n.saveState(s),emit({records:u.length,type:`pull`})},push=async e=>{if(a.signal.aborted)return;let t=n.records().filter(isDirty),i=await n.pendingDeletions();if(t.length||i.length){await r.push(t,i,e?{keepalive:!0}:void 0);for(let e of t)s.revs[key(e)]=e.record.rev;await n.clearDeletions(i),await n.saveState(s),emit({records:t.length,type:`push`})}},enqueue=e=>{let t=l.then(e);return l=t.catch(warn),t},pushJob=e=>()=>push(e).catch(async e=>{throw await pull().catch(()=>{}),e}),cycle=async()=>{await pull(),await pushJob(!1)()},flushSoon=()=>{a.signal.aborted||(c&&clearTimeout(c),c=setTimeout(()=>void enqueue(pushJob(!1)),i))},u=globalThis.document,onVisibility=()=>{u.visibilityState===`hidden`?enqueue(pushJob(!0)):enqueue(cycle)},onPageHide=()=>void enqueue(pushJob(!0));return u?.addEventListener(`visibilitychange`,onVisibility),globalThis.addEventListener?.(`pagehide`,onPageHide),enqueue(async()=>{a.signal.aborted||(s=await n.loadState()??baseline(),await cycle())}),{changed:flushSoon,disposalSignal:a.signal,dispose(){a.signal.aborted||(emit({type:`dispose`}),a.abort(),c&&clearTimeout(c),u?.removeEventListener(`visibilitychange`,onVisibility),globalThis.removeEventListener?.(`pagehide`,onPageHide),o.clear())},get disposed(){return a.signal.aborted},flush:()=>enqueue(cycle),tap(e,t){if(a.signal.aborted||t?.signal?.aborted)return()=>{};let detach=()=>{o.delete(e)};return o.add(e),a.signal.addEventListener(`abort`,detach,{once:!0}),t?.signal?.addEventListener(`abort`,detach,{once:!0}),detach},[Symbol.dispose](){this.dispose()}}}return e.createSync=createSync,e})({});
2
+ //# sourceMappingURL=tandem.iife.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tandem.iife.js","names":[],"sources":["../src/tandem.ts"],"sourcesContent":["import type { SyncEnvelope, SyncHandle, SyncOptions, SyncRecordBase, SyncState, TandemEvent } from './types';\n\nconst DEFAULT_IDLE_DELAY_MS = 3000;\n\nconst baseline = (cursor: string | null = null): SyncState => ({ cursor, revs: {} });\n\n/**\n * Drives a {@link SyncPort} against a {@link SyncGateway}. The engine never sees\n * individual commands or record shapes: it reads the device's records, and after a\n * quiet period pushes every record whose `rev` is above the last-synced baseline — a\n * burst of edits becomes one batched upload instead of one per edit. Every rev\n * comparison is the engine's: the gateway upserts what it is given and tombstones\n * what it deletes. Flushes serialize (one push in flight), fire immediately when\n * the tab hides or closes, and the tab runs a full cycle on return to the\n * foreground. A rejected push triggers a pull to reconcile.\n *\n * Runs in the browser (the visibility/pagehide hooks are no-ops without a document)\n * and in any storage-backed runtime the gateway can reach.\n */\nexport function createSync<TRecord extends SyncRecordBase = SyncRecordBase>(options: SyncOptions<TRecord>): SyncHandle {\n const { gateway, port } = options;\n const idleDelay = options.idleDelayMs ?? DEFAULT_IDLE_DELAY_MS;\n\n const controller = new AbortController();\n const tappers = new Set<(event: TandemEvent) => void>();\n const emit = (event: TandemEvent): void => {\n for (const tapper of tappers) {\n try {\n tapper(event);\n } catch {\n // Observability must not affect sync behavior.\n }\n }\n };\n const warn = (error: unknown): void => {\n emit({ message: error instanceof Error ? error.message : String(error), type: 'warning' });\n };\n\n let state: SyncState = baseline();\n let timer: ReturnType<typeof setTimeout> | null = null;\n let queue: Promise<void> = Promise.resolve();\n\n const key = (envelope: SyncEnvelope<TRecord>): string => `${envelope.entity}:${envelope.record.id}`;\n const isDirty = (envelope: SyncEnvelope<TRecord>): boolean => envelope.record.rev > (state.revs[key(envelope)] ?? -1);\n\n const pull = async (): Promise<void> => {\n if (controller.signal.aborted) return;\n const { cursor, deletions, records } = await port.pull(state.cursor);\n // One read of the device's records answers every rev question this pull raises.\n const localRevs = new Map(gateway.records().map((envelope) => [key(envelope), envelope.record.rev] as const));\n const entombed = new Set((await gateway.pendingDeletions()).map((d) => `${d.entity}:${d.id}`));\n const deleted = new Set(deletions.map((d) => `${d.entity}:${d.id}`));\n // Apply only records the server is ahead on — a locally tombstoned id or an id\n // deleted in this same pull never resurrects through the apply.\n const ahead = records.filter(\n (envelope) =>\n !entombed.has(key(envelope)) &&\n !deleted.has(key(envelope)) &&\n (localRevs.get(key(envelope)) ?? -1) < envelope.record.rev,\n );\n const skipped = await gateway.applyRecords(ahead);\n if (skipped.length) {\n emit({ message: `Skipped invalid remote records: ${skipped.join(', ')}.`, skipped, type: 'invalid' });\n }\n // A deletion loses to local changes that have not made it to the server yet.\n const doomed = deletions.filter((deletion) => {\n const localKey = `${deletion.entity}:${deletion.id}`;\n return (localRevs.get(localKey) ?? -1) <= (state.revs[localKey] ?? -1);\n });\n await gateway.applyDeletions(doomed);\n for (const envelope of records) state.revs[key(envelope)] = envelope.record.rev;\n state.cursor = cursor;\n await gateway.saveState(state);\n emit({ records: ahead.length, type: 'pull' });\n };\n\n const push = async (keepalive: boolean): Promise<void> => {\n if (controller.signal.aborted) return;\n const records = gateway.records().filter(isDirty);\n const deletions = await gateway.pendingDeletions();\n if (!records.length && !deletions.length) return;\n await port.push(records, deletions, keepalive ? { keepalive: true } : undefined);\n for (const envelope of records) state.revs[key(envelope)] = envelope.record.rev;\n // Only the tombstones this push carried — new ones may have landed mid-flight.\n await gateway.clearDeletions(deletions);\n await gateway.saveState(state);\n emit({ records: records.length, type: 'push' });\n };\n\n // Jobs serialize through one queue. A failure warns through tap and the queue\n // survives; enqueue returns the raw outcome so flush() can reject.\n const enqueue = (job: () => Promise<void>): Promise<void> => {\n const attempt = queue.then(job);\n queue = attempt.catch(warn);\n return attempt;\n };\n const pushJob = (keepalive: boolean) => (): Promise<void> =>\n push(keepalive).catch(async (error: unknown) => {\n // A rejected push usually means the server moved on — pull to reconcile,\n // then surface the failure to whoever is waiting on this job.\n await pull().catch(() => {});\n throw error;\n });\n // A full cycle: absorb remote changes first, then push the merged dirty set.\n const cycle = async (): Promise<void> => {\n await pull();\n await pushJob(false)();\n };\n\n const flushSoon = (): void => {\n if (controller.signal.aborted) return;\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => void enqueue(pushJob(false)), idleDelay);\n };\n\n const doc = globalThis.document;\n const onVisibility = (): void => {\n if (doc.visibilityState === 'hidden') void enqueue(pushJob(true));\n else void enqueue(cycle);\n };\n const onPageHide = (): void => void enqueue(pushJob(true));\n doc?.addEventListener('visibilitychange', onVisibility);\n globalThis.addEventListener?.('pagehide', onPageHide);\n\n // Boot: load the persisted baseline, run one full cycle — pull remote changes,\n // then push anything created or changed while offline. Later flushes chain onto it.\n void enqueue(async () => {\n if (controller.signal.aborted) return;\n state = (await gateway.loadState()) ?? baseline();\n await cycle();\n });\n\n return {\n changed: flushSoon,\n disposalSignal: controller.signal,\n dispose(): void {\n if (controller.signal.aborted) return;\n emit({ type: 'dispose' });\n controller.abort();\n if (timer) clearTimeout(timer);\n doc?.removeEventListener('visibilitychange', onVisibility);\n globalThis.removeEventListener?.('pagehide', onPageHide);\n tappers.clear();\n },\n get disposed(): boolean {\n return controller.signal.aborted;\n },\n flush: () => enqueue(cycle),\n tap(handler, tapOptions): () => void {\n if (controller.signal.aborted || tapOptions?.signal?.aborted) return () => {};\n const detach = (): void => {\n tappers.delete(handler);\n };\n tappers.add(handler);\n controller.signal.addEventListener('abort', detach, { once: true });\n tapOptions?.signal?.addEventListener('abort', detach, { once: true });\n return detach;\n },\n [Symbol.dispose](): void {\n this.dispose();\n },\n };\n}\n"],"mappings":"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"}
package/dist/tandem.js ADDED
@@ -0,0 +1,2 @@
1
+ var baseline=(e=null)=>({cursor:e,revs:{}});function createSync(e){let{gateway:t,port:n}=e,r=e.idleDelayMs??3e3,i=new AbortController,a=new Set,emit=e=>{for(let t of a)try{t(e)}catch{}},warn=e=>{emit({message:e instanceof Error?e.message:String(e),type:`warning`})},o=baseline(),s=null,c=Promise.resolve(),key=e=>`${e.entity}:${e.record.id}`,isDirty=e=>e.record.rev>(o.revs[key(e)]??-1),pull=async()=>{if(i.signal.aborted)return;let{cursor:e,deletions:r,records:a}=await n.pull(o.cursor),s=new Map(t.records().map(e=>[key(e),e.record.rev])),c=new Set((await t.pendingDeletions()).map(e=>`${e.entity}:${e.id}`)),l=new Set(r.map(e=>`${e.entity}:${e.id}`)),u=a.filter(e=>!c.has(key(e))&&!l.has(key(e))&&(s.get(key(e))??-1)<e.record.rev),d=await t.applyRecords(u);d.length&&emit({message:`Skipped invalid remote records: ${d.join(`, `)}.`,skipped:d,type:`invalid`});let f=r.filter(e=>{let t=`${e.entity}:${e.id}`;return(s.get(t)??-1)<=(o.revs[t]??-1)});await t.applyDeletions(f);for(let e of a)o.revs[key(e)]=e.record.rev;o.cursor=e,await t.saveState(o),emit({records:u.length,type:`pull`})},push=async e=>{if(i.signal.aborted)return;let r=t.records().filter(isDirty),a=await t.pendingDeletions();if(r.length||a.length){await n.push(r,a,e?{keepalive:!0}:void 0);for(let e of r)o.revs[key(e)]=e.record.rev;await t.clearDeletions(a),await t.saveState(o),emit({records:r.length,type:`push`})}},enqueue=e=>{let t=c.then(e);return c=t.catch(warn),t},pushJob=e=>()=>push(e).catch(async e=>{throw await pull().catch(()=>{}),e}),cycle=async()=>{await pull(),await pushJob(!1)()},flushSoon=()=>{i.signal.aborted||(s&&clearTimeout(s),s=setTimeout(()=>void enqueue(pushJob(!1)),r))},l=globalThis.document,onVisibility=()=>{l.visibilityState===`hidden`?enqueue(pushJob(!0)):enqueue(cycle)},onPageHide=()=>void enqueue(pushJob(!0));return l?.addEventListener(`visibilitychange`,onVisibility),globalThis.addEventListener?.(`pagehide`,onPageHide),enqueue(async()=>{i.signal.aborted||(o=await t.loadState()??baseline(),await cycle())}),{changed:flushSoon,disposalSignal:i.signal,dispose(){i.signal.aborted||(emit({type:`dispose`}),i.abort(),s&&clearTimeout(s),l?.removeEventListener(`visibilitychange`,onVisibility),globalThis.removeEventListener?.(`pagehide`,onPageHide),a.clear())},get disposed(){return i.signal.aborted},flush:()=>enqueue(cycle),tap(e,t){if(i.signal.aborted||t?.signal?.aborted)return()=>{};let detach=()=>{a.delete(e)};return a.add(e),i.signal.addEventListener(`abort`,detach,{once:!0}),t?.signal?.addEventListener(`abort`,detach,{once:!0}),detach},[Symbol.dispose](){this.dispose()}}}export{createSync};
2
+ //# sourceMappingURL=tandem.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tandem.js","names":[],"sources":["../src/tandem.ts"],"sourcesContent":["import type { SyncEnvelope, SyncHandle, SyncOptions, SyncRecordBase, SyncState, TandemEvent } from './types';\n\nconst DEFAULT_IDLE_DELAY_MS = 3000;\n\nconst baseline = (cursor: string | null = null): SyncState => ({ cursor, revs: {} });\n\n/**\n * Drives a {@link SyncPort} against a {@link SyncGateway}. The engine never sees\n * individual commands or record shapes: it reads the device's records, and after a\n * quiet period pushes every record whose `rev` is above the last-synced baseline — a\n * burst of edits becomes one batched upload instead of one per edit. Every rev\n * comparison is the engine's: the gateway upserts what it is given and tombstones\n * what it deletes. Flushes serialize (one push in flight), fire immediately when\n * the tab hides or closes, and the tab runs a full cycle on return to the\n * foreground. A rejected push triggers a pull to reconcile.\n *\n * Runs in the browser (the visibility/pagehide hooks are no-ops without a document)\n * and in any storage-backed runtime the gateway can reach.\n */\nexport function createSync<TRecord extends SyncRecordBase = SyncRecordBase>(options: SyncOptions<TRecord>): SyncHandle {\n const { gateway, port } = options;\n const idleDelay = options.idleDelayMs ?? DEFAULT_IDLE_DELAY_MS;\n\n const controller = new AbortController();\n const tappers = new Set<(event: TandemEvent) => void>();\n const emit = (event: TandemEvent): void => {\n for (const tapper of tappers) {\n try {\n tapper(event);\n } catch {\n // Observability must not affect sync behavior.\n }\n }\n };\n const warn = (error: unknown): void => {\n emit({ message: error instanceof Error ? error.message : String(error), type: 'warning' });\n };\n\n let state: SyncState = baseline();\n let timer: ReturnType<typeof setTimeout> | null = null;\n let queue: Promise<void> = Promise.resolve();\n\n const key = (envelope: SyncEnvelope<TRecord>): string => `${envelope.entity}:${envelope.record.id}`;\n const isDirty = (envelope: SyncEnvelope<TRecord>): boolean => envelope.record.rev > (state.revs[key(envelope)] ?? -1);\n\n const pull = async (): Promise<void> => {\n if (controller.signal.aborted) return;\n const { cursor, deletions, records } = await port.pull(state.cursor);\n // One read of the device's records answers every rev question this pull raises.\n const localRevs = new Map(gateway.records().map((envelope) => [key(envelope), envelope.record.rev] as const));\n const entombed = new Set((await gateway.pendingDeletions()).map((d) => `${d.entity}:${d.id}`));\n const deleted = new Set(deletions.map((d) => `${d.entity}:${d.id}`));\n // Apply only records the server is ahead on — a locally tombstoned id or an id\n // deleted in this same pull never resurrects through the apply.\n const ahead = records.filter(\n (envelope) =>\n !entombed.has(key(envelope)) &&\n !deleted.has(key(envelope)) &&\n (localRevs.get(key(envelope)) ?? -1) < envelope.record.rev,\n );\n const skipped = await gateway.applyRecords(ahead);\n if (skipped.length) {\n emit({ message: `Skipped invalid remote records: ${skipped.join(', ')}.`, skipped, type: 'invalid' });\n }\n // A deletion loses to local changes that have not made it to the server yet.\n const doomed = deletions.filter((deletion) => {\n const localKey = `${deletion.entity}:${deletion.id}`;\n return (localRevs.get(localKey) ?? -1) <= (state.revs[localKey] ?? -1);\n });\n await gateway.applyDeletions(doomed);\n for (const envelope of records) state.revs[key(envelope)] = envelope.record.rev;\n state.cursor = cursor;\n await gateway.saveState(state);\n emit({ records: ahead.length, type: 'pull' });\n };\n\n const push = async (keepalive: boolean): Promise<void> => {\n if (controller.signal.aborted) return;\n const records = gateway.records().filter(isDirty);\n const deletions = await gateway.pendingDeletions();\n if (!records.length && !deletions.length) return;\n await port.push(records, deletions, keepalive ? { keepalive: true } : undefined);\n for (const envelope of records) state.revs[key(envelope)] = envelope.record.rev;\n // Only the tombstones this push carried — new ones may have landed mid-flight.\n await gateway.clearDeletions(deletions);\n await gateway.saveState(state);\n emit({ records: records.length, type: 'push' });\n };\n\n // Jobs serialize through one queue. A failure warns through tap and the queue\n // survives; enqueue returns the raw outcome so flush() can reject.\n const enqueue = (job: () => Promise<void>): Promise<void> => {\n const attempt = queue.then(job);\n queue = attempt.catch(warn);\n return attempt;\n };\n const pushJob = (keepalive: boolean) => (): Promise<void> =>\n push(keepalive).catch(async (error: unknown) => {\n // A rejected push usually means the server moved on — pull to reconcile,\n // then surface the failure to whoever is waiting on this job.\n await pull().catch(() => {});\n throw error;\n });\n // A full cycle: absorb remote changes first, then push the merged dirty set.\n const cycle = async (): Promise<void> => {\n await pull();\n await pushJob(false)();\n };\n\n const flushSoon = (): void => {\n if (controller.signal.aborted) return;\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => void enqueue(pushJob(false)), idleDelay);\n };\n\n const doc = globalThis.document;\n const onVisibility = (): void => {\n if (doc.visibilityState === 'hidden') void enqueue(pushJob(true));\n else void enqueue(cycle);\n };\n const onPageHide = (): void => void enqueue(pushJob(true));\n doc?.addEventListener('visibilitychange', onVisibility);\n globalThis.addEventListener?.('pagehide', onPageHide);\n\n // Boot: load the persisted baseline, run one full cycle — pull remote changes,\n // then push anything created or changed while offline. Later flushes chain onto it.\n void enqueue(async () => {\n if (controller.signal.aborted) return;\n state = (await gateway.loadState()) ?? baseline();\n await cycle();\n });\n\n return {\n changed: flushSoon,\n disposalSignal: controller.signal,\n dispose(): void {\n if (controller.signal.aborted) return;\n emit({ type: 'dispose' });\n controller.abort();\n if (timer) clearTimeout(timer);\n doc?.removeEventListener('visibilitychange', onVisibility);\n globalThis.removeEventListener?.('pagehide', onPageHide);\n tappers.clear();\n },\n get disposed(): boolean {\n return controller.signal.aborted;\n },\n flush: () => enqueue(cycle),\n tap(handler, tapOptions): () => void {\n if (controller.signal.aborted || tapOptions?.signal?.aborted) return () => {};\n const detach = (): void => {\n tappers.delete(handler);\n };\n tappers.add(handler);\n controller.signal.addEventListener('abort', detach, { once: true });\n tapOptions?.signal?.addEventListener('abort', detach, { once: true });\n return detach;\n },\n [Symbol.dispose](): void {\n this.dispose();\n },\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The two seams Tandem drives: a {@link SyncPort} that reaches the server, and a
3
+ * {@link SyncGateway} that reaches the device's own storage. The engine never
4
+ * sees a database, a transport, or a record shape — records travel as opaque
5
+ * envelopes carrying only an `id` and a write-counter `rev`. Every rev
6
+ * comparison is the engine's: the gateway upserts, validates, and tombstones
7
+ * without re-deriving the sync policy.
8
+ */
9
+ /** A record that syncs: identified, versioned by committed writes, otherwise opaque. */
10
+ export interface SyncRecordBase {
11
+ /** Stable identity, unique within its entity. */
12
+ readonly id: string;
13
+ /** Counts this record's committed writes; equal revs on two devices mean a concurrent edit. */
14
+ readonly rev: number;
15
+ }
16
+ /** A record paired with the entity (table or kind) it belongs to. */
17
+ export interface SyncEnvelope<TRecord extends SyncRecordBase = SyncRecordBase> {
18
+ readonly entity: string;
19
+ readonly record: TRecord;
20
+ }
21
+ /** A deletion that travels: stored locally as a tombstone, propagated on the next push. */
22
+ export interface SyncDeletion {
23
+ readonly deletedAt: string;
24
+ readonly entity: string;
25
+ readonly id: string;
26
+ }
27
+ /** Per-account sync bookkeeping the gateway persists: the pull cursor and the newest
28
+ * rev the server has seen per record (`"${entity}:${id}"` → rev). A record is dirty
29
+ * exactly when its current rev is above that baseline — across reloads, not just
30
+ * this session. */
31
+ export interface SyncState {
32
+ cursor: string | null;
33
+ revs: Record<string, number>;
34
+ }
35
+ /** What a pull returned: remote records changed after the cursor, remote deletions,
36
+ * and the cursor for the next pull (null when the backend has no incremental pull). */
37
+ export interface SyncPullResult<TRecord extends SyncRecordBase = SyncRecordBase> {
38
+ readonly cursor: string | null;
39
+ readonly deletions: readonly SyncDeletion[];
40
+ readonly records: readonly SyncEnvelope<TRecord>[];
41
+ }
42
+ /** The server side of sync, implemented over HTTP, GraphQL, or anything else. */
43
+ export interface SyncPort<TRecord extends SyncRecordBase = SyncRecordBase> {
44
+ /** Remote records changed after `since`, plus remote deletions. */
45
+ pull(since: string | null): Promise<SyncPullResult<TRecord>>;
46
+ /**
47
+ * Push local records and deletions. Reject when the server diverged (it is ahead
48
+ * of the pushed lineage) so the engine can pull and reconcile. `keepalive` marks
49
+ * a flush that must survive the tab closing — serve it with `navigator.sendBeacon`
50
+ * or a `keepalive` fetch.
51
+ */
52
+ push(records: readonly SyncEnvelope<TRecord>[], deletions: readonly SyncDeletion[], options?: {
53
+ readonly keepalive?: boolean;
54
+ }): Promise<void>;
55
+ }
56
+ /**
57
+ * The device side of sync: the app's storage, expressed as the handful of reads
58
+ * and writes the engine needs. The engine does the rev math — `applyRecords`
59
+ * receives only records the server is ahead on, already filtered against
60
+ * same-or-ahead locals and locally tombstoned ids, so the gateway upserts and
61
+ * validates without comparing revs itself. Remote records are untrusted —
62
+ * `applyRecords` is the app's validation boundary and reports the ids it refused.
63
+ */
64
+ export interface SyncGateway<TRecord extends SyncRecordBase = SyncRecordBase> {
65
+ /** Applies pulled deletions — no tombstone: the deletion came from the server. */
66
+ applyDeletions(deletions: readonly SyncDeletion[]): Promise<void>;
67
+ /** Upserts the given pulled records; returns the ids of records that failed validation. */
68
+ applyRecords(records: readonly SyncEnvelope<TRecord>[]): Promise<string[]>;
69
+ /** Removes the tombstones a successful push carried — only those, since new
70
+ * deletions may have been recorded while the push was in flight. */
71
+ clearDeletions(deletions: readonly SyncDeletion[]): Promise<void>;
72
+ /** The persisted baseline, or `null` on a fresh device. */
73
+ loadState(): Promise<SyncState | null>;
74
+ /** Tombstones not yet acknowledged by the server. */
75
+ pendingDeletions(): Promise<SyncDeletion[]>;
76
+ /** The device's own records — no remote mirrors. */
77
+ records(): SyncEnvelope<TRecord>[];
78
+ /** Persists the baseline after a completed pull or push. */
79
+ saveState(state: SyncState): Promise<void>;
80
+ }
81
+ /** Notable moments the engine reports through {@link SyncHandle.tap}. */
82
+ export type TandemEvent = {
83
+ readonly records: number;
84
+ readonly type: 'pull';
85
+ } | {
86
+ readonly records: number;
87
+ readonly type: 'push';
88
+ } | {
89
+ readonly message: string;
90
+ readonly skipped: readonly string[];
91
+ readonly type: 'invalid';
92
+ } | {
93
+ readonly message: string;
94
+ readonly type: 'warning';
95
+ } | {
96
+ readonly type: 'dispose';
97
+ };
98
+ /** Options for {@link createSync}. */
99
+ export interface SyncOptions<TRecord extends SyncRecordBase = SyncRecordBase> {
100
+ /** The device's storage. */
101
+ gateway: SyncGateway<TRecord>;
102
+ /** Quiet period after the last change before a flush, in ms (default 3000). */
103
+ idleDelayMs?: number;
104
+ /** The server. */
105
+ port: SyncPort<TRecord>;
106
+ }
107
+ /** The running sync scheduler returned by {@link createSync}. */
108
+ export interface SyncHandle {
109
+ /** Records that a local change happened; (re)starts the idle timer that flushes
110
+ * dirty records. Wire this to the app's write path or reactive layer. */
111
+ changed(): void;
112
+ /** Aborted when the handle is disposed. */
113
+ readonly disposalSignal: AbortSignal;
114
+ /** Stops the scheduler. Dispose before switching accounts — the baseline is
115
+ * per-account state and a running scheduler would push into the wrong port. */
116
+ dispose(): void;
117
+ /** True once {@link dispose} has run. */
118
+ readonly disposed: boolean;
119
+ /** Runs a full sync cycle now — pulls remote changes, then pushes dirty records.
120
+ * Resolves when the cycle completes; rejects when it fails (dirty records stay
121
+ * dirty and the next cycle retries them). A no-op after {@link dispose}. */
122
+ flush(): Promise<void>;
123
+ /** Subscribes to {@link TandemEvent}s. Handler errors are swallowed; the
124
+ * subscription detaches when `signal` aborts or the handle is disposed. */
125
+ tap(handler: (event: TandemEvent) => void, options?: {
126
+ readonly signal?: AbortSignal;
127
+ }): () => void;
128
+ [Symbol.dispose](): void;
129
+ }
130
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@vielzeug/tandem",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "26.10.0",
4
+ "description": "Offline-first sync engine with rev baselines, tombstoned deletions, and idle-batched pushes",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/helmuthdu/vielzeug",
8
+ "directory": "packages/tandem"
9
+ },
10
+ "type": "module",
11
+ "engines": {
12
+ "node": ">=22"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "main": "./dist/index.cjs",
18
+ "module": "./dist/index.js",
19
+ "types": "dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "import": "./dist/index.js",
23
+ "require": "./dist/index.cjs",
24
+ "types": "./dist/index.d.ts"
25
+ }
26
+ },
27
+ "sideEffects": false,
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "registry": "https://registry.npmjs.org/"
31
+ },
32
+ "scripts": {
33
+ "build": "vite build && pnpm --silent run build:bundle && pnpm --silent run build:types",
34
+ "build:bundle": "vite build --config vite.bundle.config.ts",
35
+ "build:types": "tsc -p tsconfig.declarations.json",
36
+ "fix": "biome check --write src",
37
+ "lint": "biome ci src",
38
+ "prepublishOnly": "pnpm --silent run build",
39
+ "test": "vitest"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^26.6.2",
43
+ "typescript": "^6.0.3",
44
+ "vite": "^8.3.0",
45
+ "vitest": "^5.0.1"
46
+ }
47
+ }