@livenetworks/ashlar 1.5.1 → 1.6.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 (217) hide show
  1. package/README.md +189 -177
  2. package/js/COMPONENTS.md +55 -49
  3. package/js/index.js +22 -5
  4. package/js/ln-accordion/README.md +186 -137
  5. package/js/ln-accordion/ln-accordion.js +1 -1
  6. package/js/ln-ajax/README.md +73 -4
  7. package/js/ln-ajax/ln-ajax.js +1 -1
  8. package/js/ln-ajax/src/ln-ajax.js +13 -13
  9. package/js/ln-api-connector/README.md +261 -150
  10. package/js/ln-api-connector/ln-api-connector.js +1 -1
  11. package/js/ln-api-connector/src/ln-api-connector.js +368 -265
  12. package/js/ln-api-queue/README.md +219 -0
  13. package/js/ln-api-queue/ln-api-queue.js +1 -0
  14. package/js/ln-api-queue/src/ln-api-queue.js +320 -0
  15. package/js/ln-api-queue/src/queue-storage.js +455 -0
  16. package/js/ln-autoresize/README.md +47 -0
  17. package/js/ln-autoresize/ln-autoresize.js +1 -1
  18. package/js/ln-autosave/README.md +33 -1
  19. package/js/ln-autosave/ln-autosave.js +1 -1
  20. package/js/ln-autosave/src/ln-autosave.js +4 -4
  21. package/js/ln-circular-progress/README.md +27 -13
  22. package/js/ln-circular-progress/ln-circular-progress.js +1 -1
  23. package/js/ln-circular-progress/src/ln-circular-progress.js +18 -5
  24. package/js/ln-confirm/README.md +162 -86
  25. package/js/ln-confirm/_ln-confirm.scss +11 -0
  26. package/js/ln-confirm/ln-confirm.js +1 -1
  27. package/js/ln-confirm/src/ln-confirm.js +77 -41
  28. package/js/ln-core/README.md +896 -0
  29. package/js/ln-core/hash.js +92 -0
  30. package/js/ln-core/helpers.js +337 -8
  31. package/js/ln-core/index.js +4 -2
  32. package/js/ln-core/persist.js +29 -3
  33. package/js/ln-core/positioning.js +1 -35
  34. package/js/ln-core/window-cache.js +213 -0
  35. package/js/ln-couchdb-connector/README.md +177 -156
  36. package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -1
  37. package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +398 -348
  38. package/js/ln-data-coordinator/README.md +536 -165
  39. package/js/ln-data-coordinator/ln-data-coordinator.js +1 -1
  40. package/js/ln-data-coordinator/src/data-read-policy.js +18 -0
  41. package/js/ln-data-coordinator/src/ln-data-coordinator.js +1042 -249
  42. package/js/ln-data-coordinator/src/mutation-receipts.js +46 -0
  43. package/js/ln-data-store/README.md +241 -94
  44. package/js/ln-data-store/ln-data-store.js +1 -1
  45. package/js/ln-data-store/src/ln-data-store.js +694 -699
  46. package/js/ln-date/README.md +39 -1
  47. package/js/ln-date/ln-date-dev.scss +14 -0
  48. package/js/ln-date/ln-date.js +1 -1
  49. package/js/ln-date/src/ln-date.js +236 -178
  50. package/js/ln-debug/README.md +58 -0
  51. package/js/ln-debug/ln-debug.js +1 -0
  52. package/js/ln-debug/src/ln-debug.js +19 -0
  53. package/js/ln-dropdown/README.md +31 -8
  54. package/js/ln-dropdown/ln-dropdown.js +1 -1
  55. package/js/ln-dropdown/src/ln-dropdown.js +30 -13
  56. package/js/ln-editor/README.md +274 -0
  57. package/js/ln-editor/ln-editor.js +1 -0
  58. package/js/ln-editor/ln-editor.scss +43 -0
  59. package/js/ln-editor/src/ln-editor.js +619 -0
  60. package/js/ln-external-links/README.md +44 -5
  61. package/js/ln-external-links/ln-external-links.js +1 -1
  62. package/js/ln-fill/README.md +199 -0
  63. package/js/ln-fill/ln-fill.js +1 -0
  64. package/js/ln-fill/src/ln-fill.js +87 -0
  65. package/js/ln-filter/README.md +92 -2
  66. package/js/ln-filter/ln-filter-dev.scss +8 -0
  67. package/js/ln-filter/ln-filter.js +1 -1
  68. package/js/ln-filter/src/ln-filter.js +22 -7
  69. package/js/ln-form/README.md +212 -49
  70. package/js/ln-form/ln-form.js +1 -1
  71. package/js/ln-form/src/ln-form.js +99 -199
  72. package/js/ln-http/README.md +125 -89
  73. package/js/ln-http/ln-http.js +1 -1
  74. package/js/ln-http/src/ln-http.js +32 -13
  75. package/js/ln-icon/README.md +130 -0
  76. package/js/ln-icon/ln-icon.js +1 -0
  77. package/js/{ln-icons/src/ln-icons.js → ln-icon/src/ln-icon.js} +20 -15
  78. package/js/ln-link/README.md +47 -9
  79. package/js/ln-link/ln-link.js +1 -1
  80. package/js/ln-link/src/ln-link.js +22 -6
  81. package/js/ln-list/README.md +129 -0
  82. package/js/ln-list/ln-list.js +1 -0
  83. package/js/ln-list/ln-list.scss +28 -0
  84. package/js/ln-list/src/ln-list.js +1216 -0
  85. package/js/ln-modal/README.md +185 -154
  86. package/js/ln-modal/ln-modal-dev.scss +13 -0
  87. package/js/ln-modal/ln-modal.js +1 -1
  88. package/js/ln-modal/ln-modal.scss +28 -4
  89. package/js/ln-modal/src/ln-modal.js +137 -201
  90. package/js/ln-modal-coordinator/README.md +99 -0
  91. package/js/ln-modal-coordinator/ln-modal-coordinator-dev.scss +8 -0
  92. package/js/ln-modal-coordinator/ln-modal-coordinator.js +1 -0
  93. package/js/ln-modal-coordinator/src/ln-modal-coordinator.js +345 -0
  94. package/js/ln-nav/README.md +46 -3
  95. package/js/ln-nav/ln-nav.js +1 -1
  96. package/js/ln-nav/src/ln-nav.js +92 -127
  97. package/js/ln-number/README.md +28 -1
  98. package/js/ln-number/ln-number-dev.scss +13 -0
  99. package/js/ln-number/ln-number.js +1 -1
  100. package/js/ln-number/src/ln-number.js +443 -350
  101. package/js/ln-options/README.md +66 -0
  102. package/js/ln-options/ln-options.js +1 -0
  103. package/js/ln-options/src/ln-options.js +74 -0
  104. package/js/ln-popover/README.md +40 -5
  105. package/js/ln-popover/ln-popover.js +1 -1
  106. package/js/ln-popover/src/ln-popover.js +38 -11
  107. package/js/ln-progress/README.md +413 -442
  108. package/js/ln-progress/ln-progress.js +1 -1
  109. package/js/ln-progress/src/ln-progress.js +98 -150
  110. package/js/ln-router/README.md +432 -0
  111. package/js/ln-router/ln-router.js +1 -0
  112. package/js/ln-router/src/ln-router.js +539 -0
  113. package/js/ln-search/README.md +158 -83
  114. package/js/ln-search/ln-search-dev.scss +8 -0
  115. package/js/ln-search/ln-search.js +1 -1
  116. package/js/ln-search/src/ln-search.js +12 -9
  117. package/js/ln-slug/README.md +110 -0
  118. package/js/ln-slug/ln-slug.js +1 -0
  119. package/js/ln-slug/src/ln-slug.js +74 -0
  120. package/js/ln-sort/README.md +170 -0
  121. package/js/ln-sort/ln-sort-dev.scss +8 -0
  122. package/js/ln-sort/ln-sort.js +1 -0
  123. package/js/ln-sort/src/ln-sort.js +178 -0
  124. package/js/ln-sortable/README.md +133 -95
  125. package/js/ln-sortable/ln-sortable.js +1 -1
  126. package/js/ln-stat/README.md +55 -0
  127. package/js/ln-stat/ln-stat.js +1 -0
  128. package/js/ln-stat/src/ln-stat.js +55 -0
  129. package/js/ln-table/README.md +285 -101
  130. package/js/ln-table/ln-table-dev.scss +17 -0
  131. package/js/ln-table/ln-table.js +1 -1
  132. package/js/ln-table/ln-table.scss +1 -1
  133. package/js/ln-table/src/ln-table.js +1493 -473
  134. package/js/ln-table-coordinator/README.md +73 -0
  135. package/js/ln-table-coordinator/ln-table-coordinator.js +1 -0
  136. package/js/ln-table-coordinator/src/ln-table-coordinator.js +160 -0
  137. package/js/ln-tabs/README.md +221 -137
  138. package/js/ln-tabs/ln-tabs-dev.scss +18 -0
  139. package/js/ln-tabs/ln-tabs.js +1 -1
  140. package/js/ln-tabs/src/ln-tabs.js +41 -24
  141. package/js/ln-time/README.md +14 -0
  142. package/js/ln-time/ln-time.js +1 -1
  143. package/js/ln-time/src/ln-time.js +223 -192
  144. package/js/ln-toast/README.md +178 -122
  145. package/js/ln-toast/ln-toast.js +1 -15
  146. package/js/ln-toast/src/ln-toast.js +232 -210
  147. package/js/ln-toast/template.html +16 -12
  148. package/js/ln-toggle/README.md +61 -2
  149. package/js/ln-toggle/ln-toggle-dev.scss +13 -0
  150. package/js/ln-toggle/ln-toggle.js +1 -1
  151. package/js/ln-toggle/src/ln-toggle.js +56 -43
  152. package/js/ln-tooltip/README.md +95 -58
  153. package/js/ln-tooltip/ln-tooltip-dev.scss +9 -0
  154. package/js/ln-tooltip/ln-tooltip.js +1 -1
  155. package/js/ln-tooltip/src/ln-tooltip.js +45 -6
  156. package/js/ln-translations/README.md +118 -96
  157. package/js/ln-translations/ln-translations.js +1 -1
  158. package/js/ln-translations/src/ln-translations.js +10 -5
  159. package/js/ln-upload/README.md +85 -10
  160. package/js/ln-upload/ln-upload.js +1 -1
  161. package/js/ln-upload/src/ln-upload.js +26 -10
  162. package/js/ln-validate/README.md +72 -0
  163. package/js/ln-validate/ln-validate-dev.scss +15 -0
  164. package/js/ln-validate/ln-validate.js +1 -1
  165. package/js/ln-validate/src/ln-validate.js +64 -0
  166. package/package.json +61 -56
  167. package/scss/base/_global.scss +2 -2
  168. package/scss/base/_typography.scss +1 -2
  169. package/scss/components/_date.scss +1 -0
  170. package/scss/components/_dropdown.scss +0 -5
  171. package/scss/components/_editor.scss +5 -0
  172. package/scss/components/_filter.scss +26 -25
  173. package/scss/components/_form.scss +7 -0
  174. package/scss/components/_ln-table.scss +13 -3
  175. package/scss/components/_sort.scss +10 -0
  176. package/scss/components/_table.scss +36 -23
  177. package/scss/components/_toast.scss +68 -41
  178. package/scss/components/_toggle.scss +16 -0
  179. package/scss/config/_density.scss +205 -117
  180. package/scss/config/_icons.scss +10 -9
  181. package/scss/config/_tokens.scss +69 -47
  182. package/scss/config/mixins/_accordion.scss +2 -7
  183. package/scss/config/mixins/_alert.scss +4 -4
  184. package/scss/config/mixins/_breadcrumbs.scss +2 -2
  185. package/scss/config/mixins/_btn.scss +1 -1
  186. package/scss/config/mixins/_card.scss +89 -14
  187. package/scss/config/mixins/_collapsible.scss +3 -0
  188. package/scss/config/mixins/_colors.scss +1 -1
  189. package/scss/config/mixins/_diagnostics.scss +11 -0
  190. package/scss/config/mixins/_display.scss +1 -0
  191. package/scss/config/mixins/_dropdown.scss +3 -5
  192. package/scss/config/mixins/_editor.scss +124 -0
  193. package/scss/config/mixins/_empty-state.scss +7 -0
  194. package/scss/config/mixins/_filter.scss +3 -5
  195. package/scss/config/mixins/_footer.scss +1 -2
  196. package/scss/config/mixins/_form.scss +207 -21
  197. package/scss/config/mixins/_index.scss +2 -0
  198. package/scss/config/mixins/_ln-table.scss +41 -2
  199. package/scss/config/mixins/_modal.scss +149 -66
  200. package/scss/config/mixins/_nav.scss +3 -4
  201. package/scss/config/mixins/_popover.scss +39 -0
  202. package/scss/config/mixins/_sidebar.scss +1 -1
  203. package/scss/config/mixins/_sort.scss +42 -0
  204. package/scss/config/mixins/_table.scss +85 -60
  205. package/scss/config/mixins/_toast.scss +179 -152
  206. package/scss/config/mixins/_tooltip.scss +17 -7
  207. package/scss/config/mixins/_typography.scss +4 -2
  208. package/scss/ln-ashlar-dev.scss +27 -9
  209. package/scss/ln-ashlar.scss +7 -1
  210. package/js/ln-data-table/README.md +0 -110
  211. package/js/ln-data-table/ln-data-table.js +0 -1
  212. package/js/ln-data-table/ln-data-table.scss +0 -10
  213. package/js/ln-data-table/src/ln-data-table.js +0 -1087
  214. package/js/ln-icons/README.md +0 -88
  215. package/js/ln-icons/ln-icons.js +0 -1
  216. package/js/ln-table/ln-table-sort.js +0 -1
  217. package/js/ln-table/src/ln-table-sort.js +0 -156
@@ -1,699 +1,694 @@
1
- import { registerComponent, dispatch, setCryptoKey, getCryptoKey, encryptData, decryptData } from '../../ln-core';
2
-
3
- (function () {
4
- const DOM_SELECTOR = 'data-ln-data-store';
5
- const DOM_ATTRIBUTE = 'lnDataStore';
6
-
7
- if (window[DOM_ATTRIBUTE] !== undefined) return;
8
-
9
- const DB_NAME = 'ln_app_cache';
10
- const META_STORE = '_meta';
11
- const SCHEMA_VERSION = '1.0';
12
-
13
- let _db = null;
14
- let _dbReady = null;
15
- const _stores = {};
16
-
17
- function _uuid() {
18
- try { return crypto.randomUUID(); }
19
- catch (_) {
20
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
21
- const r = Math.random() * 16 | 0;
22
- return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
23
- });
24
- }
25
- }
26
-
27
- function _checkQuota(err) {
28
- if (err && err.name === 'QuotaExceededError') {
29
- dispatch(document, 'ln-store:quota-exceeded', { error: err });
30
- }
31
- }
32
-
33
- // ─── Database ──────────────────────────────────────────
34
-
35
- function _getRequiredStores() {
36
- const required = {};
37
- for (const el of document.querySelectorAll(`[${DOM_SELECTOR}]`)) {
38
- const name = el.getAttribute(DOM_SELECTOR);
39
- if (name) {
40
- const indexAttr = el.getAttribute('data-ln-data-store-indexes') || el.getAttribute('data-ln-store-indexes') || '';
41
- required[name] = {
42
- indexes: indexAttr.split(',').map(s => s.trim()).filter(Boolean)
43
- };
44
- }
45
- }
46
- return required;
47
- }
48
-
49
- function _openDatabase() {
50
- if (_dbReady) return _dbReady;
51
-
52
- _dbReady = new Promise(resolve => {
53
- if (typeof indexedDB === 'undefined') {
54
- console.warn('[ln-data-store] IndexedDB not available — falling back to in-memory store');
55
- return resolve(null);
56
- }
57
-
58
- const required = _getRequiredStores();
59
- const requiredNames = Object.keys(required);
60
- const probe = indexedDB.open(DB_NAME);
61
-
62
- probe.onerror = () => {
63
- console.warn('[ln-data-store] IndexedDB open failed — falling back to in-memory store');
64
- resolve(null);
65
- };
66
-
67
- probe.onsuccess = e => {
68
- const db = e.target.result;
69
- const existing = Array.from(db.objectStoreNames);
70
- const needsUpgrade = !existing.includes(META_STORE) || requiredNames.some(name => !existing.includes(name));
71
-
72
- if (!needsUpgrade) {
73
- _setupVersionChangeHandler(db);
74
- _db = db;
75
- return resolve(db);
76
- }
77
-
78
- const currentVersion = db.version;
79
- db.close();
80
-
81
- const upgrade = indexedDB.open(DB_NAME, currentVersion + 1);
82
-
83
- upgrade.onblocked = () => {
84
- console.warn('[ln-data-store] Database upgrade blocked — waiting for other tabs to close connection');
85
- };
86
-
87
- upgrade.onerror = () => {
88
- console.warn('[ln-data-store] Database upgrade failed');
89
- resolve(null);
90
- };
91
-
92
- upgrade.onupgradeneeded = e => {
93
- const db = e.target.result;
94
- if (!db.objectStoreNames.contains(META_STORE)) {
95
- db.createObjectStore(META_STORE, { keyPath: 'key' });
96
- }
97
- for (const storeName of requiredNames) {
98
- if (!db.objectStoreNames.contains(storeName)) {
99
- const store = db.createObjectStore(storeName, { keyPath: 'id' });
100
- for (const idx of required[storeName].indexes) {
101
- store.createIndex(idx, idx, { unique: false });
102
- }
103
- }
104
- }
105
- };
106
-
107
- upgrade.onsuccess = e => {
108
- const db = e.target.result;
109
- _setupVersionChangeHandler(db);
110
- _db = db;
111
- resolve(db);
112
- };
113
- };
114
- });
115
-
116
- return _dbReady;
117
- }
118
-
119
- function _setupVersionChangeHandler(db) {
120
- db.onversionchange = () => {
121
- db.close();
122
- _db = null;
123
- _dbReady = null;
124
- };
125
- }
126
-
127
- function _getDb() {
128
- if (_db) return Promise.resolve(_db);
129
- _dbReady = null;
130
- return _openDatabase();
131
- }
132
-
133
- // ─── Cryptographic Wrappers (DRY using ln-core) ──────────
134
-
135
- async function _encryptRecord(record) {
136
- if (!getCryptoKey() || !record) return record;
137
-
138
- // Isolate ID and metadata we want in plain text for IndexedDB queries
139
- const plainRecord = { ...record };
140
- const recordId = plainRecord.id;
141
- const pending = plainRecord._pending;
142
-
143
- // Encrypt payload using core helper
144
- const encryptedPayload = await encryptData(plainRecord);
145
- if (!encryptedPayload || !encryptedPayload.encrypted) return record;
146
-
147
- return {
148
- id: recordId,
149
- _pending: pending,
150
- encrypted: true,
151
- iv: encryptedPayload.iv,
152
- data: encryptedPayload.data
153
- };
154
- }
155
-
156
- async function _decryptRecord(record) {
157
- if (!record || !record.encrypted || !getCryptoKey()) return record;
158
- return decryptData(record);
159
- }
160
-
161
- // ─── IndexedDB CRUD Helpers ────────────────────────────
162
-
163
- const _tx = (storeName, mode) => _getDb().then(db => db ? db.transaction(storeName, mode).objectStore(storeName) : null);
164
-
165
- function _idbRequest(request) {
166
- return new Promise((resolve, reject) => {
167
- request.onsuccess = () => resolve(request.result);
168
- request.onerror = () => {
169
- _checkQuota(request.error);
170
- reject(request.error);
171
- };
172
- });
173
- }
174
-
175
- const _getAllRecords = storeName => _tx(storeName, 'readonly')
176
- .then(store => store ? _idbRequest(store.getAll()) : [])
177
- .then(records => getCryptoKey() ? Promise.all(records.map(r => _decryptRecord(r))) : records);
178
-
179
- const _getRecord = (storeName, id) => _tx(storeName, 'readonly')
180
- .then(store => store ? _idbRequest(store.get(id)) : null)
181
- .then(record => record ? _decryptRecord(record) : null);
182
-
183
- const _putRecord = (storeName, record) => {
184
- const prepPromise = getCryptoKey() ? _encryptRecord(record) : Promise.resolve(record);
185
- return prepPromise.then(prepped => _tx(storeName, 'readwrite').then(store => store ? _idbRequest(store.put(prepped)) : null));
186
- };
187
-
188
- const _deleteRecord = (storeName, id) => _tx(storeName, 'readwrite').then(store => store ? _idbRequest(store.delete(id)) : null);
189
- const _clearStore = storeName => _tx(storeName, 'readwrite').then(store => store ? _idbRequest(store.clear()) : null);
190
- const _countRecords = storeName => _tx(storeName, 'readonly').then(store => store ? _idbRequest(store.count()) : 0);
191
-
192
- // ─── Meta Store ────────────────────────────────────────
193
-
194
- const _getMeta = storeName => _tx(META_STORE, 'readonly').then(store => store ? _idbRequest(store.get(storeName)) : null);
195
- const _setMeta = (storeName, data) => _tx(META_STORE, 'readwrite').then(store => {
196
- if (!store) return;
197
- data.key = storeName;
198
- return _idbRequest(store.put(data));
199
- });
200
-
201
- // ─── Component Constructor ─────────────────────────────
202
-
203
- function _component(dom) {
204
- this.dom = dom;
205
- this._name = dom.getAttribute(DOM_SELECTOR);
206
-
207
- const staleAttr = dom.getAttribute('data-ln-data-store-stale') || dom.getAttribute('data-ln-store-stale');
208
- const _parsed = parseInt(staleAttr, 10);
209
- this._staleThreshold = (staleAttr === 'never' || staleAttr === '-1') ? -1 : (isNaN(_parsed) ? 300 : _parsed);
210
-
211
- const searchAttr = dom.getAttribute('data-ln-data-store-search-fields') || dom.getAttribute('data-ln-store-search-fields') || '';
212
- this._searchFields = searchAttr.split(',').map(s => s.trim()).filter(Boolean);
213
-
214
- this._handlers = null;
215
- this._pendingSnapshots = {};
216
-
217
- this.isLoaded = false;
218
- this.isSyncing = false;
219
- this.lastSyncedAt = null;
220
- this.totalCount = 0;
221
- this.presenters = null;
222
-
223
- _stores[this._name] = this;
224
-
225
- _bindEvents(this);
226
- _initStore(this);
227
- return this;
228
- }
229
-
230
- // ─── DOM Mutation Requests Listeners ────────────────────
231
-
232
- function _bindEvents(self) {
233
- self._handlers = {
234
- 'create': e => _handleCreateRequest(self, e.detail),
235
- 'update': e => _handleUpdateRequest(self, e.detail),
236
- 'delete': e => _handleDeleteRequest(self, e.detail),
237
- 'bulk-delete': e => _handleBulkDeleteRequest(self, e.detail)
238
- };
239
- for (const [event, fn] of Object.entries(self._handlers)) {
240
- self.dom.addEventListener(`ln-store:request-${event}`, fn);
241
- }
242
- }
243
-
244
- // ─── Optimistic Writing Pipeline ─────────────────────────
245
-
246
- function _handleCreateRequest(self, { data = {} } = {}) {
247
- const tempId = `_temp_${_uuid()}`;
248
- const record = { ...data, id: tempId, _pending: true };
249
-
250
- _putRecord(self._name, record).then(() => {
251
- self.totalCount++;
252
- dispatch(self.dom, 'ln-store:created', { store: self._name, record, tempId });
253
- dispatch(self.dom, 'ln-store:request-remote-create', { tempId, data });
254
- });
255
- }
256
-
257
- function _handleUpdateRequest(self, { id, data = {}, expected_version } = {}) {
258
- _getRecord(self._name, id).then(existing => {
259
- if (!existing) throw new Error(`Record not found: ${id}`);
260
-
261
- self._pendingSnapshots[id] = { ...existing };
262
- const updated = { ...existing, ...data, _pending: true };
263
-
264
- return _putRecord(self._name, updated).then(() => {
265
- dispatch(self.dom, 'ln-store:updated', { store: self._name, record: updated, previous: self._pendingSnapshots[id] });
266
- dispatch(self.dom, 'ln-store:request-remote-update', { id, data, expected_version });
267
- });
268
- }).catch(err => console.error('[ln-data-store] Optimistic update failed:', err));
269
- }
270
-
271
- function _handleDeleteRequest(self, { id } = {}) {
272
- _getRecord(self._name, id).then(existing => {
273
- if (!existing) return;
274
-
275
- self._pendingSnapshots[id] = { ...existing };
276
-
277
- return _deleteRecord(self._name, id).then(() => {
278
- self.totalCount--;
279
- dispatch(self.dom, 'ln-store:deleted', { store: self._name, id });
280
- dispatch(self.dom, 'ln-store:request-remote-delete', { id });
281
- });
282
- }).catch(err => console.error('[ln-data-store] Optimistic delete failed:', err));
283
- }
284
-
285
- function _handleBulkDeleteRequest(self, { ids = [] } = {}) {
286
- if (!ids.length) return;
287
-
288
- Promise.all(ids.map(id => _getRecord(self._name, id))).then(records => {
289
- const savedRecords = records.filter(Boolean);
290
- const savedIds = savedRecords.map(r => r.id);
291
-
292
- self._pendingSnapshots[savedIds.join(',')] = savedRecords;
293
-
294
- return _deleteBulk(self._name, savedIds).then(() => {
295
- self.totalCount -= savedIds.length;
296
- dispatch(self.dom, 'ln-store:deleted', { store: self._name, ids: savedIds });
297
- dispatch(self.dom, 'ln-store:request-remote-bulk-delete', { ids: savedIds });
298
- });
299
- }).catch(err => console.error('[ln-data-store] Optimistic bulk delete failed:', err));
300
- }
301
-
302
- // ─── Initialization ────────────────────────────────────
303
-
304
- function _initStore(self) {
305
- _openDatabase().then(() => _getMeta(self._name)).then(meta => {
306
- if (meta && meta.schema_version === SCHEMA_VERSION) {
307
- self.lastSyncedAt = meta.last_synced_at || null;
308
- self.totalCount = meta.record_count || 0;
309
-
310
- if (self.totalCount > 0) {
311
- self.isLoaded = true;
312
- dispatch(self.dom, 'ln-store:ready', { store: self._name, count: self.totalCount, source: 'cache' });
313
- if (_isStale(self)) _triggerRemoteSync(self);
314
- } else {
315
- _triggerRemoteSync(self);
316
- }
317
- } else if (meta && meta.schema_version !== SCHEMA_VERSION) {
318
- _clearStore(self._name)
319
- .then(() => _setMeta(self._name, { schema_version: SCHEMA_VERSION, last_synced_at: null, record_count: 0 }))
320
- .then(() => _triggerRemoteSync(self));
321
- } else {
322
- _triggerRemoteSync(self);
323
- }
324
- });
325
- }
326
-
327
- function _isStale(self) {
328
- if (self._staleThreshold === -1) return false;
329
- if (!self.lastSyncedAt) return true;
330
- return (Math.floor(Date.now() / 1000) - self.lastSyncedAt) > self._staleThreshold;
331
- }
332
-
333
- function _triggerRemoteSync(self) {
334
- self.isSyncing = true;
335
- dispatch(self.dom, 'ln-store:request-remote-sync', { since: self.lastSyncedAt });
336
- }
337
-
338
- // ─── Bulk IndexedDB Operations ─────────────────────────
339
-
340
- function _putBulk(storeName, records) {
341
- return _getDb().then(db => {
342
- if (!db) return;
343
-
344
- const prepPromise = getCryptoKey()
345
- ? Promise.all(records.map(r => _encryptRecord(r)))
346
- : Promise.resolve(records);
347
-
348
- return prepPromise.then(preppedRecords => {
349
- return new Promise((resolve, reject) => {
350
- const tx = db.transaction(storeName, 'readwrite');
351
- const store = tx.objectStore(storeName);
352
- preppedRecords.forEach(r => store.put(r));
353
- tx.oncomplete = () => resolve();
354
- tx.onerror = () => {
355
- _checkQuota(tx.error);
356
- reject(tx.error);
357
- };
358
- });
359
- });
360
- });
361
- }
362
-
363
- function _deleteBulk(storeName, ids) {
364
- return _getDb().then(db => {
365
- if (!db) return;
366
- return new Promise((resolve, reject) => {
367
- const tx = db.transaction(storeName, 'readwrite');
368
- const store = tx.objectStore(storeName);
369
- ids.forEach(id => store.delete(id));
370
- tx.oncomplete = () => resolve();
371
- tx.onerror = () => reject(tx.error);
372
- });
373
- });
374
- }
375
-
376
- // ─── Visibility Auto-Sync check ───
377
-
378
- let _visibilityHandler = () => {
379
- if (document.visibilityState !== 'visible') return;
380
- Object.values(_stores).forEach(inst => {
381
- if (inst.isLoaded && !inst.isSyncing && _isStale(inst)) {
382
- _triggerRemoteSync(inst);
383
- }
384
- });
385
- };
386
- document.addEventListener('visibilitychange', _visibilityHandler);
387
-
388
- // ─── Query Engine (In-Memory over Cache) ───────────────
389
-
390
- const _collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
391
-
392
- function _sort(records, sort) {
393
- if (!sort || !sort.field) return records;
394
- const { field, direction } = sort;
395
- const desc = direction === 'desc';
396
-
397
- return [...records].sort((a, b) => {
398
- const va = a[field];
399
- const vb = b[field];
400
-
401
- if (va == null && vb == null) return 0;
402
- if (va == null) return desc ? 1 : -1;
403
- if (vb == null) return desc ? -1 : 1;
404
-
405
- const result = (typeof va === 'string' && typeof vb === 'string')
406
- ? _collator.compare(va, vb)
407
- : (va < vb ? -1 : va > vb ? 1 : 0);
408
-
409
- return desc ? -result : result;
410
- });
411
- }
412
-
413
- function _filter(records, filters) {
414
- if (!filters) return records;
415
- const keys = Object.keys(filters).filter(k => Array.isArray(filters[k]) && filters[k].length > 0);
416
- if (!keys.length) return records;
417
-
418
- return records.filter(record =>
419
- keys.every(field => filters[field].map(String).includes(String(record[field])))
420
- );
421
- }
422
-
423
- function _search(records, query, searchFields) {
424
- if (!query || !searchFields || !searchFields.length) return records;
425
- const lower = query.toLowerCase();
426
- return records.filter(record =>
427
- searchFields.some(field => {
428
- const val = record[field];
429
- return val != null && String(val).toLowerCase().includes(lower);
430
- })
431
- );
432
- }
433
-
434
- function _aggregate(records, field, fn) {
435
- if (!records.length) return 0;
436
- if (fn === 'count') return records.length;
437
-
438
- const numbers = records.map(r => parseFloat(r[field])).filter(v => !isNaN(v));
439
- const sum = numbers.reduce((a, b) => a + b, 0);
440
-
441
- if (fn === 'sum') return sum;
442
- if (fn === 'avg') return numbers.length ? sum / numbers.length : 0;
443
- return 0;
444
- }
445
-
446
- function _decorate(self, records) {
447
- if (!self.presenters || !self.presenters.computed) return records;
448
- const computed = self.presenters.computed;
449
- return records.map(record => {
450
- const copy = { ...record };
451
- for (const [fieldName, fn] of Object.entries(computed)) {
452
- try {
453
- copy[fieldName] = fn(record);
454
- } catch (err) {
455
- console.error(`[ln-data-store] Decorator computed field failed for ${fieldName}`, err);
456
- }
457
- }
458
- return copy;
459
- });
460
- }
461
-
462
- // ─── Public CRUD & Sync APIs ───────────────────────────
463
-
464
- _component.prototype.getAll = function (options = {}) {
465
- const self = this;
466
- return _getAllRecords(self._name).then(records => {
467
- const total = records.length;
468
-
469
- if (options.filters) records = _filter(records, options.filters);
470
- if (options.search) records = _search(records, options.search, self._searchFields);
471
-
472
- const filtered = records.length;
473
-
474
- if (options.sort) records = _sort(records, options.sort);
475
-
476
- if (options.offset || options.limit) {
477
- const offset = options.offset || 0;
478
- const limit = options.limit || records.length;
479
- records = records.slice(offset, offset + limit);
480
- }
481
-
482
- return {
483
- data: _decorate(self, records),
484
- total,
485
- filtered
486
- };
487
- });
488
- };
489
-
490
- _component.prototype.getById = function (id) {
491
- return _getRecord(this._name, id).then(record => record ? _decorate(this, [record])[0] : null);
492
- };
493
-
494
- _component.prototype.count = function (filters) {
495
- return filters
496
- ? _getAllRecords(this._name).then(records => _filter(records, filters).length)
497
- : _countRecords(this._name);
498
- };
499
-
500
- _component.prototype.aggregate = function (field, fn) {
501
- return _getAllRecords(this._name).then(records => _aggregate(records, field, fn));
502
- };
503
-
504
- _component.prototype.setPresenters = function (presenters) {
505
- this.presenters = presenters;
506
- };
507
-
508
- // ─── Public Remote Response Synchronization Methods ────
509
-
510
- _component.prototype.applySync = function (upsertedRecords, deletedIds, syncedAt) {
511
- const self = this;
512
- const hasChanges = upsertedRecords.length > 0 || deletedIds.length > 0;
513
-
514
- let chain = Promise.resolve();
515
- if (upsertedRecords.length > 0) chain = chain.then(() => _putBulk(self._name, upsertedRecords));
516
- if (deletedIds.length > 0) chain = chain.then(() => _deleteBulk(self._name, deletedIds));
517
-
518
- return chain.then(() => _countRecords(self._name)).then(count => {
519
- self.totalCount = count;
520
- return _setMeta(self._name, {
521
- schema_version: SCHEMA_VERSION,
522
- last_synced_at: syncedAt,
523
- record_count: count
524
- });
525
- }).then(() => {
526
- const isInitialLoad = !self.isLoaded;
527
- self.isLoaded = true;
528
- self.isSyncing = false;
529
- self.lastSyncedAt = syncedAt;
530
-
531
- if (isInitialLoad) {
532
- dispatch(self.dom, 'ln-store:loaded', { store: self._name, count: self.totalCount });
533
- dispatch(self.dom, 'ln-store:ready', { store: self._name, count: self.totalCount, source: 'server' });
534
- } else {
535
- dispatch(self.dom, 'ln-store:synced', {
536
- store: self._name,
537
- added: upsertedRecords.length,
538
- deleted: deletedIds.length,
539
- changed: hasChanges
540
- });
541
- }
542
- }).catch(err => {
543
- self.isSyncing = false;
544
- console.error('[ln-data-store] applySync failed:', err);
545
- });
546
- };
547
-
548
- _component.prototype.confirmMutation = function (tempIdOrId, serverRecord, action) {
549
- const self = this;
550
- const handlers = {
551
- create: () => _deleteRecord(self._name, tempIdOrId)
552
- .then(() => _putRecord(self._name, serverRecord))
553
- .then(() => {
554
- delete self._pendingSnapshots[tempIdOrId];
555
- dispatch(self.dom, 'ln-store:confirmed', { store: self._name, record: serverRecord, tempId: tempIdOrId, action: 'create' });
556
- }),
557
- update: () => _putRecord(self._name, serverRecord).then(() => {
558
- delete self._pendingSnapshots[tempIdOrId];
559
- dispatch(self.dom, 'ln-store:confirmed', { store: self._name, record: serverRecord, action: 'update' });
560
- }),
561
- delete: () => {
562
- delete self._pendingSnapshots[tempIdOrId];
563
- dispatch(self.dom, 'ln-store:confirmed', { store: self._name, record: null, action: 'delete' });
564
- return Promise.resolve();
565
- },
566
- 'bulk-delete': () => {
567
- delete self._pendingSnapshots[tempIdOrId];
568
- dispatch(self.dom, 'ln-store:confirmed', { store: self._name, record: null, ids: tempIdOrId.split(','), action: 'bulk-delete' });
569
- return Promise.resolve();
570
- }
571
- };
572
- return handlers[action] ? handlers[action]() : Promise.resolve();
573
- };
574
-
575
- _component.prototype.revertMutation = function (tempIdOrId, action, error) {
576
- const self = this;
577
- const fallbackErr = error || `Server rejected ${action}`;
578
-
579
- const handlers = {
580
- create: () => _deleteRecord(self._name, tempIdOrId).then(() => {
581
- self.totalCount--;
582
- delete self._pendingSnapshots[tempIdOrId];
583
- dispatch(self.dom, 'ln-store:reverted', { store: self._name, record: null, action: 'create', error: fallbackErr });
584
- }),
585
- update: () => {
586
- const previous = self._pendingSnapshots[tempIdOrId];
587
- if (!previous) return Promise.resolve();
588
- return _putRecord(self._name, previous).then(() => {
589
- delete self._pendingSnapshots[tempIdOrId];
590
- dispatch(self.dom, 'ln-store:reverted', { store: self._name, record: previous, action: 'update', error: fallbackErr });
591
- });
592
- },
593
- delete: () => {
594
- const saved = self._pendingSnapshots[tempIdOrId];
595
- if (!saved) return Promise.resolve();
596
- return _putRecord(self._name, saved).then(() => {
597
- self.totalCount++;
598
- delete self._pendingSnapshots[tempIdOrId];
599
- dispatch(self.dom, 'ln-store:reverted', { store: self._name, record: saved, action: 'delete', error: fallbackErr });
600
- });
601
- },
602
- 'bulk-delete': () => {
603
- const savedRecords = self._pendingSnapshots[tempIdOrId];
604
- if (!savedRecords || !savedRecords.length) return Promise.resolve();
605
- return _putBulk(self._name, savedRecords).then(() => {
606
- self.totalCount += savedRecords.length;
607
- delete self._pendingSnapshots[tempIdOrId];
608
- dispatch(self.dom, 'ln-store:reverted', { store: self._name, record: null, ids: tempIdOrId.split(','), action: 'bulk-delete', error: fallbackErr });
609
- });
610
- }
611
- };
612
- return handlers[action] ? handlers[action]() : Promise.resolve();
613
- };
614
-
615
- _component.prototype.resolveConflict = function (id, remoteRecord, fieldDiffs) {
616
- const previous = this._pendingSnapshots[id];
617
- if (!previous) return Promise.resolve();
618
-
619
- return _putRecord(this._name, previous).then(() => {
620
- delete this._pendingSnapshots[id];
621
- dispatch(this.dom, 'ln-store:conflict', {
622
- store: this._name,
623
- local: previous,
624
- remote: remoteRecord,
625
- field_diffs: fieldDiffs || null
626
- });
627
- });
628
- };
629
-
630
- // ─── Manual Triggers & Cleanup ─────────────────────────
631
-
632
- _component.prototype.forceSync = function () {
633
- _triggerRemoteSync(this);
634
- };
635
-
636
- _component.prototype.fullReload = function () {
637
- const self = this;
638
- return _clearStore(self._name).then(() => {
639
- self.isLoaded = false;
640
- self.lastSyncedAt = null;
641
- self.totalCount = 0;
642
- _triggerRemoteSync(self);
643
- });
644
- };
645
-
646
- _component.prototype.destroy = function () {
647
- if (this._handlers) {
648
- for (const [event, fn] of Object.entries(this._handlers)) {
649
- this.dom.removeEventListener(`ln-store:request-${event}`, fn);
650
- }
651
- this._handlers = null;
652
- }
653
-
654
- delete _stores[this._name];
655
-
656
- if (Object.keys(_stores).length === 0 && _visibilityHandler) {
657
- document.removeEventListener('visibilitychange', _visibilityHandler);
658
- _visibilityHandler = null;
659
- }
660
-
661
- delete this.dom[DOM_ATTRIBUTE];
662
- dispatch(this.dom, 'ln-store:destroyed', { store: this._name });
663
- };
664
-
665
- // ─── clearAll (global) ─────────────────────────────────
666
-
667
- function _clearAll() {
668
- return _getDb().then(db => {
669
- if (!db) return;
670
- const storeNames = Array.from(db.objectStoreNames);
671
- return new Promise((resolve, reject) => {
672
- const tx = db.transaction(storeNames, 'readwrite');
673
- storeNames.forEach(name => tx.objectStore(name).clear());
674
- tx.oncomplete = () => resolve();
675
- tx.onerror = () => reject(tx.error);
676
- });
677
- }).then(() => {
678
- Object.values(_stores).forEach(inst => {
679
- inst.isLoaded = false;
680
- inst.isSyncing = false;
681
- inst.lastSyncedAt = null;
682
- inst.totalCount = 0;
683
- });
684
- });
685
- }
686
-
687
- // ─── Registration ──────────────────────────────────────
688
-
689
- registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-data-store');
690
-
691
- window[DOM_ATTRIBUTE].clearAll = _clearAll;
692
- window[DOM_ATTRIBUTE].init = window[DOM_ATTRIBUTE];
693
- window[DOM_ATTRIBUTE].setStorageKey = setCryptoKey;
694
-
695
- if (typeof window !== 'undefined') {
696
- window.lnCore = window.lnCore || {};
697
- window.lnCore.setStorageKey = setCryptoKey;
698
- }
699
- })();
1
+ import { registerComponent, dispatch, setCryptoKey, getCryptoKey, encryptData, decryptData } from '../../ln-core';
2
+
3
+ (function () {
4
+ const DOM_SELECTOR = 'data-ln-data-store';
5
+ const DOM_ATTRIBUTE = 'lnDataStore';
6
+
7
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
8
+
9
+ const DB_NAME = 'ln_app_cache';
10
+ const META_STORE = '_meta';
11
+ const SCHEMA_VERSION = '1.0';
12
+
13
+ let _db = null;
14
+ let _dbReady = null;
15
+ const _stores = {};
16
+
17
+ function _checkQuota(err) {
18
+ if (err && err.name === 'QuotaExceededError') {
19
+ dispatch(document, 'ln-data-store:quota-exceeded', { error: err });
20
+ }
21
+ }
22
+
23
+ // ─── Database ──────────────────────────────────────────
24
+
25
+ function _getRequiredStores() {
26
+ const required = {};
27
+ for (const el of document.querySelectorAll(`[${DOM_SELECTOR}]`)) {
28
+ const name = el.getAttribute(DOM_SELECTOR);
29
+ if (name) {
30
+ const indexAttr = el.getAttribute('data-ln-data-store-indexes') || '';
31
+ required[name] = {
32
+ indexes: indexAttr.split(',').map(s => s.trim()).filter(Boolean)
33
+ };
34
+ }
35
+ }
36
+ return required;
37
+ }
38
+
39
+ function _openDatabase() {
40
+ if (_dbReady) return _dbReady;
41
+
42
+ _dbReady = new Promise(resolve => {
43
+ if (typeof indexedDB === 'undefined') {
44
+ console.warn('[ln-data-store] IndexedDB not available — falling back to in-memory store');
45
+ return resolve(null);
46
+ }
47
+
48
+ const required = _getRequiredStores();
49
+ const requiredNames = Object.keys(required);
50
+ const probe = indexedDB.open(DB_NAME);
51
+
52
+ probe.onerror = () => {
53
+ console.warn('[ln-data-store] IndexedDB open failed — falling back to in-memory store');
54
+ resolve(null);
55
+ };
56
+
57
+ probe.onsuccess = e => {
58
+ const db = e.target.result;
59
+ const existing = Array.from(db.objectStoreNames);
60
+ const needsUpgrade = !existing.includes(META_STORE) || requiredNames.some(name => !existing.includes(name));
61
+
62
+ if (!needsUpgrade) {
63
+ _setupVersionChangeHandler(db);
64
+ _db = db;
65
+ return resolve(db);
66
+ }
67
+
68
+ const currentVersion = db.version;
69
+ db.close();
70
+
71
+ const upgrade = indexedDB.open(DB_NAME, currentVersion + 1);
72
+
73
+ upgrade.onblocked = () => {
74
+ console.warn('[ln-data-store] Database upgrade blocked — waiting for other tabs to close connection');
75
+ };
76
+
77
+ upgrade.onerror = () => {
78
+ console.warn('[ln-data-store] Database upgrade failed');
79
+ resolve(null);
80
+ };
81
+
82
+ upgrade.onupgradeneeded = e => {
83
+ const db = e.target.result;
84
+ if (!db.objectStoreNames.contains(META_STORE)) {
85
+ db.createObjectStore(META_STORE, { keyPath: 'key' });
86
+ }
87
+ for (const storeName of requiredNames) {
88
+ if (!db.objectStoreNames.contains(storeName)) {
89
+ const store = db.createObjectStore(storeName, { keyPath: 'id' });
90
+ for (const idx of required[storeName].indexes) {
91
+ store.createIndex(idx, idx, { unique: false });
92
+ }
93
+ }
94
+ }
95
+ };
96
+
97
+ upgrade.onsuccess = e => {
98
+ const db = e.target.result;
99
+ _setupVersionChangeHandler(db);
100
+ _db = db;
101
+ resolve(db);
102
+ };
103
+ };
104
+ });
105
+
106
+ return _dbReady;
107
+ }
108
+
109
+ function _setupVersionChangeHandler(db) {
110
+ db.onversionchange = () => {
111
+ db.close();
112
+ _db = null;
113
+ _dbReady = null;
114
+ };
115
+ }
116
+
117
+ function _getDb() {
118
+ if (_db) return Promise.resolve(_db);
119
+ _dbReady = null;
120
+ return _openDatabase();
121
+ }
122
+
123
+ // ─── Cryptographic Wrappers (DRY using ln-core) ──────────
124
+
125
+ async function _encryptRecord(record) {
126
+ if (!getCryptoKey() || !record) return record;
127
+
128
+ // Isolate ID and metadata we want in plain text for IndexedDB queries
129
+ const plainRecord = { ...record };
130
+ const recordId = plainRecord.id;
131
+
132
+ // Encrypt payload using core helper
133
+ const encryptedPayload = await encryptData(plainRecord);
134
+ if (!encryptedPayload || !encryptedPayload.encrypted) return record;
135
+
136
+ return {
137
+ id: recordId,
138
+ encrypted: true,
139
+ iv: encryptedPayload.iv,
140
+ data: encryptedPayload.data
141
+ };
142
+ }
143
+
144
+ async function _decryptRecord(record) {
145
+ if (!record || !record.encrypted || !getCryptoKey()) return record;
146
+ return decryptData(record);
147
+ }
148
+
149
+ // ─── IndexedDB CRUD Helpers ────────────────────────────
150
+
151
+ const _tx = (storeName, mode) => _getDb().then(db => db ? db.transaction(storeName, mode).objectStore(storeName) : null);
152
+
153
+ function _idbRequest(request) {
154
+ return new Promise((resolve, reject) => {
155
+ request.onsuccess = () => resolve(request.result);
156
+ request.onerror = () => {
157
+ _checkQuota(request.error);
158
+ reject(request.error);
159
+ };
160
+ });
161
+ }
162
+
163
+ const _getAllRecords = storeName => _tx(storeName, 'readonly')
164
+ .then(store => store ? _idbRequest(store.getAll()) : [])
165
+ .then(records => getCryptoKey() ? Promise.all(records.map(r => _decryptRecord(r))) : records);
166
+
167
+ const _getRecord = (storeName, id) => _tx(storeName, 'readonly')
168
+ .then(store => store ? _idbRequest(store.get(id)) : null)
169
+ .then(record => record ? _decryptRecord(record) : null);
170
+
171
+ const _putRecord = (storeName, record) => {
172
+ const prepPromise = getCryptoKey() ? _encryptRecord(record) : Promise.resolve(record);
173
+ return prepPromise.then(prepped => _tx(storeName, 'readwrite').then(store => store ? _idbRequest(store.put(prepped)) : null));
174
+ };
175
+
176
+ const _deleteRecord = (storeName, id) => _tx(storeName, 'readwrite').then(store => store ? _idbRequest(store.delete(id)) : null);
177
+ const _clearStore = storeName => _tx(storeName, 'readwrite').then(store => store ? _idbRequest(store.clear()) : null);
178
+ const _countRecords = storeName => _tx(storeName, 'readonly').then(store => store ? _idbRequest(store.count()) : 0);
179
+
180
+ // ─── Meta Store ────────────────────────────────────────
181
+
182
+ const _getMeta = storeName => _tx(META_STORE, 'readonly').then(store => store ? _idbRequest(store.get(storeName)) : null);
183
+ const _setMeta = (storeName, data) => _tx(META_STORE, 'readwrite').then(store => {
184
+ if (!store) return;
185
+ data.key = storeName;
186
+ return _idbRequest(store.put(data));
187
+ });
188
+
189
+ // ─── Component Constructor ─────────────────────────────
190
+
191
+ function _component(dom) {
192
+ this.dom = dom;
193
+ this._name = dom.getAttribute(DOM_SELECTOR);
194
+
195
+ const staleAttr = dom.getAttribute('data-ln-data-store-stale');
196
+ const _parsed = parseInt(staleAttr, 10);
197
+ this._staleThreshold = (staleAttr === 'never' || staleAttr === '-1') ? -1 : (isNaN(_parsed) ? 300 : _parsed);
198
+
199
+ const searchAttr = dom.getAttribute('data-ln-data-store-search-fields') || '';
200
+ this._searchFields = searchAttr.split(',').map(s => s.trim()).filter(Boolean);
201
+
202
+ this._handlers = null;
203
+
204
+ this.isLoaded = false;
205
+ this.isInitialized = false;
206
+ this.initializationError = null;
207
+ this.hasCache = false;
208
+ this.isSyncing = false;
209
+ this.lastSyncedAt = null;
210
+ this.totalCount = 0;
211
+ this.presenters = null;
212
+ this._mutationChain = Promise.resolve();
213
+
214
+ _stores[this._name] = this;
215
+
216
+ _bindEvents(this);
217
+ this.ready = _initStore(this);
218
+ return this;
219
+ }
220
+
221
+ // ─── DOM Mutation Requests Listeners ────────────────────
222
+
223
+ function _bindEvents(self) {
224
+ self._handlers = {
225
+ 'create': e => _queueMutation(self, 'create', e.detail, () => _handleCreateRequest(self, e.detail)),
226
+ 'update': e => _queueMutation(self, 'update', e.detail, () => _handleUpdateRequest(self, e.detail)),
227
+ 'delete': e => _queueMutation(self, 'delete', e.detail, () => _handleDeleteRequest(self, e.detail)),
228
+ 'bulk-delete': e => _queueMutation(self, 'bulk-delete', e.detail, () => _handleBulkDeleteRequest(self, e.detail)),
229
+ 'sync-failed': e => {
230
+ self.isSyncing = false;
231
+ dispatch(self.dom, 'ln-data-store:sync-error', {
232
+ store: self._name,
233
+ error: e.detail && e.detail.error,
234
+ status: e.detail && e.detail.status
235
+ });
236
+ }
237
+ };
238
+ for (const [event, fn] of Object.entries(self._handlers)) {
239
+ self.dom.addEventListener(`ln-data-store:request-${event}`, fn);
240
+ }
241
+ }
242
+
243
+ function _queueMutation(self, action, detail, operation) {
244
+ const requestId = detail && detail.requestId;
245
+ self._mutationChain = self._mutationChain
246
+ .then(() => self.ready)
247
+ .then(() => {
248
+ if (self.initializationError) throw self.initializationError;
249
+ return operation();
250
+ })
251
+ .catch(error => _mutationError(self, action, requestId, error));
252
+ return self._mutationChain;
253
+ }
254
+
255
+ function _persistMutationMeta(self) {
256
+ return _countRecords(self._name).then(count => {
257
+ self.totalCount = count;
258
+ self.hasCache = true;
259
+ self.isLoaded = true;
260
+ return _setMeta(self._name, {
261
+ schema_version: SCHEMA_VERSION,
262
+ last_synced_at: self.lastSyncedAt,
263
+ has_cache: true,
264
+ record_count: count
265
+ });
266
+ });
267
+ }
268
+
269
+ // ─── Optimistic Writing Pipeline ─────────────────────────
270
+
271
+ function _handleCreateRequest(self, { tempId, data = {}, requestId } = {}) {
272
+ const record = { ...data, id: tempId };
273
+
274
+ return _putRecord(self._name, record).then(() => _persistMutationMeta(self)).then(() => {
275
+ dispatch(self.dom, 'ln-data-store:created', { store: self._name, record, tempId, requestId });
276
+ });
277
+ }
278
+
279
+ function _handleUpdateRequest(self, { id, data = {}, requestId } = {}) {
280
+ return _getRecord(self._name, id).then(existing => {
281
+ if (!existing) throw new Error(`Record not found: ${id}`);
282
+
283
+ const updated = { ...existing, ...data };
284
+ const newId = data.id;
285
+ const isRekey = newId !== undefined && newId !== id;
286
+
287
+ const write = isRekey
288
+ ? _rekeyRecord(self._name, id, updated)
289
+ : _putRecord(self._name, updated);
290
+
291
+ return write.then(() => _persistMutationMeta(self)).then(() => {
292
+ dispatch(self.dom, 'ln-data-store:updated', { store: self._name, record: updated, previous: existing, requestId });
293
+ });
294
+ });
295
+ }
296
+
297
+ function _handleDeleteRequest(self, { id, requestId } = {}) {
298
+ return _getRecord(self._name, id).then(existing => {
299
+ if (!existing) {
300
+ dispatch(self.dom, 'ln-data-store:deleted', { store: self._name, id, requestId, missing: true });
301
+ return;
302
+ }
303
+
304
+ return _deleteRecord(self._name, id).then(() => _persistMutationMeta(self)).then(() => {
305
+ dispatch(self.dom, 'ln-data-store:deleted', { store: self._name, id, requestId });
306
+ });
307
+ });
308
+ }
309
+
310
+ function _handleBulkDeleteRequest(self, { ids = [], requestId } = {}) {
311
+ if (!ids.length) {
312
+ dispatch(self.dom, 'ln-data-store:deleted', { store: self._name, ids: [], requestId });
313
+ return Promise.resolve();
314
+ }
315
+
316
+ return Promise.all(ids.map(id => _getRecord(self._name, id))).then(records => {
317
+ const savedIds = records.filter(Boolean).map(r => r.id);
318
+
319
+ return _deleteBulk(self._name, savedIds).then(() => _persistMutationMeta(self)).then(() => {
320
+ dispatch(self.dom, 'ln-data-store:deleted', { store: self._name, ids: savedIds, requestId });
321
+ });
322
+ });
323
+ }
324
+
325
+ function _mutationError(self, action, requestId, error) {
326
+ console.error('[ln-data-store] ' + action + ' failed:', error);
327
+ dispatch(self.dom, 'ln-data-store:mutation-error', {
328
+ store: self._name,
329
+ action,
330
+ requestId,
331
+ error
332
+ });
333
+ }
334
+
335
+ // ─── Initialization ────────────────────────────────────
336
+
337
+ function _initStore(self) {
338
+ return _openDatabase().then(db => {
339
+ if (!db) throw new Error('IndexedDB is unavailable');
340
+ return _getMeta(self._name);
341
+ }).then(meta => {
342
+ self.initializationError = null;
343
+ if (meta && meta.schema_version === SCHEMA_VERSION) {
344
+ self.lastSyncedAt = meta.last_synced_at || null;
345
+ self.totalCount = meta.record_count || 0;
346
+ self.hasCache = meta.has_cache === true || self.totalCount > 0;
347
+
348
+ if (self.hasCache) {
349
+ self.isLoaded = true;
350
+ dispatch(self.dom, 'ln-data-store:ready', { store: self._name, count: self.totalCount, source: 'cache' });
351
+ }
352
+
353
+ self.isInitialized = true;
354
+ dispatch(self.dom, 'ln-data-store:initialized', { store: self._name, hasCache: self.hasCache, lastSyncedAt: self.lastSyncedAt, count: self.totalCount });
355
+ } else if (meta && meta.schema_version !== SCHEMA_VERSION) {
356
+ return _clearStore(self._name)
357
+ .then(() => _setMeta(self._name, { schema_version: SCHEMA_VERSION, last_synced_at: null, has_cache: false, record_count: 0 }))
358
+ .then(() => {
359
+ self.isInitialized = true;
360
+ self.hasCache = false;
361
+ dispatch(self.dom, 'ln-data-store:initialized', { store: self._name, hasCache: false, lastSyncedAt: null, count: 0 });
362
+ });
363
+ } else {
364
+ self.isInitialized = true;
365
+ self.hasCache = false;
366
+ dispatch(self.dom, 'ln-data-store:initialized', { store: self._name, hasCache: false, lastSyncedAt: null, count: 0 });
367
+ }
368
+ }).catch(error => {
369
+ self.isInitialized = true;
370
+ self.isLoaded = false;
371
+ self.hasCache = false;
372
+ self.isSyncing = false;
373
+ self.initializationError = error;
374
+ dispatch(self.dom, 'ln-data-store:initialization-error', { store: self._name, error });
375
+ return { ok: false, error };
376
+ });
377
+ }
378
+
379
+ function _triggerRemoteSync(self) {
380
+ self.isSyncing = true;
381
+ dispatch(self.dom, 'ln-data-store:request-remote-sync', { since: self.lastSyncedAt });
382
+ }
383
+
384
+ // ─── Bulk IndexedDB Operations ─────────────────────────
385
+
386
+ function _putBulk(storeName, records) {
387
+ return _getDb().then(db => {
388
+ if (!db) return;
389
+
390
+ const prepPromise = getCryptoKey()
391
+ ? Promise.all(records.map(r => _encryptRecord(r)))
392
+ : Promise.resolve(records);
393
+
394
+ return prepPromise.then(preppedRecords => {
395
+ return new Promise((resolve, reject) => {
396
+ const tx = db.transaction(storeName, 'readwrite');
397
+ const store = tx.objectStore(storeName);
398
+ preppedRecords.forEach(r => store.put(r));
399
+ tx.oncomplete = () => resolve();
400
+ tx.onerror = () => {
401
+ _checkQuota(tx.error);
402
+ reject(tx.error);
403
+ };
404
+ });
405
+ });
406
+ });
407
+ }
408
+
409
+ function _deleteBulk(storeName, ids) {
410
+ return _getDb().then(db => {
411
+ if (!db) return;
412
+ return new Promise((resolve, reject) => {
413
+ const tx = db.transaction(storeName, 'readwrite');
414
+ const store = tx.objectStore(storeName);
415
+ ids.forEach(id => store.delete(id));
416
+ tx.oncomplete = () => resolve();
417
+ tx.onerror = () => reject(tx.error);
418
+ });
419
+ });
420
+ }
421
+
422
+ function _rekeyRecord(storeName, oldId, newRecord) {
423
+ const prepPromise = getCryptoKey() ? _encryptRecord(newRecord) : Promise.resolve(newRecord);
424
+ return prepPromise.then(prepped => _getDb().then(db => {
425
+ if (!db) return;
426
+ return new Promise((resolve, reject) => {
427
+ const tx = db.transaction(storeName, 'readwrite');
428
+ const store = tx.objectStore(storeName);
429
+ store.put(prepped);
430
+ store.delete(oldId);
431
+ tx.oncomplete = () => resolve();
432
+ tx.onerror = () => { _checkQuota(tx.error); reject(tx.error); };
433
+ });
434
+ }));
435
+ }
436
+
437
+ // ─── Query Engine (In-Memory over Cache) ───────────────
438
+
439
+ const _collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
440
+
441
+ function _sort(records, sort) {
442
+ if (!sort || !sort.field) return records;
443
+ const { field, direction } = sort;
444
+ const desc = direction === 'desc';
445
+
446
+ return [...records].sort((a, b) => {
447
+ const va = a[field];
448
+ const vb = b[field];
449
+
450
+ if (va == null && vb == null) return 0;
451
+ if (va == null) return desc ? 1 : -1;
452
+ if (vb == null) return desc ? -1 : 1;
453
+
454
+ const result = (typeof va === 'string' && typeof vb === 'string')
455
+ ? _collator.compare(va, vb)
456
+ : (va < vb ? -1 : va > vb ? 1 : 0);
457
+
458
+ return desc ? -result : result;
459
+ });
460
+ }
461
+
462
+ function _filter(records, filters) {
463
+ if (!filters) return records;
464
+ const keys = Object.keys(filters).filter(k => Array.isArray(filters[k]) && filters[k].length > 0);
465
+ if (!keys.length) return records;
466
+
467
+ return records.filter(record =>
468
+ keys.every(field => filters[field].map(String).includes(String(record[field])))
469
+ );
470
+ }
471
+
472
+ function _search(records, query, searchFields) {
473
+ if (!query || !searchFields || !searchFields.length) return records;
474
+ const lower = query.toLowerCase();
475
+ return records.filter(record =>
476
+ searchFields.some(field => {
477
+ const val = record[field];
478
+ return val != null && String(val).toLowerCase().includes(lower);
479
+ })
480
+ );
481
+ }
482
+
483
+ function _aggregate(records, field, fn) {
484
+ if (!records.length) return 0;
485
+ if (fn === 'count') return records.length;
486
+
487
+ const numbers = records.map(r => parseFloat(r[field])).filter(v => !isNaN(v));
488
+ const sum = numbers.reduce((a, b) => a + b, 0);
489
+
490
+ if (fn === 'sum') return sum;
491
+ if (fn === 'avg') return numbers.length ? sum / numbers.length : 0;
492
+ return 0;
493
+ }
494
+
495
+ function _decorate(self, records) {
496
+ if (!self.presenters || !self.presenters.computed) return records;
497
+ const computed = self.presenters.computed;
498
+ return records.map(record => {
499
+ const copy = { ...record };
500
+ for (const [fieldName, fn] of Object.entries(computed)) {
501
+ try {
502
+ copy[fieldName] = fn(record);
503
+ } catch (err) {
504
+ console.error(`[ln-data-store] Decorator computed field failed for ${fieldName}`, err);
505
+ }
506
+ }
507
+ return copy;
508
+ });
509
+ }
510
+
511
+ // ─── Public CRUD & Sync APIs ───────────────────────────
512
+
513
+ _component.prototype.getAll = function (options = {}) {
514
+ const self = this;
515
+ return _getAllRecords(self._name).then(records => {
516
+ const total = records.length;
517
+
518
+ if (options.filters) records = _filter(records, options.filters);
519
+ if (options.search) records = _search(records, options.search, self._searchFields);
520
+
521
+ const filtered = records.length;
522
+
523
+ if (options.sort) records = _sort(records, options.sort);
524
+
525
+ if (options.offset || options.limit) {
526
+ const offset = options.offset || 0;
527
+ const limit = options.limit || records.length;
528
+ records = records.slice(offset, offset + limit);
529
+ }
530
+
531
+ return {
532
+ data: _decorate(self, records),
533
+ total,
534
+ filtered
535
+ };
536
+ });
537
+ };
538
+
539
+ _component.prototype.getById = function (id) {
540
+ return _getRecord(this._name, id).then(record => record ? _decorate(this, [record])[0] : null);
541
+ };
542
+
543
+ _component.prototype.count = function (filters) {
544
+ return filters
545
+ ? _getAllRecords(this._name).then(records => _filter(records, filters).length)
546
+ : _countRecords(this._name);
547
+ };
548
+
549
+ _component.prototype.aggregate = function (field, fn) {
550
+ return _getAllRecords(this._name).then(records => _aggregate(records, field, fn));
551
+ };
552
+
553
+ _component.prototype.setPresenters = function (presenters) {
554
+ this.presenters = presenters;
555
+ };
556
+
557
+ // ─── Public Remote Response Synchronization Methods ────
558
+
559
+ _component.prototype.applySync = function (upsertedRecords, deletedIds, syncedAt, meta) {
560
+ meta = meta || {};
561
+ const self = this;
562
+
563
+ let chain = Promise.resolve();
564
+ if (upsertedRecords.length > 0) chain = chain.then(() => _putBulk(self._name, upsertedRecords));
565
+ if (deletedIds.length > 0) chain = chain.then(() => _deleteBulk(self._name, deletedIds));
566
+
567
+ return chain.then(() => _countRecords(self._name)).then(count => {
568
+ self.totalCount = meta.total !== undefined ? meta.total : count;
569
+ self.hasCache = true;
570
+ return _setMeta(self._name, {
571
+ schema_version: SCHEMA_VERSION,
572
+ last_synced_at: syncedAt,
573
+ has_cache: true,
574
+ record_count: self.totalCount
575
+ });
576
+ }).then(() => {
577
+ const isInitialLoad = !self.isLoaded;
578
+ self.isLoaded = true;
579
+ self.isSyncing = false;
580
+ self.lastSyncedAt = syncedAt;
581
+
582
+ if (isInitialLoad) {
583
+ dispatch(self.dom, 'ln-data-store:loaded', { store: self._name, count: self.totalCount, meta: meta });
584
+ dispatch(self.dom, 'ln-data-store:ready', { store: self._name, count: self.totalCount, source: 'server', meta: meta });
585
+ } else {
586
+ dispatch(self.dom, 'ln-data-store:synced', {
587
+ store: self._name,
588
+ added: upsertedRecords.length,
589
+ deleted: deletedIds.length,
590
+ changed: true,
591
+ meta: meta
592
+ });
593
+ }
594
+ }).catch(err => {
595
+ self.isSyncing = false;
596
+ console.error('[ln-data-store] applySync failed:', err);
597
+ });
598
+ };
599
+
600
+ _component.prototype.applyQuery = function (upsertedRecords, meta) {
601
+ meta = meta || {};
602
+ const self = this;
603
+
604
+ let chain = Promise.resolve();
605
+ if (upsertedRecords.length > 0) {
606
+ chain = chain.then(() => _putBulk(self._name, upsertedRecords));
607
+ }
608
+
609
+ return chain.then(() => _countRecords(self._name)).then(count => {
610
+ self.totalCount = meta.total !== undefined ? meta.total : count;
611
+ // record_count/has_cache stay untouched — a page fetch is not an
612
+ // authoritative cache; it would corrupt _isStale/storeInitialized sync gating.
613
+ return _decorate(self, upsertedRecords);
614
+ }).catch(err => {
615
+ console.error('[ln-data-store] applyQuery failed:', err);
616
+ return [];
617
+ });
618
+ };
619
+
620
+ // ─── Manual Triggers & Cleanup ─────────────────────────
621
+
622
+ _component.prototype.forceSync = function () {
623
+ if (this.isSyncing) return;
624
+ _triggerRemoteSync(this);
625
+ };
626
+
627
+ _component.prototype.fullReload = function () {
628
+ const self = this;
629
+ return _clearStore(self._name).then(() => _setMeta(self._name, {
630
+ schema_version: SCHEMA_VERSION,
631
+ last_synced_at: null,
632
+ has_cache: false,
633
+ record_count: 0
634
+ })).then(() => {
635
+ self.isLoaded = false;
636
+ self.hasCache = false;
637
+ self.lastSyncedAt = null;
638
+ self.totalCount = 0;
639
+ _triggerRemoteSync(self);
640
+ });
641
+ };
642
+
643
+ _component.prototype.destroy = function () {
644
+ if (this._handlers) {
645
+ for (const [event, fn] of Object.entries(this._handlers)) {
646
+ this.dom.removeEventListener(`ln-data-store:request-${event}`, fn);
647
+ }
648
+ this._handlers = null;
649
+ }
650
+
651
+ delete _stores[this._name];
652
+
653
+ delete this.dom[DOM_ATTRIBUTE];
654
+ dispatch(this.dom, 'ln-data-store:destroyed', { store: this._name });
655
+ };
656
+
657
+ // ─── clearAll (global) ─────────────────────────────────
658
+
659
+ function _clearAll() {
660
+ return _getDb().then(db => {
661
+ if (!db) return;
662
+ const storeNames = Array.from(db.objectStoreNames);
663
+ return new Promise((resolve, reject) => {
664
+ const tx = db.transaction(storeNames, 'readwrite');
665
+ storeNames.forEach(name => tx.objectStore(name).clear());
666
+ tx.oncomplete = () => resolve();
667
+ tx.onerror = () => reject(tx.error);
668
+ });
669
+ }).then(() => {
670
+ Object.values(_stores).forEach(inst => {
671
+ inst.isLoaded = false;
672
+ inst.isInitialized = false;
673
+ inst.initializationError = null;
674
+ inst.hasCache = false;
675
+ inst.isSyncing = false;
676
+ inst.lastSyncedAt = null;
677
+ inst.totalCount = 0;
678
+ });
679
+ });
680
+ }
681
+
682
+ // ─── Registration ──────────────────────────────────────
683
+
684
+ registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-data-store');
685
+
686
+ window[DOM_ATTRIBUTE].clearAll = _clearAll;
687
+ window[DOM_ATTRIBUTE].init = window[DOM_ATTRIBUTE];
688
+ window[DOM_ATTRIBUTE].setStorageKey = setCryptoKey;
689
+
690
+ if (typeof window !== 'undefined') {
691
+ window.lnCore = window.lnCore || {};
692
+ window.lnCore.setStorageKey = setCryptoKey;
693
+ }
694
+ })();