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.
Files changed (246) hide show
  1. package/README.md +607 -0
  2. package/dist/actions/remoteChange.d.ts +204 -0
  3. package/dist/actions/remoteChange.d.ts.map +1 -0
  4. package/dist/actions/remoteChange.js +424 -0
  5. package/dist/actions/remoteChange.js.map +1 -0
  6. package/dist/actions/truncateTooltip.d.ts +56 -0
  7. package/dist/actions/truncateTooltip.d.ts.map +1 -0
  8. package/dist/actions/truncateTooltip.js +312 -0
  9. package/dist/actions/truncateTooltip.js.map +1 -0
  10. package/dist/auth/crypto.d.ts +41 -0
  11. package/dist/auth/crypto.d.ts.map +1 -0
  12. package/dist/auth/crypto.js +50 -0
  13. package/dist/auth/crypto.js.map +1 -0
  14. package/dist/auth/deviceVerification.d.ts +283 -0
  15. package/dist/auth/deviceVerification.d.ts.map +1 -0
  16. package/dist/auth/deviceVerification.js +575 -0
  17. package/dist/auth/deviceVerification.js.map +1 -0
  18. package/dist/auth/displayUtils.d.ts +98 -0
  19. package/dist/auth/displayUtils.d.ts.map +1 -0
  20. package/dist/auth/displayUtils.js +145 -0
  21. package/dist/auth/displayUtils.js.map +1 -0
  22. package/dist/auth/loginGuard.d.ts +134 -0
  23. package/dist/auth/loginGuard.d.ts.map +1 -0
  24. package/dist/auth/loginGuard.js +276 -0
  25. package/dist/auth/loginGuard.js.map +1 -0
  26. package/dist/auth/offlineCredentials.d.ts +105 -0
  27. package/dist/auth/offlineCredentials.d.ts.map +1 -0
  28. package/dist/auth/offlineCredentials.js +176 -0
  29. package/dist/auth/offlineCredentials.js.map +1 -0
  30. package/dist/auth/offlineSession.d.ts +96 -0
  31. package/dist/auth/offlineSession.d.ts.map +1 -0
  32. package/dist/auth/offlineSession.js +145 -0
  33. package/dist/auth/offlineSession.js.map +1 -0
  34. package/dist/auth/resolveAuthState.d.ts +85 -0
  35. package/dist/auth/resolveAuthState.d.ts.map +1 -0
  36. package/dist/auth/resolveAuthState.js +249 -0
  37. package/dist/auth/resolveAuthState.js.map +1 -0
  38. package/dist/auth/singleUser.d.ts +498 -0
  39. package/dist/auth/singleUser.d.ts.map +1 -0
  40. package/dist/auth/singleUser.js +1282 -0
  41. package/dist/auth/singleUser.js.map +1 -0
  42. package/dist/bin/commands.d.ts +14 -0
  43. package/dist/bin/commands.d.ts.map +1 -0
  44. package/dist/bin/commands.js +68 -0
  45. package/dist/bin/commands.js.map +1 -0
  46. package/dist/bin/install-pwa.d.ts +41 -0
  47. package/dist/bin/install-pwa.d.ts.map +1 -0
  48. package/dist/bin/install-pwa.js +4594 -0
  49. package/dist/bin/install-pwa.js.map +1 -0
  50. package/dist/config.d.ts +249 -0
  51. package/dist/config.d.ts.map +1 -0
  52. package/dist/config.js +395 -0
  53. package/dist/config.js.map +1 -0
  54. package/dist/conflicts.d.ts +306 -0
  55. package/dist/conflicts.d.ts.map +1 -0
  56. package/dist/conflicts.js +807 -0
  57. package/dist/conflicts.js.map +1 -0
  58. package/dist/crdt/awareness.d.ts +128 -0
  59. package/dist/crdt/awareness.d.ts.map +1 -0
  60. package/dist/crdt/awareness.js +284 -0
  61. package/dist/crdt/awareness.js.map +1 -0
  62. package/dist/crdt/channel.d.ts +165 -0
  63. package/dist/crdt/channel.d.ts.map +1 -0
  64. package/dist/crdt/channel.js +522 -0
  65. package/dist/crdt/channel.js.map +1 -0
  66. package/dist/crdt/config.d.ts +58 -0
  67. package/dist/crdt/config.d.ts.map +1 -0
  68. package/dist/crdt/config.js +123 -0
  69. package/dist/crdt/config.js.map +1 -0
  70. package/dist/crdt/helpers.d.ts +104 -0
  71. package/dist/crdt/helpers.d.ts.map +1 -0
  72. package/dist/crdt/helpers.js +116 -0
  73. package/dist/crdt/helpers.js.map +1 -0
  74. package/dist/crdt/offline.d.ts +58 -0
  75. package/dist/crdt/offline.d.ts.map +1 -0
  76. package/dist/crdt/offline.js +130 -0
  77. package/dist/crdt/offline.js.map +1 -0
  78. package/dist/crdt/persistence.d.ts +65 -0
  79. package/dist/crdt/persistence.d.ts.map +1 -0
  80. package/dist/crdt/persistence.js +171 -0
  81. package/dist/crdt/persistence.js.map +1 -0
  82. package/dist/crdt/provider.d.ts +109 -0
  83. package/dist/crdt/provider.d.ts.map +1 -0
  84. package/dist/crdt/provider.js +543 -0
  85. package/dist/crdt/provider.js.map +1 -0
  86. package/dist/crdt/store.d.ts +111 -0
  87. package/dist/crdt/store.d.ts.map +1 -0
  88. package/dist/crdt/store.js +158 -0
  89. package/dist/crdt/store.js.map +1 -0
  90. package/dist/crdt/types.d.ts +281 -0
  91. package/dist/crdt/types.d.ts.map +1 -0
  92. package/dist/crdt/types.js +26 -0
  93. package/dist/crdt/types.js.map +1 -0
  94. package/dist/data.d.ts +502 -0
  95. package/dist/data.d.ts.map +1 -0
  96. package/dist/data.js +862 -0
  97. package/dist/data.js.map +1 -0
  98. package/dist/database.d.ts +153 -0
  99. package/dist/database.d.ts.map +1 -0
  100. package/dist/database.js +325 -0
  101. package/dist/database.js.map +1 -0
  102. package/dist/debug.d.ts +87 -0
  103. package/dist/debug.d.ts.map +1 -0
  104. package/dist/debug.js +135 -0
  105. package/dist/debug.js.map +1 -0
  106. package/dist/demo.d.ts +131 -0
  107. package/dist/demo.d.ts.map +1 -0
  108. package/dist/demo.js +168 -0
  109. package/dist/demo.js.map +1 -0
  110. package/dist/deviceId.d.ts +47 -0
  111. package/dist/deviceId.d.ts.map +1 -0
  112. package/dist/deviceId.js +106 -0
  113. package/dist/deviceId.js.map +1 -0
  114. package/dist/diagnostics.d.ts +292 -0
  115. package/dist/diagnostics.d.ts.map +1 -0
  116. package/dist/diagnostics.js +378 -0
  117. package/dist/diagnostics.js.map +1 -0
  118. package/dist/engine.d.ts +230 -0
  119. package/dist/engine.d.ts.map +1 -0
  120. package/dist/engine.js +2636 -0
  121. package/dist/engine.js.map +1 -0
  122. package/dist/entries/actions.d.ts +16 -0
  123. package/dist/entries/actions.d.ts.map +1 -0
  124. package/dist/entries/actions.js +29 -0
  125. package/dist/entries/actions.js.map +1 -0
  126. package/dist/entries/auth.d.ts +19 -0
  127. package/dist/entries/auth.d.ts.map +1 -0
  128. package/dist/entries/auth.js +50 -0
  129. package/dist/entries/auth.js.map +1 -0
  130. package/dist/entries/config.d.ts +15 -0
  131. package/dist/entries/config.d.ts.map +1 -0
  132. package/dist/entries/config.js +20 -0
  133. package/dist/entries/config.js.map +1 -0
  134. package/dist/entries/crdt.d.ts +32 -0
  135. package/dist/entries/crdt.d.ts.map +1 -0
  136. package/dist/entries/crdt.js +52 -0
  137. package/dist/entries/crdt.js.map +1 -0
  138. package/dist/entries/kit.d.ts +22 -0
  139. package/dist/entries/kit.d.ts.map +1 -0
  140. package/dist/entries/kit.js +58 -0
  141. package/dist/entries/kit.js.map +1 -0
  142. package/dist/entries/stores.d.ts +22 -0
  143. package/dist/entries/stores.d.ts.map +1 -0
  144. package/dist/entries/stores.js +57 -0
  145. package/dist/entries/stores.js.map +1 -0
  146. package/dist/entries/types.d.ts +23 -0
  147. package/dist/entries/types.d.ts.map +1 -0
  148. package/dist/entries/types.js +12 -0
  149. package/dist/entries/types.js.map +1 -0
  150. package/dist/entries/utils.d.ts +12 -0
  151. package/dist/entries/utils.d.ts.map +1 -0
  152. package/dist/entries/utils.js +42 -0
  153. package/dist/entries/utils.js.map +1 -0
  154. package/dist/entries/vite.d.ts +20 -0
  155. package/dist/entries/vite.d.ts.map +1 -0
  156. package/dist/entries/vite.js +26 -0
  157. package/dist/entries/vite.js.map +1 -0
  158. package/dist/index.d.ts +77 -0
  159. package/dist/index.d.ts.map +1 -0
  160. package/dist/index.js +234 -0
  161. package/dist/index.js.map +1 -0
  162. package/dist/kit/auth.d.ts +80 -0
  163. package/dist/kit/auth.d.ts.map +1 -0
  164. package/dist/kit/auth.js +75 -0
  165. package/dist/kit/auth.js.map +1 -0
  166. package/dist/kit/confirm.d.ts +111 -0
  167. package/dist/kit/confirm.d.ts.map +1 -0
  168. package/dist/kit/confirm.js +169 -0
  169. package/dist/kit/confirm.js.map +1 -0
  170. package/dist/kit/loads.d.ts +187 -0
  171. package/dist/kit/loads.d.ts.map +1 -0
  172. package/dist/kit/loads.js +208 -0
  173. package/dist/kit/loads.js.map +1 -0
  174. package/dist/kit/server.d.ts +175 -0
  175. package/dist/kit/server.d.ts.map +1 -0
  176. package/dist/kit/server.js +297 -0
  177. package/dist/kit/server.js.map +1 -0
  178. package/dist/kit/sw.d.ts +176 -0
  179. package/dist/kit/sw.d.ts.map +1 -0
  180. package/dist/kit/sw.js +320 -0
  181. package/dist/kit/sw.js.map +1 -0
  182. package/dist/queue.d.ts +306 -0
  183. package/dist/queue.d.ts.map +1 -0
  184. package/dist/queue.js +925 -0
  185. package/dist/queue.js.map +1 -0
  186. package/dist/realtime.d.ts +280 -0
  187. package/dist/realtime.d.ts.map +1 -0
  188. package/dist/realtime.js +1031 -0
  189. package/dist/realtime.js.map +1 -0
  190. package/dist/runtime/runtimeConfig.d.ts +110 -0
  191. package/dist/runtime/runtimeConfig.d.ts.map +1 -0
  192. package/dist/runtime/runtimeConfig.js +260 -0
  193. package/dist/runtime/runtimeConfig.js.map +1 -0
  194. package/dist/schema.d.ts +150 -0
  195. package/dist/schema.d.ts.map +1 -0
  196. package/dist/schema.js +891 -0
  197. package/dist/schema.js.map +1 -0
  198. package/dist/stores/authState.d.ts +204 -0
  199. package/dist/stores/authState.d.ts.map +1 -0
  200. package/dist/stores/authState.js +336 -0
  201. package/dist/stores/authState.js.map +1 -0
  202. package/dist/stores/factories.d.ts +140 -0
  203. package/dist/stores/factories.d.ts.map +1 -0
  204. package/dist/stores/factories.js +157 -0
  205. package/dist/stores/factories.js.map +1 -0
  206. package/dist/stores/network.d.ts +48 -0
  207. package/dist/stores/network.d.ts.map +1 -0
  208. package/dist/stores/network.js +261 -0
  209. package/dist/stores/network.js.map +1 -0
  210. package/dist/stores/remoteChanges.d.ts +417 -0
  211. package/dist/stores/remoteChanges.d.ts.map +1 -0
  212. package/dist/stores/remoteChanges.js +626 -0
  213. package/dist/stores/remoteChanges.js.map +1 -0
  214. package/dist/stores/sync.d.ts +165 -0
  215. package/dist/stores/sync.d.ts.map +1 -0
  216. package/dist/stores/sync.js +275 -0
  217. package/dist/stores/sync.js.map +1 -0
  218. package/dist/supabase/auth.d.ts +219 -0
  219. package/dist/supabase/auth.d.ts.map +1 -0
  220. package/dist/supabase/auth.js +459 -0
  221. package/dist/supabase/auth.js.map +1 -0
  222. package/dist/supabase/client.d.ts +88 -0
  223. package/dist/supabase/client.d.ts.map +1 -0
  224. package/dist/supabase/client.js +313 -0
  225. package/dist/supabase/client.js.map +1 -0
  226. package/dist/supabase/validate.d.ts +118 -0
  227. package/dist/supabase/validate.d.ts.map +1 -0
  228. package/dist/supabase/validate.js +208 -0
  229. package/dist/supabase/validate.js.map +1 -0
  230. package/dist/sw/build/vite-plugin.d.ts +149 -0
  231. package/dist/sw/build/vite-plugin.d.ts.map +1 -0
  232. package/dist/sw/build/vite-plugin.js +517 -0
  233. package/dist/sw/build/vite-plugin.js.map +1 -0
  234. package/dist/sw/sw.js +664 -0
  235. package/dist/types.d.ts +363 -0
  236. package/dist/types.d.ts.map +1 -0
  237. package/dist/types.js +18 -0
  238. package/dist/types.js.map +1 -0
  239. package/dist/utils.d.ts +85 -0
  240. package/dist/utils.d.ts.map +1 -0
  241. package/dist/utils.js +156 -0
  242. package/dist/utils.js.map +1 -0
  243. package/package.json +117 -0
  244. package/src/components/DeferredChangesBanner.svelte +477 -0
  245. package/src/components/DemoBanner.svelte +110 -0
  246. 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