@prabhask5/stellar-engine 1.1.6 → 1.1.8
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 +68 -25
- package/dist/actions/remoteChange.d.ts +143 -18
- package/dist/actions/remoteChange.d.ts.map +1 -1
- package/dist/actions/remoteChange.js +182 -58
- package/dist/actions/remoteChange.js.map +1 -1
- 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/admin.d.ts +40 -3
- package/dist/auth/admin.d.ts.map +1 -1
- package/dist/auth/admin.js +45 -5
- package/dist/auth/admin.js.map +1 -1
- package/dist/auth/crypto.d.ts +55 -5
- package/dist/auth/crypto.d.ts.map +1 -1
- package/dist/auth/crypto.js +58 -5
- package/dist/auth/crypto.js.map +1 -1
- package/dist/auth/deviceVerification.d.ts +236 -20
- package/dist/auth/deviceVerification.d.ts.map +1 -1
- package/dist/auth/deviceVerification.js +293 -40
- package/dist/auth/deviceVerification.js.map +1 -1
- package/dist/auth/displayUtils.d.ts +98 -0
- package/dist/auth/displayUtils.d.ts.map +1 -0
- package/dist/auth/displayUtils.js +133 -0
- package/dist/auth/displayUtils.js.map +1 -0
- package/dist/auth/loginGuard.d.ts +108 -14
- package/dist/auth/loginGuard.d.ts.map +1 -1
- package/dist/auth/loginGuard.js +153 -31
- package/dist/auth/loginGuard.js.map +1 -1
- package/dist/auth/offlineCredentials.d.ts +132 -15
- package/dist/auth/offlineCredentials.d.ts.map +1 -1
- package/dist/auth/offlineCredentials.js +167 -23
- package/dist/auth/offlineCredentials.js.map +1 -1
- package/dist/auth/offlineLogin.d.ts +96 -10
- package/dist/auth/offlineLogin.d.ts.map +1 -1
- package/dist/auth/offlineLogin.js +82 -15
- package/dist/auth/offlineLogin.js.map +1 -1
- package/dist/auth/offlineSession.d.ts +83 -9
- package/dist/auth/offlineSession.d.ts.map +1 -1
- package/dist/auth/offlineSession.js +104 -13
- package/dist/auth/offlineSession.js.map +1 -1
- package/dist/auth/resolveAuthState.d.ts +70 -8
- package/dist/auth/resolveAuthState.d.ts.map +1 -1
- package/dist/auth/resolveAuthState.js +142 -46
- package/dist/auth/resolveAuthState.js.map +1 -1
- package/dist/auth/singleUser.d.ts +390 -37
- package/dist/auth/singleUser.d.ts.map +1 -1
- package/dist/auth/singleUser.js +505 -133
- package/dist/auth/singleUser.js.map +1 -1
- package/dist/bin/install-pwa.d.ts +25 -0
- package/dist/bin/install-pwa.d.ts.map +1 -0
- package/dist/bin/install-pwa.js +2197 -0
- package/dist/bin/install-pwa.js.map +1 -0
- package/dist/config.d.ts +132 -12
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +87 -9
- package/dist/config.js.map +1 -1
- package/dist/conflicts.d.ts +246 -23
- package/dist/conflicts.d.ts.map +1 -1
- package/dist/conflicts.js +495 -46
- package/dist/conflicts.js.map +1 -1
- package/dist/data.d.ts +338 -18
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +385 -34
- package/dist/data.js.map +1 -1
- package/dist/database.d.ts +72 -14
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +120 -29
- package/dist/database.js.map +1 -1
- package/dist/debug.d.ts +77 -1
- package/dist/debug.d.ts.map +1 -1
- package/dist/debug.js +88 -1
- package/dist/debug.js.map +1 -1
- package/dist/deviceId.d.ts +38 -7
- package/dist/deviceId.d.ts.map +1 -1
- package/dist/deviceId.js +68 -10
- package/dist/deviceId.js.map +1 -1
- package/dist/engine.d.ts +175 -3
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +831 -110
- package/dist/engine.js.map +1 -1
- package/dist/entries/actions.d.ts +14 -0
- package/dist/entries/actions.d.ts.map +1 -1
- package/dist/entries/actions.js +27 -1
- package/dist/entries/actions.js.map +1 -1
- package/dist/entries/auth.d.ts +16 -0
- package/dist/entries/auth.d.ts.map +1 -1
- package/dist/entries/auth.js +73 -1
- package/dist/entries/auth.js.map +1 -1
- package/dist/entries/config.d.ts +12 -0
- package/dist/entries/config.d.ts.map +1 -1
- package/dist/entries/config.js +18 -1
- package/dist/entries/config.js.map +1 -1
- package/dist/entries/kit.d.ts +21 -9
- package/dist/entries/kit.d.ts.map +1 -1
- package/dist/entries/kit.js +57 -8
- package/dist/entries/kit.js.map +1 -1
- package/dist/entries/stores.d.ts +11 -0
- package/dist/entries/stores.d.ts.map +1 -1
- package/dist/entries/stores.js +43 -2
- package/dist/entries/stores.js.map +1 -1
- package/dist/entries/types.d.ts +11 -1
- package/dist/entries/types.d.ts.map +1 -1
- package/dist/entries/types.js +10 -0
- package/dist/entries/types.js.map +1 -1
- package/dist/entries/utils.d.ts +7 -1
- package/dist/entries/utils.d.ts.map +1 -1
- package/dist/entries/utils.js +23 -2
- package/dist/entries/utils.js.map +1 -1
- 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 +33 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +176 -21
- package/dist/index.js.map +1 -1
- package/dist/kit/auth.d.ts +80 -0
- package/dist/kit/auth.d.ts.map +1 -0
- package/dist/kit/auth.js +72 -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 +189 -0
- package/dist/kit/loads.d.ts.map +1 -0
- package/dist/kit/loads.js +205 -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 +274 -0
- package/dist/queue.d.ts.map +1 -1
- package/dist/queue.js +556 -38
- package/dist/queue.js.map +1 -1
- package/dist/realtime.d.ts +241 -27
- package/dist/realtime.d.ts.map +1 -1
- package/dist/realtime.js +633 -109
- package/dist/realtime.js.map +1 -1
- package/dist/runtime/runtimeConfig.d.ts +91 -16
- package/dist/runtime/runtimeConfig.d.ts.map +1 -1
- package/dist/runtime/runtimeConfig.js +146 -19
- package/dist/runtime/runtimeConfig.js.map +1 -1
- package/dist/stores/authState.d.ts +150 -11
- package/dist/stores/authState.d.ts.map +1 -1
- package/dist/stores/authState.js +169 -17
- package/dist/stores/authState.js.map +1 -1
- package/dist/stores/network.d.ts +39 -0
- package/dist/stores/network.d.ts.map +1 -1
- package/dist/stores/network.js +169 -16
- package/dist/stores/network.js.map +1 -1
- package/dist/stores/remoteChanges.d.ts +327 -52
- package/dist/stores/remoteChanges.d.ts.map +1 -1
- package/dist/stores/remoteChanges.js +337 -75
- package/dist/stores/remoteChanges.js.map +1 -1
- package/dist/stores/sync.d.ts +130 -0
- package/dist/stores/sync.d.ts.map +1 -1
- package/dist/stores/sync.js +167 -7
- package/dist/stores/sync.js.map +1 -1
- package/dist/supabase/auth.d.ts +326 -19
- package/dist/supabase/auth.d.ts.map +1 -1
- package/dist/supabase/auth.js +374 -26
- package/dist/supabase/auth.js.map +1 -1
- package/dist/supabase/client.d.ts +79 -6
- package/dist/supabase/client.d.ts.map +1 -1
- package/dist/supabase/client.js +158 -15
- package/dist/supabase/client.js.map +1 -1
- package/dist/supabase/validate.d.ts +101 -7
- package/dist/supabase/validate.d.ts.map +1 -1
- package/dist/supabase/validate.js +117 -8
- package/dist/supabase/validate.js.map +1 -1
- package/dist/sw/build/vite-plugin.d.ts +74 -0
- package/dist/sw/build/vite-plugin.d.ts.map +1 -0
- package/dist/sw/build/vite-plugin.js +183 -0
- package/dist/sw/build/vite-plugin.js.map +1 -0
- package/dist/sw/sw.js +669 -0
- package/dist/types.d.ts +150 -45
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +12 -10
- package/dist/types.js.map +1 -1
- package/dist/utils.d.ts +55 -13
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +83 -22
- package/dist/utils.js.map +1 -1
- package/package.json +20 -22
- package/src/components/DeferredChangesBanner.svelte +477 -0
- package/src/components/SyncStatus.svelte +1732 -0
- package/dist/crdt/awareness.d.ts +0 -54
- package/dist/crdt/awareness.d.ts.map +0 -1
- package/dist/crdt/awareness.js +0 -219
- package/dist/crdt/awareness.js.map +0 -1
- package/dist/crdt/doc.d.ts +0 -56
- package/dist/crdt/doc.d.ts.map +0 -1
- package/dist/crdt/doc.js +0 -130
- package/dist/crdt/doc.js.map +0 -1
- package/dist/crdt/index.d.ts +0 -15
- package/dist/crdt/index.d.ts.map +0 -1
- package/dist/crdt/index.js +0 -20
- package/dist/crdt/index.js.map +0 -1
- package/dist/crdt/offline.d.ts +0 -91
- package/dist/crdt/offline.d.ts.map +0 -1
- package/dist/crdt/offline.js +0 -353
- package/dist/crdt/offline.js.map +0 -1
- package/dist/crdt/sync.d.ts +0 -58
- package/dist/crdt/sync.d.ts.map +0 -1
- package/dist/crdt/sync.js +0 -399
- package/dist/crdt/sync.js.map +0 -1
- package/dist/crdt/types.d.ts +0 -62
- package/dist/crdt/types.d.ts.map +0 -1
- package/dist/crdt/types.js +0 -7
- package/dist/crdt/types.js.map +0 -1
- package/dist/email/sendEmail.d.ts +0 -31
- package/dist/email/sendEmail.d.ts.map +0 -1
- package/dist/email/sendEmail.js +0 -39
- package/dist/email/sendEmail.js.map +0 -1
- package/dist/email/validateSmtp.d.ts +0 -18
- package/dist/email/validateSmtp.d.ts.map +0 -1
- package/dist/email/validateSmtp.js +0 -33
- package/dist/email/validateSmtp.js.map +0 -1
- package/dist/entries/crdt.d.ts +0 -3
- package/dist/entries/crdt.d.ts.map +0 -1
- package/dist/entries/crdt.js +0 -13
- package/dist/entries/crdt.js.map +0 -1
- package/dist/entries/email.d.ts +0 -4
- package/dist/entries/email.d.ts.map +0 -1
- package/dist/entries/email.js +0 -4
- package/dist/entries/email.js.map +0 -1
- package/dist/kit/authPresets.d.ts +0 -28
- package/dist/kit/authPresets.d.ts.map +0 -1
- package/dist/kit/authPresets.js +0 -23
- package/dist/kit/authPresets.js.map +0 -1
- package/dist/kit/configEndpoint.d.ts +0 -18
- package/dist/kit/configEndpoint.d.ts.map +0 -1
- package/dist/kit/configEndpoint.js +0 -27
- package/dist/kit/configEndpoint.js.map +0 -1
- package/dist/kit/deployEndpoint.d.ts +0 -22
- package/dist/kit/deployEndpoint.d.ts.map +0 -1
- package/dist/kit/deployEndpoint.js +0 -79
- package/dist/kit/deployEndpoint.js.map +0 -1
- package/dist/kit/layoutLoad.d.ts +0 -23
- package/dist/kit/layoutLoad.d.ts.map +0 -1
- package/dist/kit/layoutLoad.js +0 -41
- package/dist/kit/layoutLoad.js.map +0 -1
- package/dist/kit/protectedLoad.d.ts +0 -16
- package/dist/kit/protectedLoad.d.ts.map +0 -1
- package/dist/kit/protectedLoad.js +0 -28
- package/dist/kit/protectedLoad.js.map +0 -1
- package/dist/kit/setupLoad.d.ts +0 -11
- package/dist/kit/setupLoad.d.ts.map +0 -1
- package/dist/kit/setupLoad.js +0 -28
- package/dist/kit/setupLoad.js.map +0 -1
- package/dist/kit/validateEndpoint.d.ts +0 -9
- package/dist/kit/validateEndpoint.d.ts.map +0 -1
- package/dist/kit/validateEndpoint.js +0 -25
- package/dist/kit/validateEndpoint.js.map +0 -1
- package/dist/kit/vercelApi.d.ts +0 -6
- package/dist/kit/vercelApi.d.ts.map +0 -1
- package/dist/kit/vercelApi.js +0 -48
- package/dist/kit/vercelApi.js.map +0 -1
package/dist/data.js
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Generic CRUD and Query Operations
|
|
2
|
+
* @fileoverview Generic CRUD and Query Operations for the Stellar Sync Engine
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
6
29
|
*/
|
|
7
30
|
import { getTableMap, getTableColumns } from './config';
|
|
8
31
|
import { getDb } from './database';
|
|
@@ -11,37 +34,106 @@ import { markEntityModified, scheduleSyncPush } from './engine';
|
|
|
11
34
|
import { generateId, now } from './utils';
|
|
12
35
|
import { debugError } from './debug';
|
|
13
36
|
import { supabase } from './supabase/client';
|
|
14
|
-
//
|
|
37
|
+
// =============================================================================
|
|
15
38
|
// HELPERS
|
|
16
|
-
//
|
|
39
|
+
// =============================================================================
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a Supabase (remote) table name to its corresponding Dexie (local) table name.
|
|
42
|
+
*
|
|
43
|
+
* The table map is defined by the host application at initialization time via
|
|
44
|
+
* `configureStellarEngine`. If no mapping exists for the given name, the
|
|
45
|
+
* Supabase name is returned as-is (identity mapping), which is the common case
|
|
46
|
+
* when local and remote table names are identical.
|
|
47
|
+
*
|
|
48
|
+
* @param supabaseName - The canonical Supabase table name used by the caller.
|
|
49
|
+
* @returns The corresponding Dexie table name for local IndexedDB operations.
|
|
50
|
+
*
|
|
51
|
+
* @see {@link ./config} for `getTableMap` and engine configuration
|
|
52
|
+
*/
|
|
17
53
|
function getDexieTableName(supabaseName) {
|
|
18
54
|
const map = getTableMap();
|
|
19
55
|
return map[supabaseName] || supabaseName;
|
|
20
56
|
}
|
|
21
|
-
//
|
|
57
|
+
// =============================================================================
|
|
22
58
|
// SINGLE-ENTITY WRITE OPERATIONS
|
|
23
|
-
//
|
|
59
|
+
// =============================================================================
|
|
24
60
|
/**
|
|
25
|
-
* Create a new entity
|
|
26
|
-
*
|
|
61
|
+
* Create a new entity in the local store and enqueue it for remote sync.
|
|
62
|
+
*
|
|
63
|
+
* This is the primary entry point for all entity creation. It performs the
|
|
64
|
+
* following steps atomically within a single Dexie transaction:
|
|
65
|
+
* 1. Inserts the entity into the local Dexie table.
|
|
66
|
+
* 2. Enqueues a `create` operation in the sync queue.
|
|
67
|
+
*
|
|
68
|
+
* After the transaction commits, it marks the entity as modified (for reactive
|
|
69
|
+
* UI updates) and schedules a sync push to propagate the change to Supabase.
|
|
70
|
+
*
|
|
71
|
+
* The caller is responsible for providing all required fields (including
|
|
72
|
+
* timestamps like `created_at` and `updated_at`). If `data.id` is omitted,
|
|
73
|
+
* a new UUID is generated automatically.
|
|
74
|
+
*
|
|
75
|
+
* @param table - The Supabase table name (resolved internally to a Dexie table).
|
|
76
|
+
* @param data - The full entity payload. May include `id`; if absent, one is generated.
|
|
77
|
+
* @returns The created entity payload (with `id` guaranteed to be present).
|
|
78
|
+
*
|
|
79
|
+
* @throws {Dexie.ConstraintError} If an entity with the same `id` already exists.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* const task = await engineCreate('tasks', {
|
|
84
|
+
* title: 'Write docs',
|
|
85
|
+
* user_id: currentUserId,
|
|
86
|
+
* created_at: now(),
|
|
87
|
+
* updated_at: now(),
|
|
88
|
+
* });
|
|
89
|
+
* console.log(task.id); // auto-generated UUID
|
|
90
|
+
* ```
|
|
91
|
+
*
|
|
92
|
+
* @see {@link engineBatchWrite} for creating multiple entities atomically
|
|
93
|
+
* @see {@link queueCreateOperation} for the sync queue entry format
|
|
27
94
|
*/
|
|
28
95
|
export async function engineCreate(table, data) {
|
|
29
96
|
const db = getDb();
|
|
30
97
|
const dexieTable = getDexieTableName(table);
|
|
31
98
|
const entityId = data.id || generateId();
|
|
32
99
|
const payload = { ...data, id: entityId };
|
|
33
|
-
|
|
100
|
+
/* The queue stores `id` as a separate column, so we strip it from the payload
|
|
101
|
+
to avoid duplicating it in the serialized operation data. */
|
|
34
102
|
const { id: _id, ...queuePayload } = payload;
|
|
35
103
|
await db.transaction('rw', [db.table(dexieTable), db.table('syncQueue')], async () => {
|
|
36
104
|
await db.table(dexieTable).add(payload);
|
|
37
105
|
await queueCreateOperation(table, entityId, queuePayload);
|
|
38
106
|
});
|
|
107
|
+
/* Post-transaction side effects: these are intentionally outside the transaction
|
|
108
|
+
because they are non-critical (UI reactivity + debounced network push). */
|
|
39
109
|
markEntityModified(entityId);
|
|
40
110
|
scheduleSyncPush();
|
|
41
111
|
return payload;
|
|
42
112
|
}
|
|
43
113
|
/**
|
|
44
|
-
* Update
|
|
114
|
+
* Update specific fields on an existing entity.
|
|
115
|
+
*
|
|
116
|
+
* Automatically sets `updated_at` to the current timestamp, enqueues a `set`
|
|
117
|
+
* sync operation, and notifies the engine of the modification. The update and
|
|
118
|
+
* queue entry are wrapped in a single transaction for atomicity.
|
|
119
|
+
*
|
|
120
|
+
* If the entity does not exist (e.g., it was deleted between the caller's
|
|
121
|
+
* check and this call), the sync operation is skipped and `undefined` is
|
|
122
|
+
* returned -- no orphan queue entries are created.
|
|
123
|
+
*
|
|
124
|
+
* @param table - The Supabase table name.
|
|
125
|
+
* @param id - The primary key of the entity to update.
|
|
126
|
+
* @param fields - A partial record of fields to merge into the entity.
|
|
127
|
+
* @returns The fully updated entity record, or `undefined` if the entity was not found.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* ```ts
|
|
131
|
+
* const updated = await engineUpdate('tasks', taskId, { title: 'New title' });
|
|
132
|
+
* // updated.updated_at is automatically set
|
|
133
|
+
* ```
|
|
134
|
+
*
|
|
135
|
+
* @see {@link engineIncrement} for numeric field increments with conflict-safe semantics
|
|
136
|
+
* @see {@link queueSyncOperation} for the `set` operation queue format
|
|
45
137
|
*/
|
|
46
138
|
export async function engineUpdate(table, id, fields) {
|
|
47
139
|
const db = getDb();
|
|
@@ -51,6 +143,8 @@ export async function engineUpdate(table, id, fields) {
|
|
|
51
143
|
let updated;
|
|
52
144
|
await db.transaction('rw', [db.table(dexieTable), db.table('syncQueue')], async () => {
|
|
53
145
|
await db.table(dexieTable).update(id, updateFields);
|
|
146
|
+
/* Re-read the entity after update to return the complete merged record,
|
|
147
|
+
and to verify the entity actually existed before queuing a sync op. */
|
|
54
148
|
updated = await db.table(dexieTable).get(id);
|
|
55
149
|
if (updated) {
|
|
56
150
|
await queueSyncOperation({
|
|
@@ -68,7 +162,28 @@ export async function engineUpdate(table, id, fields) {
|
|
|
68
162
|
return updated;
|
|
69
163
|
}
|
|
70
164
|
/**
|
|
71
|
-
* Soft-delete an entity
|
|
165
|
+
* Soft-delete an entity by setting `deleted: true`.
|
|
166
|
+
*
|
|
167
|
+
* The engine uses soft deletes rather than hard deletes so that the deletion
|
|
168
|
+
* can be synced to other devices and to the server. The sync queue receives a
|
|
169
|
+
* `delete` operation, which the push logic translates into a Supabase update
|
|
170
|
+
* that sets `deleted = true` on the remote row.
|
|
171
|
+
*
|
|
172
|
+
* The entity remains in the local Dexie store (with `deleted: true`) until a
|
|
173
|
+
* future compaction or full re-sync removes it.
|
|
174
|
+
*
|
|
175
|
+
* @param table - The Supabase table name.
|
|
176
|
+
* @param id - The primary key of the entity to soft-delete.
|
|
177
|
+
* @returns Resolves when the local update and queue entry are committed.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```ts
|
|
181
|
+
* await engineDelete('tasks', taskId);
|
|
182
|
+
* // The task still exists locally with deleted: true
|
|
183
|
+
* // It will be synced as a deletion on the next push
|
|
184
|
+
* ```
|
|
185
|
+
*
|
|
186
|
+
* @see {@link queueDeleteOperation} for the delete queue entry format
|
|
72
187
|
*/
|
|
73
188
|
export async function engineDelete(table, id) {
|
|
74
189
|
const db = getDb();
|
|
@@ -82,13 +197,43 @@ export async function engineDelete(table, id) {
|
|
|
82
197
|
scheduleSyncPush();
|
|
83
198
|
}
|
|
84
199
|
/**
|
|
85
|
-
* Execute multiple write operations in a single atomic transaction.
|
|
86
|
-
*
|
|
200
|
+
* Execute multiple write operations in a single atomic Dexie transaction.
|
|
201
|
+
*
|
|
202
|
+
* This is the preferred way to perform related mutations that must succeed or
|
|
203
|
+
* fail together (e.g., creating a parent entity and its children, or moving an
|
|
204
|
+
* item from one list to another). All operations share a single `updated_at`
|
|
205
|
+
* timestamp for consistency.
|
|
206
|
+
*
|
|
207
|
+
* Transaction scope is dynamically computed: only the Dexie tables referenced
|
|
208
|
+
* by the operations (plus `syncQueue`) are locked, minimizing contention.
|
|
209
|
+
*
|
|
210
|
+
* After the transaction commits, all modified entity IDs are marked as modified
|
|
211
|
+
* in a single pass, and a single sync push is scheduled (not one per operation).
|
|
212
|
+
*
|
|
213
|
+
* @param operations - An ordered array of create/update/delete operations.
|
|
214
|
+
* @returns Resolves when all operations have been committed.
|
|
215
|
+
*
|
|
216
|
+
* @throws {Dexie.AbortError} If any operation fails, the entire batch is rolled back.
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* ```ts
|
|
220
|
+
* await engineBatchWrite([
|
|
221
|
+
* { type: 'create', table: 'tasks', data: { title: 'Subtask 1', parent_id: parentId } },
|
|
222
|
+
* { type: 'create', table: 'tasks', data: { title: 'Subtask 2', parent_id: parentId } },
|
|
223
|
+
* { type: 'update', table: 'projects', id: projectId, fields: { task_count: newCount } },
|
|
224
|
+
* ]);
|
|
225
|
+
* ```
|
|
226
|
+
*
|
|
227
|
+
* @see {@link engineCreate} for single-entity create semantics
|
|
228
|
+
* @see {@link engineUpdate} for single-entity update semantics
|
|
229
|
+
* @see {@link engineDelete} for single-entity delete semantics
|
|
87
230
|
*/
|
|
88
231
|
export async function engineBatchWrite(operations) {
|
|
89
232
|
const db = getDb();
|
|
90
233
|
const timestamp = now();
|
|
91
|
-
|
|
234
|
+
/* Collect all unique Dexie table names needed for the transaction scope.
|
|
235
|
+
We pre-compute this so Dexie can acquire the minimal set of table locks
|
|
236
|
+
upfront, avoiding deadlocks with concurrent transactions. */
|
|
92
237
|
const tableNames = new Set();
|
|
93
238
|
tableNames.add('syncQueue');
|
|
94
239
|
for (const op of operations) {
|
|
@@ -130,18 +275,50 @@ export async function engineBatchWrite(operations) {
|
|
|
130
275
|
}
|
|
131
276
|
}
|
|
132
277
|
});
|
|
278
|
+
/* Batch-notify all modified entities after the transaction commits.
|
|
279
|
+
A single scheduleSyncPush() call is sufficient because the push logic
|
|
280
|
+
drains the entire queue, not just one entry. */
|
|
133
281
|
for (const id of modifiedIds) {
|
|
134
282
|
markEntityModified(id);
|
|
135
283
|
}
|
|
136
284
|
scheduleSyncPush();
|
|
137
285
|
}
|
|
138
|
-
//
|
|
286
|
+
// =============================================================================
|
|
139
287
|
// INCREMENT OPERATION
|
|
140
|
-
//
|
|
288
|
+
// =============================================================================
|
|
141
289
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
290
|
+
* Atomically increment a numeric field on an entity.
|
|
291
|
+
*
|
|
292
|
+
* Unlike a plain `engineUpdate` with a computed value, this function preserves
|
|
293
|
+
* the **increment intent** in the sync queue (operationType: 'increment').
|
|
294
|
+
* This is critical for correct multi-device conflict resolution: when two
|
|
295
|
+
* devices each increment a counter by 1, the server can apply both increments
|
|
296
|
+
* additively (+2) rather than last-write-wins (which would yield +1).
|
|
297
|
+
*
|
|
298
|
+
* The local Dexie value is updated immediately (read-modify-write inside a
|
|
299
|
+
* transaction to prevent TOCTOU races). If additional fields need to be set
|
|
300
|
+
* alongside the increment (e.g., a `completed` flag), they are queued as a
|
|
301
|
+
* separate `set` operation so the increment and set semantics remain distinct.
|
|
302
|
+
*
|
|
303
|
+
* @param table - The Supabase table name.
|
|
304
|
+
* @param id - The primary key of the entity to increment.
|
|
305
|
+
* @param field - The name of the numeric field to increment.
|
|
306
|
+
* @param amount - The increment delta (can be negative for decrements).
|
|
307
|
+
* @param additionalFields - Optional extra fields to set alongside the increment
|
|
308
|
+
* (e.g., `{ completed: true }`). These are queued as a
|
|
309
|
+
* separate `set` operation.
|
|
310
|
+
* @returns The fully updated entity record, or `undefined` if the entity was not found.
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* ```ts
|
|
314
|
+
* // Increment a task's focus_count by 1 and mark it as touched
|
|
315
|
+
* const updated = await engineIncrement('tasks', taskId, 'focus_count', 1, {
|
|
316
|
+
* last_focused_at: now(),
|
|
317
|
+
* });
|
|
318
|
+
* ```
|
|
319
|
+
*
|
|
320
|
+
* @see {@link engineUpdate} for non-increment field updates
|
|
321
|
+
* @see {@link ./conflicts} for how increment operations are resolved during sync
|
|
145
322
|
*/
|
|
146
323
|
export async function engineIncrement(table, id, field, amount, additionalFields) {
|
|
147
324
|
const db = getDb();
|
|
@@ -149,7 +326,9 @@ export async function engineIncrement(table, id, field, amount, additionalFields
|
|
|
149
326
|
const timestamp = now();
|
|
150
327
|
let updated;
|
|
151
328
|
await db.transaction('rw', [db.table(dexieTable), db.table('syncQueue')], async () => {
|
|
152
|
-
|
|
329
|
+
/* Read current value inside the transaction to prevent TOCTOU race:
|
|
330
|
+
another tab or transaction could modify the value between our read
|
|
331
|
+
and write if we read outside the transaction boundary. */
|
|
153
332
|
const current = await db.table(dexieTable).get(id);
|
|
154
333
|
if (!current)
|
|
155
334
|
return;
|
|
@@ -163,6 +342,8 @@ export async function engineIncrement(table, id, field, amount, additionalFields
|
|
|
163
342
|
await db.table(dexieTable).update(id, updateFields);
|
|
164
343
|
updated = await db.table(dexieTable).get(id);
|
|
165
344
|
if (updated) {
|
|
345
|
+
/* Queue the increment as its own operation type so the sync push
|
|
346
|
+
can send it as an RPC / SQL increment rather than a flat set. */
|
|
166
347
|
await queueSyncOperation({
|
|
167
348
|
table,
|
|
168
349
|
entityId: id,
|
|
@@ -170,7 +351,9 @@ export async function engineIncrement(table, id, field, amount, additionalFields
|
|
|
170
351
|
field,
|
|
171
352
|
value: amount
|
|
172
353
|
});
|
|
173
|
-
|
|
354
|
+
/* Queue additional fields as a separate set operation if present.
|
|
355
|
+
Keeping them separate ensures the increment semantic is not
|
|
356
|
+
conflated with plain field overwrites during conflict resolution. */
|
|
174
357
|
if (additionalFields && Object.keys(additionalFields).length > 0) {
|
|
175
358
|
await queueSyncOperation({
|
|
176
359
|
table,
|
|
@@ -187,11 +370,39 @@ export async function engineIncrement(table, id, field, amount, additionalFields
|
|
|
187
370
|
}
|
|
188
371
|
return updated;
|
|
189
372
|
}
|
|
190
|
-
//
|
|
373
|
+
// =============================================================================
|
|
191
374
|
// QUERY OPERATIONS
|
|
192
|
-
//
|
|
375
|
+
// =============================================================================
|
|
193
376
|
/**
|
|
194
|
-
*
|
|
377
|
+
* Retrieve a single entity by its primary key.
|
|
378
|
+
*
|
|
379
|
+
* Queries the local Dexie store first. If the entity is not found locally and
|
|
380
|
+
* `remoteFallback` is enabled (and the device is online), a single-row fetch
|
|
381
|
+
* is made from Supabase. The remote result is cached locally in Dexie for
|
|
382
|
+
* subsequent offline access.
|
|
383
|
+
*
|
|
384
|
+
* The remote fallback filters out soft-deleted rows (`deleted IS NULL OR deleted = false`)
|
|
385
|
+
* to avoid resurrecting deleted entities.
|
|
386
|
+
*
|
|
387
|
+
* @param table - The Supabase table name.
|
|
388
|
+
* @param id - The primary key of the entity to retrieve.
|
|
389
|
+
* @param opts - Optional configuration.
|
|
390
|
+
* @param opts.remoteFallback - If `true`, fall back to a Supabase query when
|
|
391
|
+
* the entity is not found locally. Defaults to `false`.
|
|
392
|
+
* @returns The entity record, or `null` if not found (locally or remotely).
|
|
393
|
+
*
|
|
394
|
+
* @example
|
|
395
|
+
* ```ts
|
|
396
|
+
* // Local-only lookup (fast, offline-safe)
|
|
397
|
+
* const task = await engineGet('tasks', taskId);
|
|
398
|
+
*
|
|
399
|
+
* // With remote fallback for cache misses
|
|
400
|
+
* const task = await engineGet('tasks', taskId, { remoteFallback: true });
|
|
401
|
+
* ```
|
|
402
|
+
*
|
|
403
|
+
* @see {@link engineGetAll} for retrieving all entities from a table
|
|
404
|
+
* @see {@link engineQuery} for index-based filtered queries
|
|
405
|
+
* @see {@link getTableColumns} for column projection on remote queries
|
|
195
406
|
*/
|
|
196
407
|
export async function engineGet(table, id, opts) {
|
|
197
408
|
const db = getDb();
|
|
@@ -199,6 +410,8 @@ export async function engineGet(table, id, opts) {
|
|
|
199
410
|
const local = await db.table(dexieTable).get(id);
|
|
200
411
|
if (local)
|
|
201
412
|
return local;
|
|
413
|
+
/* Remote fallback: only attempted when explicitly opted in AND the browser
|
|
414
|
+
reports online status. This avoids unnecessary network errors in offline mode. */
|
|
202
415
|
if (opts?.remoteFallback && typeof navigator !== 'undefined' && navigator.onLine) {
|
|
203
416
|
try {
|
|
204
417
|
const columns = getTableColumns(table);
|
|
@@ -209,6 +422,7 @@ export async function engineGet(table, id, opts) {
|
|
|
209
422
|
.or('deleted.is.null,deleted.eq.false')
|
|
210
423
|
.maybeSingle();
|
|
211
424
|
if (!error && data) {
|
|
425
|
+
/* Cache the remote result locally so future reads are instant and offline-safe. */
|
|
212
426
|
await db.table(dexieTable).put(data);
|
|
213
427
|
return data;
|
|
214
428
|
}
|
|
@@ -220,7 +434,35 @@ export async function engineGet(table, id, opts) {
|
|
|
220
434
|
return null;
|
|
221
435
|
}
|
|
222
436
|
/**
|
|
223
|
-
*
|
|
437
|
+
* Retrieve all entities from a table, with optional ordering and remote fallback.
|
|
438
|
+
*
|
|
439
|
+
* Returns the full (non-filtered) contents of the local Dexie table. If the
|
|
440
|
+
* local table is empty and `remoteFallback` is enabled, a bulk fetch from
|
|
441
|
+
* Supabase is performed and results are cached locally via `bulkPut`.
|
|
442
|
+
*
|
|
443
|
+
* Note: This does NOT filter out soft-deleted entities locally. Callers that
|
|
444
|
+
* need to exclude deleted records should filter the results themselves. The
|
|
445
|
+
* remote fallback, however, does exclude deleted rows to avoid pulling down
|
|
446
|
+
* tombstones.
|
|
447
|
+
*
|
|
448
|
+
* @param table - The Supabase table name.
|
|
449
|
+
* @param opts - Optional configuration.
|
|
450
|
+
* @param opts.orderBy - A Dexie-indexed field name to sort results by.
|
|
451
|
+
* @param opts.remoteFallback - If `true`, fall back to Supabase when the local
|
|
452
|
+
* table is empty. Defaults to `false`.
|
|
453
|
+
* @returns An array of entity records (may be empty).
|
|
454
|
+
*
|
|
455
|
+
* @example
|
|
456
|
+
* ```ts
|
|
457
|
+
* // Get all tasks ordered by creation date
|
|
458
|
+
* const tasks = await engineGetAll('tasks', { orderBy: 'created_at' });
|
|
459
|
+
*
|
|
460
|
+
* // Bootstrap from remote on first load
|
|
461
|
+
* const tasks = await engineGetAll('tasks', { remoteFallback: true });
|
|
462
|
+
* ```
|
|
463
|
+
*
|
|
464
|
+
* @see {@link engineGet} for single-entity retrieval
|
|
465
|
+
* @see {@link engineQuery} for filtered queries by index
|
|
224
466
|
*/
|
|
225
467
|
export async function engineGetAll(table, opts) {
|
|
226
468
|
const db = getDb();
|
|
@@ -232,6 +474,9 @@ export async function engineGetAll(table, opts) {
|
|
|
232
474
|
else {
|
|
233
475
|
results = await db.table(dexieTable).toArray();
|
|
234
476
|
}
|
|
477
|
+
/* Remote fallback only fires when the local table is completely empty.
|
|
478
|
+
This handles the "first device" or "fresh install" scenario where no
|
|
479
|
+
data has been synced down yet. */
|
|
235
480
|
if (results.length === 0 &&
|
|
236
481
|
opts?.remoteFallback &&
|
|
237
482
|
typeof navigator !== 'undefined' &&
|
|
@@ -244,6 +489,8 @@ export async function engineGetAll(table, opts) {
|
|
|
244
489
|
.or('deleted.is.null,deleted.eq.false');
|
|
245
490
|
if (!error && data && data.length > 0) {
|
|
246
491
|
await db.table(dexieTable).bulkPut(data);
|
|
492
|
+
/* If ordering was requested, re-read from Dexie to get proper index-based
|
|
493
|
+
ordering rather than relying on Supabase's default sort order. */
|
|
247
494
|
if (opts?.orderBy) {
|
|
248
495
|
results = await db.table(dexieTable).orderBy(opts.orderBy).toArray();
|
|
249
496
|
}
|
|
@@ -259,7 +506,31 @@ export async function engineGetAll(table, opts) {
|
|
|
259
506
|
return results;
|
|
260
507
|
}
|
|
261
508
|
/**
|
|
262
|
-
* Query entities by
|
|
509
|
+
* Query entities by a single indexed field value (equivalent to `WHERE index = value`).
|
|
510
|
+
*
|
|
511
|
+
* Uses Dexie's indexed `where().equals()` for efficient local lookups. If no
|
|
512
|
+
* results are found locally and `remoteFallback` is enabled, a filtered query
|
|
513
|
+
* is made against Supabase and results are cached locally.
|
|
514
|
+
*
|
|
515
|
+
* @param table - The Supabase table name.
|
|
516
|
+
* @param index - The name of the indexed field to filter on.
|
|
517
|
+
* @param value - The value to match against the indexed field.
|
|
518
|
+
* @param opts - Optional configuration.
|
|
519
|
+
* @param opts.remoteFallback - If `true`, fall back to Supabase when no local
|
|
520
|
+
* results are found. Defaults to `false`.
|
|
521
|
+
* @returns An array of matching entity records.
|
|
522
|
+
*
|
|
523
|
+
* @example
|
|
524
|
+
* ```ts
|
|
525
|
+
* // Get all tasks belonging to a specific project
|
|
526
|
+
* const tasks = await engineQuery('tasks', 'project_id', projectId);
|
|
527
|
+
*
|
|
528
|
+
* // With remote fallback for initial sync scenarios
|
|
529
|
+
* const tasks = await engineQuery('tasks', 'user_id', userId, { remoteFallback: true });
|
|
530
|
+
* ```
|
|
531
|
+
*
|
|
532
|
+
* @see {@link engineQueryRange} for range-based queries (BETWEEN)
|
|
533
|
+
* @see {@link engineGetAll} for unfiltered table scans
|
|
263
534
|
*/
|
|
264
535
|
export async function engineQuery(table, index, value, opts) {
|
|
265
536
|
const db = getDb();
|
|
@@ -281,6 +552,9 @@ export async function engineQuery(table, index, value, opts) {
|
|
|
281
552
|
.eq(index, value)
|
|
282
553
|
.or('deleted.is.null,deleted.eq.false');
|
|
283
554
|
if (!error && data && data.length > 0) {
|
|
555
|
+
/* Cache remote results locally for future offline access. bulkPut is
|
|
556
|
+
used instead of bulkAdd to handle the case where some records may
|
|
557
|
+
already exist locally (e.g., partial sync). */
|
|
284
558
|
await db.table(dexieTable).bulkPut(data);
|
|
285
559
|
results = data;
|
|
286
560
|
}
|
|
@@ -292,11 +566,39 @@ export async function engineQuery(table, index, value, opts) {
|
|
|
292
566
|
return results;
|
|
293
567
|
}
|
|
294
568
|
/**
|
|
295
|
-
*
|
|
569
|
+
* Query entities where an indexed field falls within an inclusive range.
|
|
570
|
+
*
|
|
571
|
+
* Equivalent to `WHERE index BETWEEN lower AND upper` (inclusive on both ends).
|
|
572
|
+
* Useful for date-range queries (e.g., "all tasks due this week") or numeric
|
|
573
|
+
* range filters.
|
|
574
|
+
*
|
|
575
|
+
* Like other query functions, supports an optional remote fallback for when the
|
|
576
|
+
* local store has no matching results.
|
|
577
|
+
*
|
|
578
|
+
* @param table - The Supabase table name.
|
|
579
|
+
* @param index - The name of the indexed field to filter on.
|
|
580
|
+
* @param lower - The inclusive lower bound of the range.
|
|
581
|
+
* @param upper - The inclusive upper bound of the range.
|
|
582
|
+
* @param opts - Optional configuration.
|
|
583
|
+
* @param opts.remoteFallback - If `true`, fall back to Supabase when no local
|
|
584
|
+
* results are found. Defaults to `false`.
|
|
585
|
+
* @returns An array of matching entity records within the range.
|
|
586
|
+
*
|
|
587
|
+
* @example
|
|
588
|
+
* ```ts
|
|
589
|
+
* // Get all tasks due between Monday and Friday
|
|
590
|
+
* const tasks = await engineQueryRange('tasks', 'due_date', mondayISO, fridayISO);
|
|
591
|
+
*
|
|
592
|
+
* // Get focus sessions within a score range
|
|
593
|
+
* const sessions = await engineQueryRange('focus_sessions', 'score', 80, 100);
|
|
594
|
+
* ```
|
|
595
|
+
*
|
|
596
|
+
* @see {@link engineQuery} for exact-match queries
|
|
296
597
|
*/
|
|
297
598
|
export async function engineQueryRange(table, index, lower, upper, opts) {
|
|
298
599
|
const db = getDb();
|
|
299
600
|
const dexieTable = getDexieTableName(table);
|
|
601
|
+
/* The `true, true` arguments make both bounds inclusive (closed interval). */
|
|
300
602
|
let results = await db.table(dexieTable).where(index).between(lower, upper, true, true).toArray();
|
|
301
603
|
if (results.length === 0 &&
|
|
302
604
|
opts?.remoteFallback &&
|
|
@@ -321,23 +623,69 @@ export async function engineQueryRange(table, index, lower, upper, opts) {
|
|
|
321
623
|
}
|
|
322
624
|
return results;
|
|
323
625
|
}
|
|
626
|
+
// =============================================================================
|
|
627
|
+
// GET-OR-CREATE (SINGLETON PATTERN)
|
|
628
|
+
// =============================================================================
|
|
324
629
|
/**
|
|
325
|
-
*
|
|
326
|
-
*
|
|
630
|
+
* Retrieve an existing entity by index, or create one with defaults if none exists.
|
|
631
|
+
*
|
|
632
|
+
* Implements the singleton/get-or-create pattern commonly used for per-user
|
|
633
|
+
* settings records (e.g., `focus_settings`) where exactly one row per user
|
|
634
|
+
* should exist. The lookup uses an indexed field (typically `user_id`) rather
|
|
635
|
+
* than the primary key.
|
|
636
|
+
*
|
|
637
|
+
* Resolution order:
|
|
638
|
+
* 1. **Local lookup** -- query Dexie by the given index. If a non-deleted
|
|
639
|
+
* match is found, return it immediately.
|
|
640
|
+
* 2. **Remote check** (optional) -- if `checkRemote` is true and online,
|
|
641
|
+
* query Supabase for an existing record. If found, cache it locally and
|
|
642
|
+
* return it. This handles the case where the record exists on the server
|
|
643
|
+
* but hasn't been synced down to this device yet.
|
|
644
|
+
* 3. **Local create** -- if neither local nor remote has a match, create a
|
|
645
|
+
* new entity with the provided defaults, queue it for sync, and return it.
|
|
646
|
+
*
|
|
647
|
+
* @param table - The Supabase table name.
|
|
648
|
+
* @param index - The indexed field to search on (e.g., `'user_id'`).
|
|
649
|
+
* @param value - The value to match against the index (e.g., the current user's ID).
|
|
650
|
+
* @param defaults - Default field values for the newly created entity (excluding
|
|
651
|
+
* `id`, `created_at`, and `updated_at`, which are auto-generated).
|
|
652
|
+
* @param opts - Optional configuration.
|
|
653
|
+
* @param opts.checkRemote - If `true`, check Supabase before creating locally.
|
|
654
|
+
* Prevents duplicate creation when the record exists
|
|
655
|
+
* on another device but hasn't synced down yet.
|
|
656
|
+
* Defaults to `false`.
|
|
657
|
+
* @returns The existing or newly created entity record.
|
|
658
|
+
*
|
|
659
|
+
* @example
|
|
660
|
+
* ```ts
|
|
661
|
+
* // Get or create user-specific focus settings
|
|
662
|
+
* const settings = await engineGetOrCreate(
|
|
663
|
+
* 'focus_settings',
|
|
664
|
+
* 'user_id',
|
|
665
|
+
* currentUserId,
|
|
666
|
+
* { user_id: currentUserId, pomodoro_minutes: 25, break_minutes: 5 },
|
|
667
|
+
* { checkRemote: true }
|
|
668
|
+
* );
|
|
669
|
+
* ```
|
|
670
|
+
*
|
|
671
|
+
* @see {@link engineCreate} for the underlying create logic
|
|
672
|
+
* @see {@link engineQuery} for index-based queries without auto-creation
|
|
327
673
|
*/
|
|
328
674
|
export async function engineGetOrCreate(table, index, value, defaults, opts) {
|
|
329
675
|
const db = getDb();
|
|
330
676
|
const dexieTable = getDexieTableName(table);
|
|
331
|
-
|
|
677
|
+
/* Step 1: Check local first -- fast path, no network needed. */
|
|
332
678
|
const localResults = await db
|
|
333
679
|
.table(dexieTable)
|
|
334
680
|
.where(index)
|
|
335
681
|
.equals(value)
|
|
336
682
|
.toArray();
|
|
683
|
+
/* Filter out soft-deleted records so we don't return a tombstone as the
|
|
684
|
+
"existing" entity -- that would prevent a valid re-creation. */
|
|
337
685
|
const existing = localResults.find((r) => !r.deleted);
|
|
338
686
|
if (existing)
|
|
339
687
|
return existing;
|
|
340
|
-
|
|
688
|
+
/* Step 2: Check remote if requested -- prevents duplicate creation across devices. */
|
|
341
689
|
if (opts?.checkRemote && typeof navigator !== 'undefined' && navigator.onLine) {
|
|
342
690
|
try {
|
|
343
691
|
const columns = getTableColumns(table);
|
|
@@ -348,15 +696,18 @@ export async function engineGetOrCreate(table, index, value, defaults, opts) {
|
|
|
348
696
|
.is('deleted', null)
|
|
349
697
|
.maybeSingle();
|
|
350
698
|
if (data) {
|
|
699
|
+
/* Cache the remote record locally for offline access. */
|
|
351
700
|
await db.table(dexieTable).put(data);
|
|
352
701
|
return data;
|
|
353
702
|
}
|
|
354
703
|
}
|
|
355
704
|
catch {
|
|
356
|
-
|
|
705
|
+
/* Offline or network error -- fall through to local create.
|
|
706
|
+
This is intentionally swallowed: creating a local record is
|
|
707
|
+
always safe, and duplicate resolution happens during sync. */
|
|
357
708
|
}
|
|
358
709
|
}
|
|
359
|
-
|
|
710
|
+
/* Step 3: Create new entity -- no existing record found anywhere. */
|
|
360
711
|
const entityId = generateId();
|
|
361
712
|
const timestamp = now();
|
|
362
713
|
const payload = {
|