Offline-first synchronization layer bridging PocketBase (remote) and Dexie / IndexedDB (local).
┌─────────────┐ createSyncCollection() ┌──────────┐
│ PocketBase │ ◄────── CRUD / Batch ─────────► │ Dexie │
│ (remote) │ ◄────── realtime events ─────── │ (local) │
└─────────────┘ └────┬─────┘
│
useLiveQuery()
liveQuery()
│
┌──────▼──────┐
│ Svelte 5 │
│ components │
└─────────────┘
initialFetch() retrieves only records changed since the last known updated timestamp.
%%{init: {'theme': 'base', 'themeVariables': {'background':'#f8f9fa','primaryColor':'#e8f0fe','secondaryColor':'#fef3e2','tertiaryColor':'#e8f5e9','lineColor':'#555','fontSize':'13px'}, 'sequence': {'wrap':true,'width':180,'actorMargin':70,'messageMargin':36,'boxMargin':10,'noteMargin':10}}}%%
sequenceDiagram
participant UI as App
participant SC as Sync
participant DX as Dexie
participant PB as PocketBase
UI->>SC: initialFetch()
SC->>DX: last updated?
DX-->>SC: since timestamp
SC->>PB: getFullList(updated > since)
PB-->>SC: fresh records
SC->>DX: bulkPut(fresh)
alt tombstones exist
SC->>PB: get tombstones
PB-->>SC: deleted IDs
SC->>DX: mark or delete
end
SC-->>UI: done
PocketBase pushes events via SSE. pb-sync mirrors them to Dexie, triggering reactive UI updates.
%%{init: {'theme': 'base', 'themeVariables': {'background':'#f8f9fa','primaryColor':'#e8f0fe','secondaryColor':'#fef3e2','tertiaryColor':'#e8f5e9','lineColor':'#555','fontSize':'13px'}, 'sequence': {'wrap':true,'width':180,'actorMargin':70,'messageMargin':36,'boxMargin':10,'noteMargin':10}}}%%
sequenceDiagram
participant UI as Component
participant LQ as liveQuery
participant DX as Dexie
participant SC as Sync
participant PB as Server
UI->>SC: subscribe()
SC->>PB: open SSE stream
Note over PB: remote change occurs
PB->>SC: event (create/update/delete)
alt delete
SC->>DX: mark deleted or remove
else create / update
SC->>DX: put(record)
end
DX-->>LQ: table changed
LQ-->>UI: re-render
Writes are optimistic: Dexie updates immediately, then PocketBase. On failure, Dexie rolls back.
%%{init: {'theme': 'base', 'themeVariables': {'background':'#f8f9fa','primaryColor':'#e8f0fe','secondaryColor':'#fef3e2','tertiaryColor':'#e8f5e9','lineColor':'#555','fontSize':'13px'}, 'sequence': {'wrap':true,'width':180,'actorMargin':70,'messageMargin':36,'boxMargin':10,'noteMargin':10}}}%%
sequenceDiagram
participant UI as Service
participant SC as Sync
participant DX as Dexie
participant PB as PocketBase
UI->>SC: update(id, data)
SC->>DX: snapshot current
SC->>DX: put optimistic
alt merge strategies
SC->>PB: getOne(id)
PB-->>SC: server state
SC->>SC: merge arrays
end
SC->>PB: update(id, merged)
alt success
PB-->>SC: confirmed
SC->>DX: put confirmed
SC-->>UI: return result
else failure
PB-->>SC: error
SC->>DX: rollback snapshot
SC-->>UI: throw error
end
createBatch() chains operations into a single PocketBase batch request with full rollback.
%%{init: {'theme': 'base', 'themeVariables': {'background':'#f8f9fa','primaryColor':'#e8f0fe','secondaryColor':'#fef3e2','tertiaryColor':'#e8f5e9','lineColor':'#555','fontSize':'13px'}, 'sequence': {'wrap':true,'width':180,'actorMargin':70,'messageMargin':36,'boxMargin':10,'noteMargin':10}}}%%
sequenceDiagram
participant UI as Service
participant B as Batch
participant DX as Dexie
participant PB as PocketBase
UI->>B: createBatch()
loop chain
UI->>B: .create() .update() .delete()
end
UI->>B: .send()
B->>DX: snapshot + optimistic apply
B->>PB: batch.send()
alt all succeed
PB-->>B: results
B->>DX: bulkPut confirmed
B-->>UI: {records}
else any fails
PB-->>B: error
B->>DX: rollback all
B-->>UI: throw error
end
Two different paths depending on whether the user is authenticated.
%%{init: {'theme': 'base', 'themeVariables': {'background':'#f8f9fa','primaryColor':'#e8f0fe','secondaryColor':'#fef3e2','tertiaryColor':'#e8f5e9','lineColor':'#555','fontSize':'13px'}, 'flowchart': {'nodeSpacing':50,'rankSpacing':50,'padding':15}}}%%
flowchart TD
A["setActiveToken()"] --> B{"logged in?"}
B -->|"Yes"| C["Dexie lookup"]
C --> D{"found?"}
D -->|"Yes"| E["subscribe Dexie"]
E --> F["savePlanning"]
F --> G["done"]
D -->|"No"| H
B -->|"No"| H["fetch network"]
H --> I{"ok?"}
I -->|"error"| J["set error"]
I -->|"ok"| K["put Dexie"]
K --> L["initialFetch occurrences"]
L --> M["subscribe realtime"]
M --> N["savePlanning"]
N --> O["done"]
Components read from Dexie via liveQuery or useLiveQuery, never directly from PocketBase.
%%{init: {'theme': 'base', 'themeVariables': {'background':'#f8f9fa','primaryColor':'#e8f0fe','secondaryColor':'#fef3e2','tertiaryColor':'#e8f5e9','lineColor':'#555','fontSize':'13px'}, 'flowchart': {'nodeSpacing':60,'rankSpacing':60,'padding':20}}}%%
flowchart LR
subgraph Dexie
T1[masters]
T2[occurrences]
T3[commentState]
end
subgraph Store
S1[liveQuery]
S2[master state]
S3[occurrences state]
end
subgraph UI
C1[useLiveQuery]
C2[store.master]
C3[store.occurrences]
end
T1 --> S1 --> S2 --> C2 --> R1[render]
T2 --> S1 --> S3 --> C3 --> R1
T3 --> C1 --> R2[unread dot]
| File | Purpose |
|---|---|
collection.ts |
createSyncCollection() — main bridge API |
types.ts |
Shared TypeScript types |
db.ts |
Dexie database schema (appDB) |
batch.ts |
Standalone executeBatch() for multi-collection operations |
use-live-query.svelte.ts |
Svelte 5 reactive wrapper around Dexie liveQuery |
createSyncCollection<T>(pb, table, collectionName, options?)Creates a sync collection that binds a PocketBase collection to a Dexie table with realtime subscriptions and conflict-resolution strategies.
Parameters:
| Param | Type | Description |
|---|---|---|
pb |
PocketBase |
PocketBase client instance |
table |
Dexie.Table<T> |
Dexie table to sync with |
collectionName |
string |
PocketBase collection name |
options |
SyncCollectionOptions<T> |
Optional configuration (see below) |
Options:
interface SyncCollectionOptions<T> {
mergeStrategies?: {
// Per-field merge function: (localValue, remoteValue) => mergedValue
[K in keyof T]?: (local: T[K], remote: T[K]) => T[K];
};
softDelete?: boolean; // default: true — mark deleted instead of removing
tombstoneCollection?: string; // PocketBase collection tracking server-side deletions
localTrash?: boolean; // keep soft-deleted records locally with {deleted: true}
}
Returns an object with the following methods:
| Method | Signature | Description |
|---|---|---|
initialFetch |
(params?: PbQueryOptions) => Promise<void> |
Incremental sync: fetches records updated since last local updated timestamp. Also reconciles server-side deletions via tombstoneCollection. |
subscribe |
(params?: PbSubscribeOptions) => SubscriptionRef |
Opens a PocketBase realtime subscription. Events are applied to Dexie. Returns a SubscriptionRef for later unsubscription. |
unsubscribe |
(subRefOrId: SubscriptionRef | string) => Promise<void> |
Closes a specific realtime subscription. |
unsubscribeAll |
() => Promise<void> |
Closes all active realtime subscriptions for this collection. |
| Method | Signature | Description |
|---|---|---|
create |
(data: Omit<T, 'id' | 'updated' | 'created'>, params?) => Promise<T> |
Creates a record on PocketBase, then mirrors to Dexie. |
update |
(id: string, data: Partial<T>, params?) => Promise<T> |
Optimistic write to Dexie, then PocketBase. On failure, rolls back Dexie. Applies merge strategies for array fields. |
remove |
(id: string, params?) => Promise<void> |
Soft-delete or hard-delete (based on softDelete option). Rolls back on failure. |
| Method | Signature | Description |
|---|---|---|
list |
(params?: PbListOptions) => Promise<PaginatedList<T>> |
Paginated query directly to PocketBase. |
view |
(id: string, params?) => Promise<T> |
Fetch a single record directly from PocketBase. |
| Method | Signature | Description |
|---|---|---|
createBatch |
(params?) => CollectionBatch |
Returns a chainable batch builder. |
bulkCreate |
(items[], params?) => Promise<T[]> |
Shorthand: create multiple records. |
bulkUpdate |
({id, data}[], params?) => Promise<T[]> |
Shorthand: update multiple records. |
bulkUpsert |
(items[], params?) => Promise<T[]> |
Shorthand: upsert multiple records. |
bulkDelete |
(ids[], params?) => Promise<void> |
Shorthand: delete multiple records. |
| Method | Signature | Description |
|---|---|---|
getTable |
() => Table<T> |
Returns the underlying Dexie table for direct queries. |
collectionName |
getter | Returns the PocketBase collection name. |
mergeByKey<T>(key: string)Factory that creates a merge strategy for array fields. Merges local and remote arrays by a unique key, preferring local values on conflict.
// Example: merge participants by 'id'
mergeStrategies: {
participants: mergeByKey<Participant>("id");
}
useLiveQuery<T>(querier, deps?)Svelte 5 reactive wrapper. Must be called from a component <script> block (requires $effect context).
const query = useLiveQuery(
() => db.masters.get(masterId),
() => [masterId], // optional reactive deps
);
// query.current — latest value
// query.isLoading — boolean
// query.error — any
All operations accept optional PbQueryOptions:
interface PbQueryOptions {
filter?: string | [template: string, vars: Record<string, unknown>];
expand?: string; // e.g. 'master,owner'
fields?: string; // e.g. 'id,title,updated'
query?: Record<string, string>; // raw query params, e.g. { _token: '...' }
}
interface PbSubscribeOptions extends PbQueryOptions {
record?: string; // specific record ID, or omit for '*'
}
interface PbListOptions extends PbQueryOptions {
sort?: string;
page?: number;
perPage?: number;
}
executeBatch(pb, operations)Standalone batch executor for operations spanning multiple collections. Takes snapshots before applying, rolls back on failure.
await executeBatch(pb, [
{ type: 'create', table: db.masters, collection: 'planning_masters', data: {...} },
{ type: 'update', table: db.occurrences, collection: 'planning_occurrences', id: '...', data: {...} },
]);
planningStore.svelte.ts)Two sync collections are created with merge strategies for concurrent array field resolution:
export const mastersCollection = createSyncCollection<PlanningMaster>(pb, db.masters, "planning_masters", {
mergeStrategies: { participants: mergeByKey("id"), tasks: mergeByKey("id") },
});
export const occurrencesCollection = createSyncCollection<PlanningOccurrence>(
pb,
db.occurrences,
"planning_occurrences",
{
mergeStrategies: {
responses: mergeByKey("participantId"),
comments: mergeByKey("id"),
tasks: mergeByKey("id"),
},
},
);
#setActiveGuest)// 1. Initial fetch (incremental delta)
await occurrencesCollection.initialFetch({
filter: ["master = {:masterId}", { masterId: master.id }],
query: { _token: token },
});
// 2. Subscribe to realtime updates
mastersCollection.subscribe({ record: master.id, query: { _token: token } });
occurrencesCollection.subscribe({
filter: ["master = {:masterId}", { masterId: master.id }],
query: { _token: token },
});
#setActiveAuth)Authenticated users skip network fetch — data is already synced by the layout. They subscribe directly to Dexie liveQuery:
this.#masterSub = liveQuery(() => db.masters.get(masterId)).subscribe({
next: (val) => {
this.#master = val ?? null;
},
});
planningActions.ts)Components never call PocketBase directly. All mutations go through the service layer:
// Create
const master = await mastersCollection.create({ title, ... });
// Create with batch
const batch = occurrencesCollection.createBatch();
for (const date of dates) batch.create({ master: master.id, date, ... });
await batch.send();
// Update (with merge strategy for array fields)
await mastersCollection.update(masterId, { participants: [...] }, { query: { _token } });
// Delete (soft-delete by default)
await mastersCollection.remove(masterId, { query: { _token } });
OccurrenceView.svelte)Components use useLiveQuery for local Dexie tables that aren't covered by the store:
const commentStateQuery = useLiveQuery(
() => db.commentState.get(occurrence.id),
() => [occurrence.id],
);
On planning deactivation, all realtime subscriptions are torn down:
mastersCollection.unsubscribeAll();
occurrencesCollection.unsubscribeAll();
db.ts)db.ts is the only project-specific file — it must be customized to match your PocketBase collections. It defines:
WithMeta)Table<T> properties)$state Proxy objects before IndexedDB writes)See db.ts for detailed inline documentation on how to configure each part.
update() and remove() snapshot the local record before sending to PocketBase. On failure, the snapshot is restored.createBatch().send() snapshots all affected records. On failure, the transaction is rolled back to the pre-batch state.