@drakkar.software/starfish-client 1.3.2 → 1.4.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/dist/fetch.js ADDED
@@ -0,0 +1,156 @@
1
+ /** Classify an error from a fetch response or network failure. */
2
+ export function classifyError(err) {
3
+ if (err instanceof Response || (err && typeof err === "object" && "status" in err)) {
4
+ const status = err.status;
5
+ if (status === 401 || status === 403)
6
+ return "auth";
7
+ if (status === 409)
8
+ return "conflict";
9
+ if (status === 429)
10
+ return "rate-limited";
11
+ if (status >= 500)
12
+ return "server";
13
+ if (status >= 400)
14
+ return "client";
15
+ }
16
+ if (err instanceof Error && /failed to fetch|fetch failed|network|load failed|ECONNREFUSED|ENOTFOUND/i.test(err.message))
17
+ return "network";
18
+ return "unknown";
19
+ }
20
+ /**
21
+ * Wraps a fetch function with automatic retry for retriable errors
22
+ * (network failures, 429, 5xx). Respects Retry-After headers.
23
+ */
24
+ export function createRetryFetch(options) {
25
+ const maxRetries = Math.max(0, options?.maxRetries ?? 3);
26
+ const initialDelay = options?.initialDelayMs ?? 500;
27
+ const maxDelay = options?.maxDelayMs ?? 10_000;
28
+ return async (input, init) => {
29
+ let attempt = 0;
30
+ while (true) {
31
+ try {
32
+ const res = await globalThis.fetch(input, init);
33
+ if (res.ok || attempt >= maxRetries)
34
+ return res;
35
+ const category = classifyError(res);
36
+ if (category !== "rate-limited" && category !== "server")
37
+ return res;
38
+ const retryAfter = res.headers.get("Retry-After");
39
+ let delay;
40
+ if (retryAfter) {
41
+ const seconds = Number(retryAfter);
42
+ if (!isNaN(seconds)) {
43
+ delay = Math.min(seconds * 1000, maxDelay);
44
+ }
45
+ else {
46
+ const date = Date.parse(retryAfter);
47
+ delay = isNaN(date) ? initialDelay : Math.min(Math.max(date - Date.now(), 0), maxDelay);
48
+ }
49
+ }
50
+ else {
51
+ delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
52
+ }
53
+ await new Promise((r) => setTimeout(r, delay));
54
+ attempt++;
55
+ }
56
+ catch (err) {
57
+ if (attempt >= maxRetries)
58
+ throw err;
59
+ const category = classifyError(err);
60
+ if (category !== "network")
61
+ throw err;
62
+ const delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
63
+ await new Promise((r) => setTimeout(r, delay));
64
+ attempt++;
65
+ }
66
+ }
67
+ };
68
+ }
69
+ /** Circuit breaker that prevents requests when the backend is unavailable. */
70
+ export class CircuitBreaker {
71
+ state = "closed";
72
+ failures = 0;
73
+ openedAt = 0;
74
+ threshold;
75
+ cooldownMs;
76
+ constructor(options) {
77
+ this.threshold = options?.threshold ?? 5;
78
+ this.cooldownMs = options?.cooldownMs ?? 30_000;
79
+ }
80
+ getState() {
81
+ this.maybeTransition();
82
+ return this.state;
83
+ }
84
+ isOpen() {
85
+ return this.getState() === "open";
86
+ }
87
+ recordSuccess() {
88
+ this.failures = 0;
89
+ this.state = "closed";
90
+ }
91
+ recordFailure() {
92
+ this.failures++;
93
+ if (this.state === "half-open" || this.failures >= this.threshold) {
94
+ this.state = "open";
95
+ this.openedAt = Date.now();
96
+ }
97
+ }
98
+ maybeTransition() {
99
+ if (this.state === "open" && Date.now() - this.openedAt >= this.cooldownMs) {
100
+ this.state = "half-open";
101
+ }
102
+ }
103
+ }
104
+ /**
105
+ * Wraps fetch to gzip-compress string request bodies using the CompressionStream API.
106
+ * Adds Content-Encoding: gzip header. Non-string bodies (ArrayBuffer, Blob, etc.)
107
+ * are passed through uncompressed. Requires CompressionStream (browsers, Node.js 18+, Deno).
108
+ */
109
+ export function createCompressedFetch(inner) {
110
+ const baseFetch = inner ?? globalThis.fetch.bind(globalThis);
111
+ return async (input, init) => {
112
+ if (!init?.body || typeof CompressionStream === "undefined") {
113
+ return baseFetch(input, init);
114
+ }
115
+ const bodyText = typeof init.body === "string" ? init.body : null;
116
+ if (!bodyText)
117
+ return baseFetch(input, init);
118
+ const stream = new Blob([bodyText]).stream().pipeThrough(new CompressionStream("gzip"));
119
+ const compressed = await new Response(stream).arrayBuffer();
120
+ const normalized = Object.fromEntries(new Headers(init.headers).entries());
121
+ normalized["content-encoding"] = "gzip";
122
+ return baseFetch(input, {
123
+ ...init,
124
+ body: compressed,
125
+ headers: normalized,
126
+ });
127
+ };
128
+ }
129
+ /**
130
+ * Combines retry and circuit breaker into a single resilient fetch wrapper.
131
+ * Rejects immediately when the circuit is open.
132
+ */
133
+ export function createResilientFetch(retryOptions, breakerOptions) {
134
+ const breaker = new CircuitBreaker(breakerOptions);
135
+ const retryFetch = createRetryFetch(retryOptions);
136
+ const resilientFetch = async (input, init) => {
137
+ if (breaker.isOpen()) {
138
+ throw new Error(`Circuit breaker is open (${breaker.getState()}, cooldown ${breakerOptions?.cooldownMs ?? 30_000}ms)`);
139
+ }
140
+ try {
141
+ const res = await retryFetch(input, init);
142
+ if (res.ok) {
143
+ breaker.recordSuccess();
144
+ }
145
+ else if (res.status >= 500) {
146
+ breaker.recordFailure();
147
+ }
148
+ return res;
149
+ }
150
+ catch (err) {
151
+ breaker.recordFailure();
152
+ throw err;
153
+ }
154
+ };
155
+ return { fetch: resilientFetch, breaker };
156
+ }
@@ -0,0 +1,29 @@
1
+ export interface Snapshot {
2
+ timestamp: number;
3
+ label: string;
4
+ data: string;
5
+ }
6
+ export interface SnapshotHistoryOptions {
7
+ /** Maximum number of snapshots to retain. Oldest are trimmed first. Default: 20. */
8
+ maxSnapshots?: number;
9
+ /** localStorage key for persistence. Pass to enable auto-save/load. */
10
+ storageKey?: string;
11
+ }
12
+ export declare class SnapshotHistory {
13
+ private snapshots;
14
+ private readonly maxSnapshots;
15
+ private readonly storageKey;
16
+ constructor(options?: SnapshotHistoryOptions);
17
+ /** Take a labeled snapshot of the given data. */
18
+ take(label: string, data: Record<string, unknown>): void;
19
+ /** Restore data from a snapshot at the given index. Returns undefined if index is invalid. */
20
+ restore(index: number): Record<string, unknown> | undefined;
21
+ /** List available snapshots (metadata only, no data payload). */
22
+ list(): Array<{
23
+ timestamp: number;
24
+ label: string;
25
+ }>;
26
+ /** Clear all snapshots. */
27
+ clear(): void;
28
+ private persist;
29
+ }
@@ -0,0 +1,53 @@
1
+ export class SnapshotHistory {
2
+ snapshots = [];
3
+ maxSnapshots;
4
+ storageKey;
5
+ constructor(options) {
6
+ this.maxSnapshots = options?.maxSnapshots ?? 20;
7
+ this.storageKey = options?.storageKey;
8
+ if (this.storageKey) {
9
+ try {
10
+ const raw = localStorage.getItem(this.storageKey);
11
+ if (raw)
12
+ this.snapshots = JSON.parse(raw);
13
+ }
14
+ catch { /* corrupted or unavailable — start fresh */ }
15
+ }
16
+ }
17
+ /** Take a labeled snapshot of the given data. */
18
+ take(label, data) {
19
+ this.snapshots.push({
20
+ timestamp: Date.now(),
21
+ label,
22
+ data: JSON.stringify(data),
23
+ });
24
+ if (this.snapshots.length > this.maxSnapshots) {
25
+ this.snapshots = this.snapshots.slice(-this.maxSnapshots);
26
+ }
27
+ this.persist();
28
+ }
29
+ /** Restore data from a snapshot at the given index. Returns undefined if index is invalid. */
30
+ restore(index) {
31
+ const snapshot = this.snapshots[index];
32
+ if (!snapshot)
33
+ return undefined;
34
+ return JSON.parse(snapshot.data);
35
+ }
36
+ /** List available snapshots (metadata only, no data payload). */
37
+ list() {
38
+ return this.snapshots.map(({ timestamp, label }) => ({ timestamp, label }));
39
+ }
40
+ /** Clear all snapshots. */
41
+ clear() {
42
+ this.snapshots = [];
43
+ this.persist();
44
+ }
45
+ persist() {
46
+ if (!this.storageKey)
47
+ return;
48
+ try {
49
+ localStorage.setItem(this.storageKey, JSON.stringify(this.snapshots));
50
+ }
51
+ catch { /* quota exceeded — skip silently */ }
52
+ }
53
+ }
package/dist/index.d.ts CHANGED
@@ -3,9 +3,23 @@ export type { CryptoProvider, Base64Provider, PlatformConfig } from "@drakkar.so
3
3
  export { stableStringify, computeHash } from "@drakkar.software/starfish-protocol";
4
4
  export type { PullResult, PushSuccess } from "@drakkar.software/starfish-protocol";
5
5
  export { StarfishClient } from "./client.js";
6
+ export type { BlobPullResult, BlobPushResult } from "./client.js";
6
7
  export { SyncManager } from "./sync.js";
7
8
  export type { SyncManagerOptions } from "./sync.js";
8
9
  export { createEncryptor, ENCRYPTED_KEY } from "./crypto.js";
9
10
  export type { Encryptor } from "./crypto.js";
10
11
  export { ConflictError, StarfishHttpError, } from "./types.js";
11
12
  export type { StarfishClientOptions, AuthProvider, ConflictResolver, } from "./types.js";
13
+ export { consoleSyncLogger, noopSyncLogger } from "./logger.js";
14
+ export type { SyncLogger } from "./logger.js";
15
+ export { createMigrator } from "./migrate.js";
16
+ export type { MigrationFn, MigrationConfig } from "./migrate.js";
17
+ export { ValidationError, createSchemaValidator } from "./validate.js";
18
+ export type { Validator, ValidationResult } from "./validate.js";
19
+ export { classifyError } from "./fetch.js";
20
+ export type { ErrorCategory } from "./fetch.js";
21
+ export { createUnionMerge, createSoftDeleteResolver, timestampWinner, pruneTombstones, } from "./resolvers.js";
22
+ export { SnapshotHistory } from "./history.js";
23
+ export type { Snapshot, SnapshotHistoryOptions } from "./history.js";
24
+ export { startPolling, startAdaptivePolling } from "./polling.js";
25
+ export type { PollableState, AdaptivePollingOptions, AdaptivePollingControls } from "./polling.js";
package/dist/index.js CHANGED
@@ -4,3 +4,10 @@ export { StarfishClient } from "./client.js";
4
4
  export { SyncManager } from "./sync.js";
5
5
  export { createEncryptor, ENCRYPTED_KEY } from "./crypto.js";
6
6
  export { ConflictError, StarfishHttpError, } from "./types.js";
7
+ export { consoleSyncLogger, noopSyncLogger } from "./logger.js";
8
+ export { createMigrator } from "./migrate.js";
9
+ export { ValidationError, createSchemaValidator } from "./validate.js";
10
+ export { classifyError } from "./fetch.js";
11
+ export { createUnionMerge, createSoftDeleteResolver, timestampWinner, pruneTombstones, } from "./resolvers.js";
12
+ export { SnapshotHistory } from "./history.js";
13
+ export { startPolling, startAdaptivePolling } from "./polling.js";
@@ -0,0 +1,14 @@
1
+ /** Structured logger for sync operations. */
2
+ export interface SyncLogger {
3
+ pullStart(store: string): void;
4
+ pullSuccess(store: string, durationMs: number): void;
5
+ pullError(store: string, error: string): void;
6
+ pushStart(store: string): void;
7
+ pushSuccess(store: string, durationMs: number): void;
8
+ pushError(store: string, error: string): void;
9
+ conflict(store: string, attempt: number): void;
10
+ }
11
+ /** Console-based sync logger with structured output. */
12
+ export declare const consoleSyncLogger: SyncLogger;
13
+ /** Silent sync logger (no output). */
14
+ export declare const noopSyncLogger: SyncLogger;
package/dist/logger.js ADDED
@@ -0,0 +1,20 @@
1
+ /** Console-based sync logger with structured output. */
2
+ export const consoleSyncLogger = {
3
+ pullStart: (s) => console.log(`[starfish:${s}] pull started`),
4
+ pullSuccess: (s, ms) => console.log(`[starfish:${s}] pull OK (${ms}ms)`),
5
+ pullError: (s, err) => console.error(`[starfish:${s}] pull failed: ${err}`),
6
+ pushStart: (s) => console.log(`[starfish:${s}] push started`),
7
+ pushSuccess: (s, ms) => console.log(`[starfish:${s}] push OK (${ms}ms)`),
8
+ pushError: (s, err) => console.error(`[starfish:${s}] push failed: ${err}`),
9
+ conflict: (s, n) => console.warn(`[starfish:${s}] conflict (attempt ${n})`),
10
+ };
11
+ /** Silent sync logger (no output). */
12
+ export const noopSyncLogger = {
13
+ pullStart: () => { },
14
+ pullSuccess: () => { },
15
+ pullError: () => { },
16
+ pushStart: () => { },
17
+ pushSuccess: () => { },
18
+ pushError: () => { },
19
+ conflict: () => { },
20
+ };
@@ -0,0 +1,16 @@
1
+ /** A function that migrates data from one schema version to the next. */
2
+ export type MigrationFn = (data: Record<string, unknown>) => Record<string, unknown>;
3
+ export interface MigrationConfig {
4
+ /** The current schema version of the application. */
5
+ currentVersion: number;
6
+ /** Map of version number to the migration that upgrades FROM that version. */
7
+ migrations: Record<number, MigrationFn>;
8
+ }
9
+ /**
10
+ * Creates a migration runner that upgrades documents to the current schema version.
11
+ *
12
+ * Given a document with `_schemaVersion`, applies each migration in sequence
13
+ * until the document reaches `currentVersion`. Throws if the document version
14
+ * is ahead of the app (forward compatibility guard).
15
+ */
16
+ export declare function createMigrator(config: MigrationConfig): (data: Record<string, unknown>) => Record<string, unknown>;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Creates a migration runner that upgrades documents to the current schema version.
3
+ *
4
+ * Given a document with `_schemaVersion`, applies each migration in sequence
5
+ * until the document reaches `currentVersion`. Throws if the document version
6
+ * is ahead of the app (forward compatibility guard).
7
+ */
8
+ export function createMigrator(config) {
9
+ // Eagerly validate the migration chain
10
+ for (let v = 1; v < config.currentVersion; v++) {
11
+ if (!config.migrations[v]) {
12
+ throw new Error(`Missing migration for version ${v} -> ${v + 1}`);
13
+ }
14
+ }
15
+ return (data) => {
16
+ const version = typeof data._schemaVersion === "number" ? data._schemaVersion : 1;
17
+ if (version > config.currentVersion) {
18
+ throw new Error(`Document schema version ${version} is newer than app version ${config.currentVersion}. Update the app.`);
19
+ }
20
+ if (version === config.currentVersion)
21
+ return data;
22
+ let result = { ...data };
23
+ for (let v = version; v < config.currentVersion; v++) {
24
+ const fn = config.migrations[v];
25
+ if (!fn) {
26
+ throw new Error(`Missing migration for version ${v} -> ${v + 1}`);
27
+ }
28
+ result = fn(result);
29
+ }
30
+ result._schemaVersion = config.currentVersion;
31
+ return result;
32
+ };
33
+ }
@@ -0,0 +1,28 @@
1
+ /** Minimal state needed by polling utilities. */
2
+ export interface PollableState {
3
+ online: boolean;
4
+ syncing: boolean;
5
+ }
6
+ /**
7
+ * Start periodic pulling at a fixed interval.
8
+ * Skips pulls when offline or already syncing.
9
+ * Returns a cleanup function that stops polling.
10
+ */
11
+ export declare function startPolling(pullFn: () => Promise<void>, getState: () => PollableState, intervalMs?: number): () => void;
12
+ export interface AdaptivePollingOptions {
13
+ /** Override the base interval in ms. If set, skips network quality detection. */
14
+ intervalMs?: number;
15
+ /** Custom mapping from effectiveType to interval in ms. */
16
+ intervals?: Record<string, number>;
17
+ }
18
+ export interface AdaptivePollingControls {
19
+ pause: () => void;
20
+ resume: () => void;
21
+ stop: () => void;
22
+ }
23
+ /**
24
+ * Start polling with adaptive intervals based on network quality.
25
+ * Uses the Network Information API (`navigator.connection.effectiveType`) when available.
26
+ * Returns controls to pause, resume, or stop polling.
27
+ */
28
+ export declare function startAdaptivePolling(pullFn: () => Promise<void>, getState: () => PollableState, options?: AdaptivePollingOptions): AdaptivePollingControls;
@@ -0,0 +1,52 @@
1
+ const DEFAULT_INTERVALS = {
2
+ "slow-2g": 120_000,
3
+ "2g": 60_000,
4
+ "3g": 30_000,
5
+ "4g": 10_000,
6
+ };
7
+ const DEFAULT_FALLBACK_MS = 15_000;
8
+ /**
9
+ * Start periodic pulling at a fixed interval.
10
+ * Skips pulls when offline or already syncing.
11
+ * Returns a cleanup function that stops polling.
12
+ */
13
+ export function startPolling(pullFn, getState, intervalMs = 30_000) {
14
+ const timer = setInterval(() => {
15
+ const { online, syncing } = getState();
16
+ if (online && !syncing)
17
+ pullFn();
18
+ }, intervalMs);
19
+ return () => clearInterval(timer);
20
+ }
21
+ /**
22
+ * Start polling with adaptive intervals based on network quality.
23
+ * Uses the Network Information API (`navigator.connection.effectiveType`) when available.
24
+ * Returns controls to pause, resume, or stop polling.
25
+ */
26
+ export function startAdaptivePolling(pullFn, getState, options) {
27
+ let intervalMs;
28
+ if (options?.intervalMs != null) {
29
+ intervalMs = options.intervalMs;
30
+ }
31
+ else {
32
+ const intervals = options?.intervals ?? DEFAULT_INTERVALS;
33
+ let effectiveType;
34
+ if (typeof navigator !== "undefined" && "connection" in navigator) {
35
+ effectiveType = navigator.connection.effectiveType;
36
+ }
37
+ intervalMs = (effectiveType != null ? intervals[effectiveType] : undefined) ?? DEFAULT_FALLBACK_MS;
38
+ }
39
+ let paused = false;
40
+ const timer = setInterval(() => {
41
+ if (paused)
42
+ return;
43
+ const { online, syncing } = getState();
44
+ if (online && !syncing)
45
+ pullFn();
46
+ }, intervalMs);
47
+ return {
48
+ pause: () => { paused = true; },
49
+ resume: () => { paused = false; },
50
+ stop: () => clearInterval(timer),
51
+ };
52
+ }
@@ -0,0 +1,50 @@
1
+ import type { ConflictResolver } from "./types.js";
2
+ /**
3
+ * Creates a conflict resolver that merges arrays by ID with per-item
4
+ * timestamp comparison, and uses document-level timestamp for scalars.
5
+ *
6
+ * For arrays: builds a union of both sets keyed by `idKey`. When both
7
+ * sides have the same item, the one with the newer `timestampKey` wins.
8
+ * For scalars: the document with the newer `documentTimestampKey` wins.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * const merge = createUnionMerge()
13
+ * const sync = new SyncManager({ ..., onConflict: merge })
14
+ * ```
15
+ */
16
+ export declare function createUnionMerge(options?: {
17
+ /** Key used to identify items in arrays (default: "id"). */
18
+ idKey?: string;
19
+ /** Key used for per-item timestamp comparison (default: "updatedAt"). */
20
+ timestampKey?: string;
21
+ /** Key used for document-level timestamp comparison (default: "timestamp"). */
22
+ documentTimestampKey?: string;
23
+ }): ConflictResolver;
24
+ /**
25
+ * Creates a conflict resolver that handles soft-deleted items (tombstones).
26
+ * Extends union merge with tombstone awareness: if an item exists on one side
27
+ * with a `deletedAtKey` set, that deletion is respected even if the other side
28
+ * still has the item alive — as long as the deletion timestamp is newer.
29
+ */
30
+ export declare function createSoftDeleteResolver(options?: {
31
+ idKey?: string;
32
+ timestampKey?: string;
33
+ documentTimestampKey?: string;
34
+ /** Key marking an item as deleted (default: "_deletedAt"). */
35
+ deletedAtKey?: string;
36
+ }): ConflictResolver;
37
+ /**
38
+ * Simple resolver: the document with the newer timestamp wins entirely.
39
+ * No per-field or per-item merging.
40
+ */
41
+ export declare function timestampWinner(timestampKey?: string): ConflictResolver;
42
+ /**
43
+ * Remove expired tombstones from an array of items.
44
+ * Items with a `deletedAtKey` older than `ttlMs` are pruned.
45
+ *
46
+ * @param items - Array of items, some with a deletedAt timestamp
47
+ * @param ttlMs - Time-to-live in ms for tombstones (default: 30 days)
48
+ * @param deletedAtKey - Key marking deletion timestamp (default: "_deletedAt")
49
+ */
50
+ export declare function pruneTombstones<T extends Record<string, unknown>>(items: T[], ttlMs?: number, deletedAtKey?: string): T[];
@@ -0,0 +1,162 @@
1
+ /** Compare two timestamp values. Handles both numeric (epoch) and string (ISO-8601) timestamps. */
2
+ function compareTimestamps(a, b) {
3
+ if (typeof a === "number" && typeof b === "number")
4
+ return a >= b;
5
+ return String(a ?? "") >= String(b ?? "");
6
+ }
7
+ /**
8
+ * Creates a conflict resolver that merges arrays by ID with per-item
9
+ * timestamp comparison, and uses document-level timestamp for scalars.
10
+ *
11
+ * For arrays: builds a union of both sets keyed by `idKey`. When both
12
+ * sides have the same item, the one with the newer `timestampKey` wins.
13
+ * For scalars: the document with the newer `documentTimestampKey` wins.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * const merge = createUnionMerge()
18
+ * const sync = new SyncManager({ ..., onConflict: merge })
19
+ * ```
20
+ */
21
+ export function createUnionMerge(options) {
22
+ const idKey = options?.idKey ?? "id";
23
+ const tsKey = options?.timestampKey ?? "updatedAt";
24
+ const docTsKey = options?.documentTimestampKey ?? "timestamp";
25
+ return (local, remote) => {
26
+ const result = {};
27
+ const localNewer = compareTimestamps(local[docTsKey], remote[docTsKey]);
28
+ const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]);
29
+ for (const key of allKeys) {
30
+ const lv = local[key];
31
+ const rv = remote[key];
32
+ // Both sides have arrays — attempt ID-based union
33
+ if (Array.isArray(lv) && Array.isArray(rv)) {
34
+ const map = new Map();
35
+ // Seed with remote items
36
+ for (const item of rv) {
37
+ if (item && typeof item === "object" && idKey in item) {
38
+ map.set(item[idKey], item);
39
+ }
40
+ else {
41
+ map.set(Symbol(), item);
42
+ }
43
+ }
44
+ // Overlay local items (per-item timestamp wins)
45
+ for (const item of lv) {
46
+ if (item && typeof item === "object" && idKey in item) {
47
+ const localItem = item;
48
+ const id = localItem[idKey];
49
+ const remoteItem = map.get(id);
50
+ if (!remoteItem) {
51
+ map.set(id, localItem);
52
+ }
53
+ else {
54
+ if (compareTimestamps(localItem[tsKey], remoteItem[tsKey])) {
55
+ map.set(id, localItem);
56
+ }
57
+ }
58
+ }
59
+ else {
60
+ map.set(Symbol(), item);
61
+ }
62
+ }
63
+ result[key] = [...map.values()];
64
+ }
65
+ else if (lv !== undefined && rv !== undefined) {
66
+ // Scalar: document-level timestamp wins
67
+ result[key] = localNewer ? lv : rv;
68
+ }
69
+ else {
70
+ // Only one side has the key
71
+ result[key] = lv ?? rv;
72
+ }
73
+ }
74
+ return result;
75
+ };
76
+ }
77
+ /**
78
+ * Creates a conflict resolver that handles soft-deleted items (tombstones).
79
+ * Extends union merge with tombstone awareness: if an item exists on one side
80
+ * with a `deletedAtKey` set, that deletion is respected even if the other side
81
+ * still has the item alive — as long as the deletion timestamp is newer.
82
+ */
83
+ export function createSoftDeleteResolver(options) {
84
+ const idKey = options?.idKey ?? "id";
85
+ const tsKey = options?.timestampKey ?? "updatedAt";
86
+ const deletedAtKey = options?.deletedAtKey ?? "_deletedAt";
87
+ const baseMerge = createUnionMerge(options);
88
+ return (local, remote) => {
89
+ const merged = baseMerge(local, remote);
90
+ // Build a tombstone map from both sides: id → deletedAt timestamp
91
+ const tombstones = new Map();
92
+ for (const source of [local, remote]) {
93
+ for (const key of Object.keys(source)) {
94
+ const arr = source[key];
95
+ if (!Array.isArray(arr))
96
+ continue;
97
+ for (const item of arr) {
98
+ if (item && typeof item === "object" && idKey in item && deletedAtKey in item) {
99
+ const rec = item;
100
+ const id = rec[idKey];
101
+ const deletedAt = rec[deletedAtKey];
102
+ if (typeof deletedAt === "number") {
103
+ const existing = tombstones.get(id) ?? 0;
104
+ if (deletedAt > existing)
105
+ tombstones.set(id, deletedAt);
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+ // For merged arrays, ensure tombstoned items stay deleted
112
+ // (don't resurrect an item if its tombstone is newer than its updatedAt)
113
+ for (const key of Object.keys(merged)) {
114
+ const value = merged[key];
115
+ if (!Array.isArray(value))
116
+ continue;
117
+ merged[key] = value.filter((item) => {
118
+ if (!item || typeof item !== "object" || !(idKey in item))
119
+ return true;
120
+ const rec = item;
121
+ const id = rec[idKey];
122
+ const deletedAt = tombstones.get(id);
123
+ if (deletedAt == null)
124
+ return true;
125
+ // Keep the item if it has a deletedAt (it's the tombstone itself)
126
+ if (rec[deletedAtKey] != null)
127
+ return true;
128
+ // Filter out alive items that have a newer tombstone
129
+ return compareTimestamps(rec[tsKey], deletedAt) && rec[tsKey] !== deletedAt;
130
+ });
131
+ }
132
+ return merged;
133
+ };
134
+ }
135
+ /**
136
+ * Simple resolver: the document with the newer timestamp wins entirely.
137
+ * No per-field or per-item merging.
138
+ */
139
+ export function timestampWinner(timestampKey = "timestamp") {
140
+ return (local, remote) => {
141
+ return compareTimestamps(local[timestampKey], remote[timestampKey])
142
+ ? local
143
+ : remote;
144
+ };
145
+ }
146
+ /**
147
+ * Remove expired tombstones from an array of items.
148
+ * Items with a `deletedAtKey` older than `ttlMs` are pruned.
149
+ *
150
+ * @param items - Array of items, some with a deletedAt timestamp
151
+ * @param ttlMs - Time-to-live in ms for tombstones (default: 30 days)
152
+ * @param deletedAtKey - Key marking deletion timestamp (default: "_deletedAt")
153
+ */
154
+ export function pruneTombstones(items, ttlMs = 30 * 24 * 60 * 60 * 1000, deletedAtKey = "_deletedAt") {
155
+ const cutoff = Date.now() - ttlMs;
156
+ return items.filter((item) => {
157
+ const deletedAt = item[deletedAtKey];
158
+ if (deletedAt == null)
159
+ return true;
160
+ return typeof deletedAt === "number" && deletedAt > cutoff;
161
+ });
162
+ }