stellar-drive 1.0.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 +607 -0
- package/dist/actions/remoteChange.d.ts +204 -0
- package/dist/actions/remoteChange.d.ts.map +1 -0
- package/dist/actions/remoteChange.js +424 -0
- package/dist/actions/remoteChange.js.map +1 -0
- package/dist/actions/truncateTooltip.d.ts +56 -0
- package/dist/actions/truncateTooltip.d.ts.map +1 -0
- package/dist/actions/truncateTooltip.js +312 -0
- package/dist/actions/truncateTooltip.js.map +1 -0
- package/dist/auth/crypto.d.ts +41 -0
- package/dist/auth/crypto.d.ts.map +1 -0
- package/dist/auth/crypto.js +50 -0
- package/dist/auth/crypto.js.map +1 -0
- package/dist/auth/deviceVerification.d.ts +283 -0
- package/dist/auth/deviceVerification.d.ts.map +1 -0
- package/dist/auth/deviceVerification.js +575 -0
- package/dist/auth/deviceVerification.js.map +1 -0
- package/dist/auth/displayUtils.d.ts +98 -0
- package/dist/auth/displayUtils.d.ts.map +1 -0
- package/dist/auth/displayUtils.js +145 -0
- package/dist/auth/displayUtils.js.map +1 -0
- package/dist/auth/loginGuard.d.ts +134 -0
- package/dist/auth/loginGuard.d.ts.map +1 -0
- package/dist/auth/loginGuard.js +276 -0
- package/dist/auth/loginGuard.js.map +1 -0
- package/dist/auth/offlineCredentials.d.ts +105 -0
- package/dist/auth/offlineCredentials.d.ts.map +1 -0
- package/dist/auth/offlineCredentials.js +176 -0
- package/dist/auth/offlineCredentials.js.map +1 -0
- package/dist/auth/offlineSession.d.ts +96 -0
- package/dist/auth/offlineSession.d.ts.map +1 -0
- package/dist/auth/offlineSession.js +145 -0
- package/dist/auth/offlineSession.js.map +1 -0
- package/dist/auth/resolveAuthState.d.ts +85 -0
- package/dist/auth/resolveAuthState.d.ts.map +1 -0
- package/dist/auth/resolveAuthState.js +249 -0
- package/dist/auth/resolveAuthState.js.map +1 -0
- package/dist/auth/singleUser.d.ts +498 -0
- package/dist/auth/singleUser.d.ts.map +1 -0
- package/dist/auth/singleUser.js +1282 -0
- package/dist/auth/singleUser.js.map +1 -0
- package/dist/bin/commands.d.ts +14 -0
- package/dist/bin/commands.d.ts.map +1 -0
- package/dist/bin/commands.js +68 -0
- package/dist/bin/commands.js.map +1 -0
- package/dist/bin/install-pwa.d.ts +41 -0
- package/dist/bin/install-pwa.d.ts.map +1 -0
- package/dist/bin/install-pwa.js +4594 -0
- package/dist/bin/install-pwa.js.map +1 -0
- package/dist/config.d.ts +249 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +395 -0
- package/dist/config.js.map +1 -0
- package/dist/conflicts.d.ts +306 -0
- package/dist/conflicts.d.ts.map +1 -0
- package/dist/conflicts.js +807 -0
- package/dist/conflicts.js.map +1 -0
- package/dist/crdt/awareness.d.ts +128 -0
- package/dist/crdt/awareness.d.ts.map +1 -0
- package/dist/crdt/awareness.js +284 -0
- package/dist/crdt/awareness.js.map +1 -0
- package/dist/crdt/channel.d.ts +165 -0
- package/dist/crdt/channel.d.ts.map +1 -0
- package/dist/crdt/channel.js +522 -0
- package/dist/crdt/channel.js.map +1 -0
- package/dist/crdt/config.d.ts +58 -0
- package/dist/crdt/config.d.ts.map +1 -0
- package/dist/crdt/config.js +123 -0
- package/dist/crdt/config.js.map +1 -0
- package/dist/crdt/helpers.d.ts +104 -0
- package/dist/crdt/helpers.d.ts.map +1 -0
- package/dist/crdt/helpers.js +116 -0
- package/dist/crdt/helpers.js.map +1 -0
- package/dist/crdt/offline.d.ts +58 -0
- package/dist/crdt/offline.d.ts.map +1 -0
- package/dist/crdt/offline.js +130 -0
- package/dist/crdt/offline.js.map +1 -0
- package/dist/crdt/persistence.d.ts +65 -0
- package/dist/crdt/persistence.d.ts.map +1 -0
- package/dist/crdt/persistence.js +171 -0
- package/dist/crdt/persistence.js.map +1 -0
- package/dist/crdt/provider.d.ts +109 -0
- package/dist/crdt/provider.d.ts.map +1 -0
- package/dist/crdt/provider.js +543 -0
- package/dist/crdt/provider.js.map +1 -0
- package/dist/crdt/store.d.ts +111 -0
- package/dist/crdt/store.d.ts.map +1 -0
- package/dist/crdt/store.js +158 -0
- package/dist/crdt/store.js.map +1 -0
- package/dist/crdt/types.d.ts +281 -0
- package/dist/crdt/types.d.ts.map +1 -0
- package/dist/crdt/types.js +26 -0
- package/dist/crdt/types.js.map +1 -0
- package/dist/data.d.ts +502 -0
- package/dist/data.d.ts.map +1 -0
- package/dist/data.js +862 -0
- package/dist/data.js.map +1 -0
- package/dist/database.d.ts +153 -0
- package/dist/database.d.ts.map +1 -0
- package/dist/database.js +325 -0
- package/dist/database.js.map +1 -0
- package/dist/debug.d.ts +87 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/debug.js +135 -0
- package/dist/debug.js.map +1 -0
- package/dist/demo.d.ts +131 -0
- package/dist/demo.d.ts.map +1 -0
- package/dist/demo.js +168 -0
- package/dist/demo.js.map +1 -0
- package/dist/deviceId.d.ts +47 -0
- package/dist/deviceId.d.ts.map +1 -0
- package/dist/deviceId.js +106 -0
- package/dist/deviceId.js.map +1 -0
- package/dist/diagnostics.d.ts +292 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +378 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/engine.d.ts +230 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +2636 -0
- package/dist/engine.js.map +1 -0
- package/dist/entries/actions.d.ts +16 -0
- package/dist/entries/actions.d.ts.map +1 -0
- package/dist/entries/actions.js +29 -0
- package/dist/entries/actions.js.map +1 -0
- package/dist/entries/auth.d.ts +19 -0
- package/dist/entries/auth.d.ts.map +1 -0
- package/dist/entries/auth.js +50 -0
- package/dist/entries/auth.js.map +1 -0
- package/dist/entries/config.d.ts +15 -0
- package/dist/entries/config.d.ts.map +1 -0
- package/dist/entries/config.js +20 -0
- package/dist/entries/config.js.map +1 -0
- package/dist/entries/crdt.d.ts +32 -0
- package/dist/entries/crdt.d.ts.map +1 -0
- package/dist/entries/crdt.js +52 -0
- package/dist/entries/crdt.js.map +1 -0
- package/dist/entries/kit.d.ts +22 -0
- package/dist/entries/kit.d.ts.map +1 -0
- package/dist/entries/kit.js +58 -0
- package/dist/entries/kit.js.map +1 -0
- package/dist/entries/stores.d.ts +22 -0
- package/dist/entries/stores.d.ts.map +1 -0
- package/dist/entries/stores.js +57 -0
- package/dist/entries/stores.js.map +1 -0
- package/dist/entries/types.d.ts +23 -0
- package/dist/entries/types.d.ts.map +1 -0
- package/dist/entries/types.js +12 -0
- package/dist/entries/types.js.map +1 -0
- package/dist/entries/utils.d.ts +12 -0
- package/dist/entries/utils.d.ts.map +1 -0
- package/dist/entries/utils.js +42 -0
- package/dist/entries/utils.js.map +1 -0
- package/dist/entries/vite.d.ts +20 -0
- package/dist/entries/vite.d.ts.map +1 -0
- package/dist/entries/vite.js +26 -0
- package/dist/entries/vite.js.map +1 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +234 -0
- package/dist/index.js.map +1 -0
- package/dist/kit/auth.d.ts +80 -0
- package/dist/kit/auth.d.ts.map +1 -0
- package/dist/kit/auth.js +75 -0
- package/dist/kit/auth.js.map +1 -0
- package/dist/kit/confirm.d.ts +111 -0
- package/dist/kit/confirm.d.ts.map +1 -0
- package/dist/kit/confirm.js +169 -0
- package/dist/kit/confirm.js.map +1 -0
- package/dist/kit/loads.d.ts +187 -0
- package/dist/kit/loads.d.ts.map +1 -0
- package/dist/kit/loads.js +208 -0
- package/dist/kit/loads.js.map +1 -0
- package/dist/kit/server.d.ts +175 -0
- package/dist/kit/server.d.ts.map +1 -0
- package/dist/kit/server.js +297 -0
- package/dist/kit/server.js.map +1 -0
- package/dist/kit/sw.d.ts +176 -0
- package/dist/kit/sw.d.ts.map +1 -0
- package/dist/kit/sw.js +320 -0
- package/dist/kit/sw.js.map +1 -0
- package/dist/queue.d.ts +306 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +925 -0
- package/dist/queue.js.map +1 -0
- package/dist/realtime.d.ts +280 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +1031 -0
- package/dist/realtime.js.map +1 -0
- package/dist/runtime/runtimeConfig.d.ts +110 -0
- package/dist/runtime/runtimeConfig.d.ts.map +1 -0
- package/dist/runtime/runtimeConfig.js +260 -0
- package/dist/runtime/runtimeConfig.js.map +1 -0
- package/dist/schema.d.ts +150 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +891 -0
- package/dist/schema.js.map +1 -0
- package/dist/stores/authState.d.ts +204 -0
- package/dist/stores/authState.d.ts.map +1 -0
- package/dist/stores/authState.js +336 -0
- package/dist/stores/authState.js.map +1 -0
- package/dist/stores/factories.d.ts +140 -0
- package/dist/stores/factories.d.ts.map +1 -0
- package/dist/stores/factories.js +157 -0
- package/dist/stores/factories.js.map +1 -0
- package/dist/stores/network.d.ts +48 -0
- package/dist/stores/network.d.ts.map +1 -0
- package/dist/stores/network.js +261 -0
- package/dist/stores/network.js.map +1 -0
- package/dist/stores/remoteChanges.d.ts +417 -0
- package/dist/stores/remoteChanges.d.ts.map +1 -0
- package/dist/stores/remoteChanges.js +626 -0
- package/dist/stores/remoteChanges.js.map +1 -0
- package/dist/stores/sync.d.ts +165 -0
- package/dist/stores/sync.d.ts.map +1 -0
- package/dist/stores/sync.js +275 -0
- package/dist/stores/sync.js.map +1 -0
- package/dist/supabase/auth.d.ts +219 -0
- package/dist/supabase/auth.d.ts.map +1 -0
- package/dist/supabase/auth.js +459 -0
- package/dist/supabase/auth.js.map +1 -0
- package/dist/supabase/client.d.ts +88 -0
- package/dist/supabase/client.d.ts.map +1 -0
- package/dist/supabase/client.js +313 -0
- package/dist/supabase/client.js.map +1 -0
- package/dist/supabase/validate.d.ts +118 -0
- package/dist/supabase/validate.d.ts.map +1 -0
- package/dist/supabase/validate.js +208 -0
- package/dist/supabase/validate.js.map +1 -0
- package/dist/sw/build/vite-plugin.d.ts +149 -0
- package/dist/sw/build/vite-plugin.d.ts.map +1 -0
- package/dist/sw/build/vite-plugin.js +517 -0
- package/dist/sw/build/vite-plugin.js.map +1 -0
- package/dist/sw/sw.js +664 -0
- package/dist/types.d.ts +363 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +85 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +156 -0
- package/dist/utils.js.map +1 -0
- package/package.json +117 -0
- package/src/components/DeferredChangesBanner.svelte +477 -0
- package/src/components/DemoBanner.svelte +110 -0
- package/src/components/SyncStatus.svelte +1732 -0
package/dist/data.js
ADDED
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Generic CRUD and Query Operations for the Stellar Sync Engine
|
|
3
|
+
*
|
|
4
|
+
* This module serves as the primary data access layer for the sync engine,
|
|
5
|
+
* replacing per-entity repository boilerplate with a unified, table-driven API.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* - Callers reference tables by their **Supabase** name (the remote/canonical name).
|
|
9
|
+
* - Internally, every operation resolves that name to the corresponding **Dexie**
|
|
10
|
+
* (IndexedDB) table name via the configured table map.
|
|
11
|
+
* - All write operations (create, update, delete, increment, batch) follow the
|
|
12
|
+
* same transactional pattern:
|
|
13
|
+
* 1. Open a Dexie read-write transaction spanning the target table + syncQueue.
|
|
14
|
+
* 2. Apply the mutation locally.
|
|
15
|
+
* 3. Enqueue the corresponding sync operation for eventual push to Supabase.
|
|
16
|
+
* 4. After commit, mark the entity as modified and schedule a sync push.
|
|
17
|
+
* - All read operations query Dexie first, with an optional remote fallback that
|
|
18
|
+
* fetches from Supabase when the local store is empty and the device is online.
|
|
19
|
+
*
|
|
20
|
+
* This dual-layer design enables full offline-first functionality: the app works
|
|
21
|
+
* against the local Dexie store, and the sync queue ensures changes propagate to
|
|
22
|
+
* the server when connectivity is available.
|
|
23
|
+
*
|
|
24
|
+
* @see {@link ./config} for table map and column configuration
|
|
25
|
+
* @see {@link ./database} for Dexie database instance management
|
|
26
|
+
* @see {@link ./queue} for sync queue enqueueing operations
|
|
27
|
+
* @see {@link ./engine} for sync push scheduling and entity modification tracking
|
|
28
|
+
* @see {@link ./conflicts} for conflict resolution during sync pull
|
|
29
|
+
*/
|
|
30
|
+
import { getTableMap, getTableColumns } from './config';
|
|
31
|
+
import { getDb } from './database';
|
|
32
|
+
import { queueCreateOperation, queueDeleteOperation, queueSyncOperation } from './queue';
|
|
33
|
+
import { markEntityModified, scheduleSyncPush } from './engine';
|
|
34
|
+
import { generateId, now } from './utils';
|
|
35
|
+
import { debugError } from './debug';
|
|
36
|
+
import { supabase } from './supabase/client';
|
|
37
|
+
import { isDemoMode } from './demo';
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// HELPERS
|
|
40
|
+
// =============================================================================
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a Supabase (remote) table name to its corresponding Dexie (local) table name.
|
|
43
|
+
*
|
|
44
|
+
* The table map is defined by the host application at initialization time via
|
|
45
|
+
* `configureStellarEngine`. If no mapping exists for the given name, the
|
|
46
|
+
* Supabase name is returned as-is (identity mapping), which is the common case
|
|
47
|
+
* when local and remote table names are identical.
|
|
48
|
+
*
|
|
49
|
+
* @param supabaseName - The canonical Supabase table name used by the caller.
|
|
50
|
+
* @returns The corresponding Dexie table name for local IndexedDB operations.
|
|
51
|
+
*
|
|
52
|
+
* @see {@link ./config} for `getTableMap` and engine configuration
|
|
53
|
+
*/
|
|
54
|
+
function getDexieTableName(supabaseName) {
|
|
55
|
+
const map = getTableMap();
|
|
56
|
+
return map[supabaseName] || supabaseName;
|
|
57
|
+
}
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// SINGLE-ENTITY WRITE OPERATIONS
|
|
60
|
+
// =============================================================================
|
|
61
|
+
/**
|
|
62
|
+
* Create a new entity in the local store and enqueue it for remote sync.
|
|
63
|
+
*
|
|
64
|
+
* This is the primary entry point for all entity creation. It performs the
|
|
65
|
+
* following steps atomically within a single Dexie transaction:
|
|
66
|
+
* 1. Inserts the entity into the local Dexie table.
|
|
67
|
+
* 2. Enqueues a `create` operation in the sync queue.
|
|
68
|
+
*
|
|
69
|
+
* After the transaction commits, it marks the entity as modified (for reactive
|
|
70
|
+
* UI updates) and schedules a sync push to propagate the change to Supabase.
|
|
71
|
+
*
|
|
72
|
+
* The caller is responsible for providing all required fields (including
|
|
73
|
+
* timestamps like `created_at` and `updated_at`). If `data.id` is omitted,
|
|
74
|
+
* a new UUID is generated automatically.
|
|
75
|
+
*
|
|
76
|
+
* @param table - The Supabase table name (resolved internally to a Dexie table).
|
|
77
|
+
* @param data - The full entity payload. May include `id`; if absent, one is generated.
|
|
78
|
+
* @returns The created entity payload (with `id` guaranteed to be present).
|
|
79
|
+
*
|
|
80
|
+
* @throws {Dexie.ConstraintError} If an entity with the same `id` already exists.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* const task = await engineCreate('tasks', {
|
|
85
|
+
* title: 'Write docs',
|
|
86
|
+
* user_id: currentUserId,
|
|
87
|
+
* created_at: now(),
|
|
88
|
+
* updated_at: now(),
|
|
89
|
+
* });
|
|
90
|
+
* console.log(task.id); // auto-generated UUID
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* @see {@link engineBatchWrite} for creating multiple entities atomically
|
|
94
|
+
* @see {@link queueCreateOperation} for the sync queue entry format
|
|
95
|
+
*/
|
|
96
|
+
export async function engineCreate(table, data) {
|
|
97
|
+
const db = getDb();
|
|
98
|
+
const dexieTable = getDexieTableName(table);
|
|
99
|
+
const entityId = data.id || generateId();
|
|
100
|
+
const payload = { ...data, id: entityId };
|
|
101
|
+
/* The queue stores `id` as a separate column, so we strip it from the payload
|
|
102
|
+
to avoid duplicating it in the serialized operation data. */
|
|
103
|
+
const { id: _id, ...queuePayload } = payload;
|
|
104
|
+
await db.transaction('rw', [db.table(dexieTable), db.table('syncQueue')], async () => {
|
|
105
|
+
await db.table(dexieTable).add(payload);
|
|
106
|
+
await queueCreateOperation(table, entityId, queuePayload);
|
|
107
|
+
});
|
|
108
|
+
/* Post-transaction side effects: these are intentionally outside the transaction
|
|
109
|
+
because they are non-critical (UI reactivity + debounced network push). */
|
|
110
|
+
markEntityModified(entityId);
|
|
111
|
+
scheduleSyncPush();
|
|
112
|
+
return payload;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Update specific fields on an existing entity.
|
|
116
|
+
*
|
|
117
|
+
* Automatically sets `updated_at` to the current timestamp, enqueues a `set`
|
|
118
|
+
* sync operation, and notifies the engine of the modification. The update and
|
|
119
|
+
* queue entry are wrapped in a single transaction for atomicity.
|
|
120
|
+
*
|
|
121
|
+
* If the entity does not exist (e.g., it was deleted between the caller's
|
|
122
|
+
* check and this call), the sync operation is skipped and `undefined` is
|
|
123
|
+
* returned -- no orphan queue entries are created.
|
|
124
|
+
*
|
|
125
|
+
* @param table - The Supabase table name.
|
|
126
|
+
* @param id - The primary key of the entity to update.
|
|
127
|
+
* @param fields - A partial record of fields to merge into the entity.
|
|
128
|
+
* @returns The fully updated entity record, or `undefined` if the entity was not found.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```ts
|
|
132
|
+
* const updated = await engineUpdate('tasks', taskId, { title: 'New title' });
|
|
133
|
+
* // updated.updated_at is automatically set
|
|
134
|
+
* ```
|
|
135
|
+
*
|
|
136
|
+
* @see {@link engineIncrement} for numeric field increments with conflict-safe semantics
|
|
137
|
+
* @see {@link queueSyncOperation} for the `set` operation queue format
|
|
138
|
+
*/
|
|
139
|
+
export async function engineUpdate(table, id, fields) {
|
|
140
|
+
const db = getDb();
|
|
141
|
+
const dexieTable = getDexieTableName(table);
|
|
142
|
+
const timestamp = now();
|
|
143
|
+
const updateFields = { ...fields, updated_at: timestamp };
|
|
144
|
+
let updated;
|
|
145
|
+
await db.transaction('rw', [db.table(dexieTable), db.table('syncQueue')], async () => {
|
|
146
|
+
await db.table(dexieTable).update(id, updateFields);
|
|
147
|
+
/* Re-read the entity after update to return the complete merged record,
|
|
148
|
+
and to verify the entity actually existed before queuing a sync op. */
|
|
149
|
+
updated = await db.table(dexieTable).get(id);
|
|
150
|
+
if (updated) {
|
|
151
|
+
await queueSyncOperation({
|
|
152
|
+
table,
|
|
153
|
+
entityId: id,
|
|
154
|
+
operationType: 'set',
|
|
155
|
+
value: updateFields
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
if (updated) {
|
|
160
|
+
markEntityModified(id);
|
|
161
|
+
scheduleSyncPush();
|
|
162
|
+
}
|
|
163
|
+
return updated;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Soft-delete an entity by setting `deleted: true`.
|
|
167
|
+
*
|
|
168
|
+
* The engine uses soft deletes rather than hard deletes so that the deletion
|
|
169
|
+
* can be synced to other devices and to the server. The sync queue receives a
|
|
170
|
+
* `delete` operation, which the push logic translates into a Supabase update
|
|
171
|
+
* that sets `deleted = true` on the remote row.
|
|
172
|
+
*
|
|
173
|
+
* The entity remains in the local Dexie store (with `deleted: true`) until a
|
|
174
|
+
* future compaction or full re-sync removes it.
|
|
175
|
+
*
|
|
176
|
+
* @param table - The Supabase table name.
|
|
177
|
+
* @param id - The primary key of the entity to soft-delete.
|
|
178
|
+
* @returns Resolves when the local update and queue entry are committed.
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* ```ts
|
|
182
|
+
* await engineDelete('tasks', taskId);
|
|
183
|
+
* // The task still exists locally with deleted: true
|
|
184
|
+
* // It will be synced as a deletion on the next push
|
|
185
|
+
* ```
|
|
186
|
+
*
|
|
187
|
+
* @see {@link queueDeleteOperation} for the delete queue entry format
|
|
188
|
+
*/
|
|
189
|
+
export async function engineDelete(table, id) {
|
|
190
|
+
const db = getDb();
|
|
191
|
+
const dexieTable = getDexieTableName(table);
|
|
192
|
+
const timestamp = now();
|
|
193
|
+
await db.transaction('rw', [db.table(dexieTable), db.table('syncQueue')], async () => {
|
|
194
|
+
await db.table(dexieTable).update(id, { deleted: true, updated_at: timestamp });
|
|
195
|
+
await queueDeleteOperation(table, id);
|
|
196
|
+
});
|
|
197
|
+
markEntityModified(id);
|
|
198
|
+
scheduleSyncPush();
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Execute multiple write operations in a single atomic Dexie transaction.
|
|
202
|
+
*
|
|
203
|
+
* This is the preferred way to perform related mutations that must succeed or
|
|
204
|
+
* fail together (e.g., creating a parent entity and its children, or moving an
|
|
205
|
+
* item from one list to another). All operations share a single `updated_at`
|
|
206
|
+
* timestamp for consistency.
|
|
207
|
+
*
|
|
208
|
+
* Transaction scope is dynamically computed: only the Dexie tables referenced
|
|
209
|
+
* by the operations (plus `syncQueue`) are locked, minimizing contention.
|
|
210
|
+
*
|
|
211
|
+
* After the transaction commits, all modified entity IDs are marked as modified
|
|
212
|
+
* in a single pass, and a single sync push is scheduled (not one per operation).
|
|
213
|
+
*
|
|
214
|
+
* @param operations - An ordered array of create/update/delete operations.
|
|
215
|
+
* @returns Resolves when all operations have been committed.
|
|
216
|
+
*
|
|
217
|
+
* @throws {Dexie.AbortError} If any operation fails, the entire batch is rolled back.
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* ```ts
|
|
221
|
+
* await engineBatchWrite([
|
|
222
|
+
* { type: 'create', table: 'tasks', data: { title: 'Subtask 1', parent_id: parentId } },
|
|
223
|
+
* { type: 'create', table: 'tasks', data: { title: 'Subtask 2', parent_id: parentId } },
|
|
224
|
+
* { type: 'update', table: 'projects', id: projectId, fields: { task_count: newCount } },
|
|
225
|
+
* ]);
|
|
226
|
+
* ```
|
|
227
|
+
*
|
|
228
|
+
* @see {@link engineCreate} for single-entity create semantics
|
|
229
|
+
* @see {@link engineUpdate} for single-entity update semantics
|
|
230
|
+
* @see {@link engineDelete} for single-entity delete semantics
|
|
231
|
+
*/
|
|
232
|
+
export async function engineBatchWrite(operations) {
|
|
233
|
+
const db = getDb();
|
|
234
|
+
const timestamp = now();
|
|
235
|
+
/* Collect all unique Dexie table names needed for the transaction scope.
|
|
236
|
+
We pre-compute this so Dexie can acquire the minimal set of table locks
|
|
237
|
+
upfront, avoiding deadlocks with concurrent transactions. */
|
|
238
|
+
const tableNames = new Set();
|
|
239
|
+
tableNames.add('syncQueue');
|
|
240
|
+
for (const op of operations) {
|
|
241
|
+
tableNames.add(getDexieTableName(op.table));
|
|
242
|
+
}
|
|
243
|
+
const tables = Array.from(tableNames).map((name) => db.table(name));
|
|
244
|
+
const modifiedIds = [];
|
|
245
|
+
await db.transaction('rw', tables, async () => {
|
|
246
|
+
for (const op of operations) {
|
|
247
|
+
const dexieTable = getDexieTableName(op.table);
|
|
248
|
+
switch (op.type) {
|
|
249
|
+
case 'create': {
|
|
250
|
+
const entityId = op.data.id || generateId();
|
|
251
|
+
const payload = { ...op.data, id: entityId };
|
|
252
|
+
const { id: _id, ...queuePayload } = payload;
|
|
253
|
+
await db.table(dexieTable).add(payload);
|
|
254
|
+
await queueCreateOperation(op.table, entityId, queuePayload);
|
|
255
|
+
modifiedIds.push(entityId);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case 'update': {
|
|
259
|
+
const updateFields = { ...op.fields, updated_at: timestamp };
|
|
260
|
+
await db.table(dexieTable).update(op.id, updateFields);
|
|
261
|
+
await queueSyncOperation({
|
|
262
|
+
table: op.table,
|
|
263
|
+
entityId: op.id,
|
|
264
|
+
operationType: 'set',
|
|
265
|
+
value: updateFields
|
|
266
|
+
});
|
|
267
|
+
modifiedIds.push(op.id);
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
case 'delete': {
|
|
271
|
+
await db.table(dexieTable).update(op.id, { deleted: true, updated_at: timestamp });
|
|
272
|
+
await queueDeleteOperation(op.table, op.id);
|
|
273
|
+
modifiedIds.push(op.id);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
/* Batch-notify all modified entities after the transaction commits.
|
|
280
|
+
A single scheduleSyncPush() call is sufficient because the push logic
|
|
281
|
+
drains the entire queue, not just one entry. */
|
|
282
|
+
for (const id of modifiedIds) {
|
|
283
|
+
markEntityModified(id);
|
|
284
|
+
}
|
|
285
|
+
scheduleSyncPush();
|
|
286
|
+
}
|
|
287
|
+
// =============================================================================
|
|
288
|
+
// INCREMENT OPERATION
|
|
289
|
+
// =============================================================================
|
|
290
|
+
/**
|
|
291
|
+
* Atomically increment a numeric field on an entity.
|
|
292
|
+
*
|
|
293
|
+
* Unlike a plain `engineUpdate` with a computed value, this function preserves
|
|
294
|
+
* the **increment intent** in the sync queue (operationType: 'increment').
|
|
295
|
+
* This is critical for correct multi-device conflict resolution: when two
|
|
296
|
+
* devices each increment a counter by 1, the server can apply both increments
|
|
297
|
+
* additively (+2) rather than last-write-wins (which would yield +1).
|
|
298
|
+
*
|
|
299
|
+
* The local Dexie value is updated immediately (read-modify-write inside a
|
|
300
|
+
* transaction to prevent TOCTOU races). If additional fields need to be set
|
|
301
|
+
* alongside the increment (e.g., a `completed` flag), they are queued as a
|
|
302
|
+
* separate `set` operation so the increment and set semantics remain distinct.
|
|
303
|
+
*
|
|
304
|
+
* @param table - The Supabase table name.
|
|
305
|
+
* @param id - The primary key of the entity to increment.
|
|
306
|
+
* @param field - The name of the numeric field to increment.
|
|
307
|
+
* @param amount - The increment delta (can be negative for decrements).
|
|
308
|
+
* @param additionalFields - Optional extra fields to set alongside the increment
|
|
309
|
+
* (e.g., `{ completed: true }`). These are queued as a
|
|
310
|
+
* separate `set` operation.
|
|
311
|
+
* @returns The fully updated entity record, or `undefined` if the entity was not found.
|
|
312
|
+
*
|
|
313
|
+
* @example
|
|
314
|
+
* ```ts
|
|
315
|
+
* // Increment a task's focus_count by 1 and mark it as touched
|
|
316
|
+
* const updated = await engineIncrement('tasks', taskId, 'focus_count', 1, {
|
|
317
|
+
* last_focused_at: now(),
|
|
318
|
+
* });
|
|
319
|
+
* ```
|
|
320
|
+
*
|
|
321
|
+
* @see {@link engineUpdate} for non-increment field updates
|
|
322
|
+
* @see {@link ./conflicts} for how increment operations are resolved during sync
|
|
323
|
+
*/
|
|
324
|
+
export async function engineIncrement(table, id, field, amount, additionalFields) {
|
|
325
|
+
const db = getDb();
|
|
326
|
+
const dexieTable = getDexieTableName(table);
|
|
327
|
+
const timestamp = now();
|
|
328
|
+
let updated;
|
|
329
|
+
await db.transaction('rw', [db.table(dexieTable), db.table('syncQueue')], async () => {
|
|
330
|
+
/* Read current value inside the transaction to prevent TOCTOU race:
|
|
331
|
+
another tab or transaction could modify the value between our read
|
|
332
|
+
and write if we read outside the transaction boundary. */
|
|
333
|
+
const current = await db.table(dexieTable).get(id);
|
|
334
|
+
if (!current)
|
|
335
|
+
return;
|
|
336
|
+
const currentValue = current[field] || 0;
|
|
337
|
+
const newValue = currentValue + amount;
|
|
338
|
+
const updateFields = {
|
|
339
|
+
[field]: newValue,
|
|
340
|
+
updated_at: timestamp,
|
|
341
|
+
...additionalFields
|
|
342
|
+
};
|
|
343
|
+
await db.table(dexieTable).update(id, updateFields);
|
|
344
|
+
updated = await db.table(dexieTable).get(id);
|
|
345
|
+
if (updated) {
|
|
346
|
+
/* Queue the increment as its own operation type so the sync push
|
|
347
|
+
can send it as an RPC / SQL increment rather than a flat set. */
|
|
348
|
+
await queueSyncOperation({
|
|
349
|
+
table,
|
|
350
|
+
entityId: id,
|
|
351
|
+
operationType: 'increment',
|
|
352
|
+
field,
|
|
353
|
+
value: amount
|
|
354
|
+
});
|
|
355
|
+
/* Queue additional fields as a separate set operation if present.
|
|
356
|
+
Keeping them separate ensures the increment semantic is not
|
|
357
|
+
conflated with plain field overwrites during conflict resolution. */
|
|
358
|
+
if (additionalFields && Object.keys(additionalFields).length > 0) {
|
|
359
|
+
await queueSyncOperation({
|
|
360
|
+
table,
|
|
361
|
+
entityId: id,
|
|
362
|
+
operationType: 'set',
|
|
363
|
+
value: { ...additionalFields, updated_at: timestamp }
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
if (updated) {
|
|
369
|
+
markEntityModified(id);
|
|
370
|
+
scheduleSyncPush();
|
|
371
|
+
}
|
|
372
|
+
return updated;
|
|
373
|
+
}
|
|
374
|
+
// =============================================================================
|
|
375
|
+
// QUERY OPERATIONS
|
|
376
|
+
// =============================================================================
|
|
377
|
+
/**
|
|
378
|
+
* Retrieve a single entity by its primary key.
|
|
379
|
+
*
|
|
380
|
+
* Queries the local Dexie store first. If the entity is not found locally and
|
|
381
|
+
* `remoteFallback` is enabled (and the device is online), a single-row fetch
|
|
382
|
+
* is made from Supabase. The remote result is cached locally in Dexie for
|
|
383
|
+
* subsequent offline access.
|
|
384
|
+
*
|
|
385
|
+
* The remote fallback filters out soft-deleted rows (`deleted IS NULL OR deleted = false`)
|
|
386
|
+
* to avoid resurrecting deleted entities.
|
|
387
|
+
*
|
|
388
|
+
* @param table - The Supabase table name.
|
|
389
|
+
* @param id - The primary key of the entity to retrieve.
|
|
390
|
+
* @param opts - Optional configuration.
|
|
391
|
+
* @param opts.remoteFallback - If `true`, fall back to a Supabase query when
|
|
392
|
+
* the entity is not found locally. Defaults to `false`.
|
|
393
|
+
* @returns The entity record, or `null` if not found (locally or remotely).
|
|
394
|
+
*
|
|
395
|
+
* @example
|
|
396
|
+
* ```ts
|
|
397
|
+
* // Local-only lookup (fast, offline-safe)
|
|
398
|
+
* const task = await engineGet('tasks', taskId);
|
|
399
|
+
*
|
|
400
|
+
* // With remote fallback for cache misses
|
|
401
|
+
* const task = await engineGet('tasks', taskId, { remoteFallback: true });
|
|
402
|
+
* ```
|
|
403
|
+
*
|
|
404
|
+
* @see {@link engineGetAll} for retrieving all entities from a table
|
|
405
|
+
* @see {@link engineQuery} for index-based filtered queries
|
|
406
|
+
* @see {@link getTableColumns} for column projection on remote queries
|
|
407
|
+
*/
|
|
408
|
+
export async function engineGet(table, id, opts) {
|
|
409
|
+
const db = getDb();
|
|
410
|
+
const dexieTable = getDexieTableName(table);
|
|
411
|
+
const local = await db.table(dexieTable).get(id);
|
|
412
|
+
if (local)
|
|
413
|
+
return local;
|
|
414
|
+
/* Remote fallback: only attempted when explicitly opted in AND the browser
|
|
415
|
+
reports online status. Skipped in demo mode (sandboxed, no Supabase). */
|
|
416
|
+
if (opts?.remoteFallback &&
|
|
417
|
+
!isDemoMode() &&
|
|
418
|
+
typeof navigator !== 'undefined' &&
|
|
419
|
+
navigator.onLine) {
|
|
420
|
+
try {
|
|
421
|
+
const columns = getTableColumns(table);
|
|
422
|
+
const { data, error } = await supabase
|
|
423
|
+
.from(table)
|
|
424
|
+
.select(columns)
|
|
425
|
+
.eq('id', id)
|
|
426
|
+
.or('deleted.is.null,deleted.eq.false')
|
|
427
|
+
.maybeSingle();
|
|
428
|
+
if (!error && data) {
|
|
429
|
+
/* Cache the remote result locally so future reads are instant and offline-safe. */
|
|
430
|
+
await db.table(dexieTable).put(data);
|
|
431
|
+
return data;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch (e) {
|
|
435
|
+
debugError(`[Data] Remote fallback failed for ${table}/${id}:`, e);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Retrieve all entities from a table, with optional ordering and remote fallback.
|
|
442
|
+
*
|
|
443
|
+
* Returns the full (non-filtered) contents of the local Dexie table. If the
|
|
444
|
+
* local table is empty and `remoteFallback` is enabled, a bulk fetch from
|
|
445
|
+
* Supabase is performed and results are cached locally via `bulkPut`.
|
|
446
|
+
*
|
|
447
|
+
* Note: This does NOT filter out soft-deleted entities locally. Callers that
|
|
448
|
+
* need to exclude deleted records should filter the results themselves. The
|
|
449
|
+
* remote fallback, however, does exclude deleted rows to avoid pulling down
|
|
450
|
+
* tombstones.
|
|
451
|
+
*
|
|
452
|
+
* @param table - The Supabase table name.
|
|
453
|
+
* @param opts - Optional configuration.
|
|
454
|
+
* @param opts.orderBy - A Dexie-indexed field name to sort results by.
|
|
455
|
+
* @param opts.remoteFallback - If `true`, fall back to Supabase when the local
|
|
456
|
+
* table is empty. Defaults to `false`.
|
|
457
|
+
* @returns An array of entity records (may be empty).
|
|
458
|
+
*
|
|
459
|
+
* @example
|
|
460
|
+
* ```ts
|
|
461
|
+
* // Get all tasks ordered by creation date
|
|
462
|
+
* const tasks = await engineGetAll('tasks', { orderBy: 'created_at' });
|
|
463
|
+
*
|
|
464
|
+
* // Bootstrap from remote on first load
|
|
465
|
+
* const tasks = await engineGetAll('tasks', { remoteFallback: true });
|
|
466
|
+
* ```
|
|
467
|
+
*
|
|
468
|
+
* @see {@link engineGet} for single-entity retrieval
|
|
469
|
+
* @see {@link engineQuery} for filtered queries by index
|
|
470
|
+
*/
|
|
471
|
+
export async function engineGetAll(table, opts) {
|
|
472
|
+
const db = getDb();
|
|
473
|
+
const dexieTable = getDexieTableName(table);
|
|
474
|
+
let results;
|
|
475
|
+
if (opts?.orderBy) {
|
|
476
|
+
results = await db.table(dexieTable).orderBy(opts.orderBy).toArray();
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
results = await db.table(dexieTable).toArray();
|
|
480
|
+
}
|
|
481
|
+
/* Remote fallback only fires when the local table is completely empty.
|
|
482
|
+
This handles the "first device" or "fresh install" scenario where no
|
|
483
|
+
data has been synced down yet. Skipped in demo mode (sandboxed). */
|
|
484
|
+
if (results.length === 0 &&
|
|
485
|
+
opts?.remoteFallback &&
|
|
486
|
+
!isDemoMode() &&
|
|
487
|
+
typeof navigator !== 'undefined' &&
|
|
488
|
+
navigator.onLine) {
|
|
489
|
+
try {
|
|
490
|
+
const columns = getTableColumns(table);
|
|
491
|
+
const { data, error } = await supabase
|
|
492
|
+
.from(table)
|
|
493
|
+
.select(columns)
|
|
494
|
+
.or('deleted.is.null,deleted.eq.false');
|
|
495
|
+
if (!error && data && data.length > 0) {
|
|
496
|
+
await db.table(dexieTable).bulkPut(data);
|
|
497
|
+
/* If ordering was requested, re-read from Dexie to get proper index-based
|
|
498
|
+
ordering rather than relying on Supabase's default sort order. */
|
|
499
|
+
if (opts?.orderBy) {
|
|
500
|
+
results = await db.table(dexieTable).orderBy(opts.orderBy).toArray();
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
results = data;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
catch (e) {
|
|
508
|
+
debugError(`[Data] Remote fallback failed for ${table}:`, e);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return results;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Query entities by a single indexed field value (equivalent to `WHERE index = value`).
|
|
515
|
+
*
|
|
516
|
+
* Uses Dexie's indexed `where().equals()` for efficient local lookups. If no
|
|
517
|
+
* results are found locally and `remoteFallback` is enabled, a filtered query
|
|
518
|
+
* is made against Supabase and results are cached locally.
|
|
519
|
+
*
|
|
520
|
+
* @param table - The Supabase table name.
|
|
521
|
+
* @param index - The name of the indexed field to filter on.
|
|
522
|
+
* @param value - The value to match against the indexed field.
|
|
523
|
+
* @param opts - Optional configuration.
|
|
524
|
+
* @param opts.remoteFallback - If `true`, fall back to Supabase when no local
|
|
525
|
+
* results are found. Defaults to `false`.
|
|
526
|
+
* @returns An array of matching entity records.
|
|
527
|
+
*
|
|
528
|
+
* @example
|
|
529
|
+
* ```ts
|
|
530
|
+
* // Get all tasks belonging to a specific project
|
|
531
|
+
* const tasks = await engineQuery('tasks', 'project_id', projectId);
|
|
532
|
+
*
|
|
533
|
+
* // With remote fallback for initial sync scenarios
|
|
534
|
+
* const tasks = await engineQuery('tasks', 'user_id', userId, { remoteFallback: true });
|
|
535
|
+
* ```
|
|
536
|
+
*
|
|
537
|
+
* @see {@link engineQueryRange} for range-based queries (BETWEEN)
|
|
538
|
+
* @see {@link engineGetAll} for unfiltered table scans
|
|
539
|
+
*/
|
|
540
|
+
export async function engineQuery(table, index, value, opts) {
|
|
541
|
+
const db = getDb();
|
|
542
|
+
const dexieTable = getDexieTableName(table);
|
|
543
|
+
let results = await db
|
|
544
|
+
.table(dexieTable)
|
|
545
|
+
.where(index)
|
|
546
|
+
.equals(value)
|
|
547
|
+
.toArray();
|
|
548
|
+
if (results.length === 0 &&
|
|
549
|
+
opts?.remoteFallback &&
|
|
550
|
+
!isDemoMode() &&
|
|
551
|
+
typeof navigator !== 'undefined' &&
|
|
552
|
+
navigator.onLine) {
|
|
553
|
+
try {
|
|
554
|
+
const columns = getTableColumns(table);
|
|
555
|
+
const { data, error } = await supabase
|
|
556
|
+
.from(table)
|
|
557
|
+
.select(columns)
|
|
558
|
+
.eq(index, value)
|
|
559
|
+
.or('deleted.is.null,deleted.eq.false');
|
|
560
|
+
if (!error && data && data.length > 0) {
|
|
561
|
+
/* Cache remote results locally for future offline access. bulkPut is
|
|
562
|
+
used instead of bulkAdd to handle the case where some records may
|
|
563
|
+
already exist locally (e.g., partial sync). */
|
|
564
|
+
await db.table(dexieTable).bulkPut(data);
|
|
565
|
+
results = data;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
catch (e) {
|
|
569
|
+
debugError(`[Data] Remote query fallback failed for ${table}.${index}:`, e);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return results;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Query entities where an indexed field falls within an inclusive range.
|
|
576
|
+
*
|
|
577
|
+
* Equivalent to `WHERE index BETWEEN lower AND upper` (inclusive on both ends).
|
|
578
|
+
* Useful for date-range queries (e.g., "all tasks due this week") or numeric
|
|
579
|
+
* range filters.
|
|
580
|
+
*
|
|
581
|
+
* Like other query functions, supports an optional remote fallback for when the
|
|
582
|
+
* local store has no matching results.
|
|
583
|
+
*
|
|
584
|
+
* @param table - The Supabase table name.
|
|
585
|
+
* @param index - The name of the indexed field to filter on.
|
|
586
|
+
* @param lower - The inclusive lower bound of the range.
|
|
587
|
+
* @param upper - The inclusive upper bound of the range.
|
|
588
|
+
* @param opts - Optional configuration.
|
|
589
|
+
* @param opts.remoteFallback - If `true`, fall back to Supabase when no local
|
|
590
|
+
* results are found. Defaults to `false`.
|
|
591
|
+
* @returns An array of matching entity records within the range.
|
|
592
|
+
*
|
|
593
|
+
* @example
|
|
594
|
+
* ```ts
|
|
595
|
+
* // Get all tasks due between Monday and Friday
|
|
596
|
+
* const tasks = await engineQueryRange('tasks', 'due_date', mondayISO, fridayISO);
|
|
597
|
+
*
|
|
598
|
+
* // Get focus sessions within a score range
|
|
599
|
+
* const sessions = await engineQueryRange('focus_sessions', 'score', 80, 100);
|
|
600
|
+
* ```
|
|
601
|
+
*
|
|
602
|
+
* @see {@link engineQuery} for exact-match queries
|
|
603
|
+
*/
|
|
604
|
+
export async function engineQueryRange(table, index, lower, upper, opts) {
|
|
605
|
+
const db = getDb();
|
|
606
|
+
const dexieTable = getDexieTableName(table);
|
|
607
|
+
/* The `true, true` arguments make both bounds inclusive (closed interval). */
|
|
608
|
+
let results = await db.table(dexieTable).where(index).between(lower, upper, true, true).toArray();
|
|
609
|
+
if (results.length === 0 &&
|
|
610
|
+
opts?.remoteFallback &&
|
|
611
|
+
!isDemoMode() &&
|
|
612
|
+
typeof navigator !== 'undefined' &&
|
|
613
|
+
navigator.onLine) {
|
|
614
|
+
try {
|
|
615
|
+
const columns = getTableColumns(table);
|
|
616
|
+
const { data, error } = await supabase
|
|
617
|
+
.from(table)
|
|
618
|
+
.select(columns)
|
|
619
|
+
.gte(index, lower)
|
|
620
|
+
.lte(index, upper)
|
|
621
|
+
.or('deleted.is.null,deleted.eq.false');
|
|
622
|
+
if (!error && data && data.length > 0) {
|
|
623
|
+
await db.table(dexieTable).bulkPut(data);
|
|
624
|
+
results = data;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
debugError(`[Data] Remote range query fallback failed for ${table}.${index}:`, e);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return results;
|
|
632
|
+
}
|
|
633
|
+
// =============================================================================
|
|
634
|
+
// GET-OR-CREATE (SINGLETON PATTERN)
|
|
635
|
+
// =============================================================================
|
|
636
|
+
/**
|
|
637
|
+
* Retrieve an existing entity by index, or create one with defaults if none exists.
|
|
638
|
+
*
|
|
639
|
+
* Implements the singleton/get-or-create pattern commonly used for per-user
|
|
640
|
+
* settings records (e.g., `focus_settings`) where exactly one row per user
|
|
641
|
+
* should exist. The lookup uses an indexed field (typically `user_id`) rather
|
|
642
|
+
* than the primary key.
|
|
643
|
+
*
|
|
644
|
+
* Resolution order:
|
|
645
|
+
* 1. **Local lookup** -- query Dexie by the given index. If a non-deleted
|
|
646
|
+
* match is found, return it immediately.
|
|
647
|
+
* 2. **Remote check** (optional) -- if `checkRemote` is true and online,
|
|
648
|
+
* query Supabase for an existing record. If found, cache it locally and
|
|
649
|
+
* return it. This handles the case where the record exists on the server
|
|
650
|
+
* but hasn't been synced down to this device yet.
|
|
651
|
+
* 3. **Local create** -- if neither local nor remote has a match, create a
|
|
652
|
+
* new entity with the provided defaults, queue it for sync, and return it.
|
|
653
|
+
*
|
|
654
|
+
* @param table - The Supabase table name.
|
|
655
|
+
* @param index - The indexed field to search on (e.g., `'user_id'`).
|
|
656
|
+
* @param value - The value to match against the index (e.g., the current user's ID).
|
|
657
|
+
* @param defaults - Default field values for the newly created entity (excluding
|
|
658
|
+
* `id`, `created_at`, and `updated_at`, which are auto-generated).
|
|
659
|
+
* @param opts - Optional configuration.
|
|
660
|
+
* @param opts.checkRemote - If `true`, check Supabase before creating locally.
|
|
661
|
+
* Prevents duplicate creation when the record exists
|
|
662
|
+
* on another device but hasn't synced down yet.
|
|
663
|
+
* Defaults to `false`.
|
|
664
|
+
* @returns The existing or newly created entity record.
|
|
665
|
+
*
|
|
666
|
+
* @example
|
|
667
|
+
* ```ts
|
|
668
|
+
* // Get or create user-specific focus settings
|
|
669
|
+
* const settings = await engineGetOrCreate(
|
|
670
|
+
* 'focus_settings',
|
|
671
|
+
* 'user_id',
|
|
672
|
+
* currentUserId,
|
|
673
|
+
* { user_id: currentUserId, pomodoro_minutes: 25, break_minutes: 5 },
|
|
674
|
+
* { checkRemote: true }
|
|
675
|
+
* );
|
|
676
|
+
* ```
|
|
677
|
+
*
|
|
678
|
+
* @see {@link engineCreate} for the underlying create logic
|
|
679
|
+
* @see {@link engineQuery} for index-based queries without auto-creation
|
|
680
|
+
*/
|
|
681
|
+
export async function engineGetOrCreate(table, index, value, defaults, opts) {
|
|
682
|
+
const db = getDb();
|
|
683
|
+
const dexieTable = getDexieTableName(table);
|
|
684
|
+
/* Step 1: Check local first -- fast path, no network needed. */
|
|
685
|
+
const localResults = await db
|
|
686
|
+
.table(dexieTable)
|
|
687
|
+
.where(index)
|
|
688
|
+
.equals(value)
|
|
689
|
+
.toArray();
|
|
690
|
+
/* Filter out soft-deleted records so we don't return a tombstone as the
|
|
691
|
+
"existing" entity -- that would prevent a valid re-creation. */
|
|
692
|
+
const existing = localResults.find((r) => !r.deleted);
|
|
693
|
+
if (existing)
|
|
694
|
+
return existing;
|
|
695
|
+
/* Step 2: Check remote if requested -- prevents duplicate creation across devices.
|
|
696
|
+
Skipped in demo mode (sandboxed, no Supabase). */
|
|
697
|
+
if (opts?.checkRemote && !isDemoMode() && typeof navigator !== 'undefined' && navigator.onLine) {
|
|
698
|
+
try {
|
|
699
|
+
const columns = getTableColumns(table);
|
|
700
|
+
const { data } = await supabase
|
|
701
|
+
.from(table)
|
|
702
|
+
.select(columns)
|
|
703
|
+
.eq(index, value)
|
|
704
|
+
.is('deleted', null)
|
|
705
|
+
.maybeSingle();
|
|
706
|
+
if (data) {
|
|
707
|
+
/* Cache the remote record locally for offline access. */
|
|
708
|
+
await db.table(dexieTable).put(data);
|
|
709
|
+
return data;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
/* Offline or network error -- fall through to local create.
|
|
714
|
+
This is intentionally swallowed: creating a local record is
|
|
715
|
+
always safe, and duplicate resolution happens during sync. */
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
/* Step 3: Create new entity -- no existing record found anywhere. */
|
|
719
|
+
const entityId = generateId();
|
|
720
|
+
const timestamp = now();
|
|
721
|
+
const payload = {
|
|
722
|
+
id: entityId,
|
|
723
|
+
...defaults,
|
|
724
|
+
created_at: timestamp,
|
|
725
|
+
updated_at: timestamp
|
|
726
|
+
};
|
|
727
|
+
const { id: _id, ...queuePayload } = payload;
|
|
728
|
+
await db.transaction('rw', [db.table(dexieTable), db.table('syncQueue')], async () => {
|
|
729
|
+
await db.table(dexieTable).add(payload);
|
|
730
|
+
await queueCreateOperation(table, entityId, queuePayload);
|
|
731
|
+
});
|
|
732
|
+
markEntityModified(entityId);
|
|
733
|
+
scheduleSyncPush();
|
|
734
|
+
return payload;
|
|
735
|
+
}
|
|
736
|
+
// =============================================================================
|
|
737
|
+
// QUERY HELPERS
|
|
738
|
+
// =============================================================================
|
|
739
|
+
/**
|
|
740
|
+
* Fetch all non-deleted records from a table, sorted by `order`.
|
|
741
|
+
*
|
|
742
|
+
* A convenience wrapper around {@link engineGetAll} that applies the two most
|
|
743
|
+
* common post-processing steps: filtering out soft-deleted records and sorting
|
|
744
|
+
* by the `order` field. This eliminates the repetitive
|
|
745
|
+
* `.filter(i => !i.deleted).sort(...)` pattern from every query function.
|
|
746
|
+
*
|
|
747
|
+
* @typeParam T - The entity type (must have at least `deleted` and `order` fields).
|
|
748
|
+
* @param table - The Supabase table name.
|
|
749
|
+
* @param opts - Optional configuration.
|
|
750
|
+
* @param opts.remoteFallback - If `true`, fall back to Supabase when the local
|
|
751
|
+
* table is empty. Defaults to `false`.
|
|
752
|
+
* @param opts.orderBy - A Dexie-indexed field to pre-sort by before
|
|
753
|
+
* filtering. Defaults to `undefined`.
|
|
754
|
+
* @returns An array of non-deleted entity records sorted by `order`.
|
|
755
|
+
*
|
|
756
|
+
* @example
|
|
757
|
+
* ```ts
|
|
758
|
+
* import { queryAll } from 'stellar-drive/data';
|
|
759
|
+
*
|
|
760
|
+
* const categories = await queryAll<TaskCategory>('task_categories');
|
|
761
|
+
* // Returns only non-deleted records, sorted by order ascending
|
|
762
|
+
* ```
|
|
763
|
+
*
|
|
764
|
+
* @see {@link engineGetAll} for the underlying query
|
|
765
|
+
*/
|
|
766
|
+
export async function queryAll(table, opts) {
|
|
767
|
+
const results = await engineGetAll(table, opts);
|
|
768
|
+
return results
|
|
769
|
+
.filter((item) => !item.deleted)
|
|
770
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Fetch a single non-deleted record by ID, or `null`.
|
|
774
|
+
*
|
|
775
|
+
* A convenience wrapper around {@link engineGet} that returns `null` if the
|
|
776
|
+
* record exists but is soft-deleted. This prevents callers from accidentally
|
|
777
|
+
* displaying tombstoned entities in detail views.
|
|
778
|
+
*
|
|
779
|
+
* @typeParam T - The entity type.
|
|
780
|
+
* @param table - The Supabase table name.
|
|
781
|
+
* @param id - The primary key of the entity to retrieve.
|
|
782
|
+
* @param opts - Optional configuration.
|
|
783
|
+
* @param opts.remoteFallback - If `true`, fall back to Supabase when the entity
|
|
784
|
+
* is not found locally. Defaults to `false`.
|
|
785
|
+
* @returns The entity record, or `null` if not found or soft-deleted.
|
|
786
|
+
*
|
|
787
|
+
* @example
|
|
788
|
+
* ```ts
|
|
789
|
+
* import { queryOne } from 'stellar-drive/data';
|
|
790
|
+
*
|
|
791
|
+
* const task = await queryOne<Task>('tasks', taskId);
|
|
792
|
+
* if (!task) console.log('Not found or deleted');
|
|
793
|
+
* ```
|
|
794
|
+
*
|
|
795
|
+
* @see {@link engineGet} for the underlying query
|
|
796
|
+
*/
|
|
797
|
+
export async function queryOne(table, id, opts) {
|
|
798
|
+
const record = await engineGet(table, id, opts);
|
|
799
|
+
if (!record || record.deleted)
|
|
800
|
+
return null;
|
|
801
|
+
return record;
|
|
802
|
+
}
|
|
803
|
+
// =============================================================================
|
|
804
|
+
// REPOSITORY HELPERS
|
|
805
|
+
// =============================================================================
|
|
806
|
+
/**
|
|
807
|
+
* Update just the `order` field on any entity.
|
|
808
|
+
*
|
|
809
|
+
* A thin wrapper around {@link engineUpdate} for the common reorder operation.
|
|
810
|
+
* Consumer apps typically have identical `reorder` functions across every
|
|
811
|
+
* repository; this generic version eliminates that duplication.
|
|
812
|
+
*
|
|
813
|
+
* @typeParam T - The entity type.
|
|
814
|
+
* @param table - The Supabase table name.
|
|
815
|
+
* @param id - The primary key of the entity to reorder.
|
|
816
|
+
* @param newOrder - The new order value.
|
|
817
|
+
* @returns The updated entity, or `undefined` if not found.
|
|
818
|
+
*
|
|
819
|
+
* @example
|
|
820
|
+
* ```ts
|
|
821
|
+
* import { reorderEntity } from 'stellar-drive/data';
|
|
822
|
+
*
|
|
823
|
+
* const updated = await reorderEntity<Task>('tasks', taskId, 2.5);
|
|
824
|
+
* ```
|
|
825
|
+
*
|
|
826
|
+
* @see {@link engineUpdate} for the underlying update
|
|
827
|
+
*/
|
|
828
|
+
export async function reorderEntity(table, id, newOrder) {
|
|
829
|
+
const result = await engineUpdate(table, id, { order: newOrder });
|
|
830
|
+
return result;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Compute the next prepend-order value for inserting at the top of a list.
|
|
834
|
+
*
|
|
835
|
+
* Queries all non-deleted records matching the given index/value pair, finds
|
|
836
|
+
* the minimum `order` value, and returns `min - 1`. If no records exist,
|
|
837
|
+
* returns `0`. This is the standard pattern for "add to top" operations.
|
|
838
|
+
*
|
|
839
|
+
* @param table - The Supabase table name.
|
|
840
|
+
* @param indexField - The indexed field to filter on (e.g., `'user_id'`).
|
|
841
|
+
* @param indexValue - The value to match against the index.
|
|
842
|
+
* @returns The computed order value for prepending.
|
|
843
|
+
*
|
|
844
|
+
* @example
|
|
845
|
+
* ```ts
|
|
846
|
+
* import { prependOrder } from 'stellar-drive/data';
|
|
847
|
+
*
|
|
848
|
+
* const order = await prependOrder('tasks', 'user_id', currentUserId);
|
|
849
|
+
* await engineCreate('tasks', { ..., order });
|
|
850
|
+
* ```
|
|
851
|
+
*
|
|
852
|
+
* @see {@link engineQuery} for the underlying query
|
|
853
|
+
*/
|
|
854
|
+
export async function prependOrder(table, indexField, indexValue) {
|
|
855
|
+
const records = await engineQuery(table, indexField, indexValue);
|
|
856
|
+
const active = records.filter((r) => !r.deleted);
|
|
857
|
+
if (active.length === 0)
|
|
858
|
+
return 0;
|
|
859
|
+
const minOrder = Math.min(...active.map((r) => r.order ?? 0));
|
|
860
|
+
return minOrder - 1;
|
|
861
|
+
}
|
|
862
|
+
//# sourceMappingURL=data.js.map
|