@livenetworks/ashlar 1.4.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 (219) 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 +189 -99
  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 -55
  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 -0
  173. package/scss/components/_form.scss +7 -0
  174. package/scss/components/_ln-table.scss +14 -5
  175. package/scss/components/_sort.scss +10 -0
  176. package/scss/components/_table.scss +68 -1
  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 +48 -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 +47 -0
  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 +4 -1
  198. package/scss/config/mixins/_ln-table.scss +51 -93
  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 +232 -38
  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 +39 -0
  209. package/scss/ln-ashlar.scss +8 -2
  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 -1103
  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 -168
  218. package/scss/components/_data-table.scss +0 -83
  219. package/scss/config/mixins/_data-table.scss +0 -346
@@ -0,0 +1,219 @@
1
+ # `data-ln-api-queue`
2
+
3
+ A zero-dependency, Local-First **offline write outbox** component that implements the persistent-queue tier of `ln-ashlar`'s 3-tier data layer.
4
+
5
+ This component persists pending mutations to its own `IndexedDB` database and replays them, FIFO per chain, once the coordinator can execute transport for them. It is **connector-blind and id-blind** — it never calls the network itself, never knows about REST paths, and never interprets `entryId`/`targetId`/`chainKey` beyond bookkeeping. The parent `ln-data-coordinator` executes the actual transport call on the queue's `ln-api-queue:send` command and reports the outcome back via `ack` / `nack`.
6
+
7
+ ---
8
+
9
+ ## Declarative DOM Setup
10
+
11
+ Place the queue inside your parent coordinator element alongside your store and connector — it is an **optional** third child:
12
+
13
+ ```html
14
+ <ul data-ln-data-coordinator="documents" hidden>
15
+ <!-- Storage Layer Cache (Blind to networking) -->
16
+ <li data-ln-data-store
17
+ data-ln-data-store-indexes="status,updated_at">
18
+ </li>
19
+
20
+ <!-- Transport Gateway (REST / API Connector) -->
21
+ <li data-ln-api-connector
22
+ data-ln-api-base-url="https://api.livenetworks.com/v1"
23
+ data-ln-api-path="/documents">
24
+ </li>
25
+
26
+ <!-- Offline Outbox (optional Child 3) -->
27
+ <li data-ln-api-queue></li>
28
+ </ul>
29
+ ```
30
+
31
+ When this child is absent, the coordinator's write handlers call the connector directly (unchanged, queue-absent path). When present, every mutation is routed through the queue instead.
32
+
33
+ ---
34
+
35
+ ## Attributes
36
+
37
+ | Attribute | Category | Description |
38
+ |-----------|----------|-------------|
39
+ | `data-ln-api-queue` | Selector | Creates the component instance. Optional value serves as a scope/name (defaults to the coordinator's scope). |
40
+ | `data-ln-api-queue-online` | Status (read-only, reflected) | Set by the component itself — `"true"` when the scope is draining/idle normally, `"false"` while paused (e.g. auth pause). Useful for CSS hooks on a connectivity indicator; not meant to be written by consumers. |
41
+
42
+ ---
43
+
44
+ ## IndexedDB Schema
45
+
46
+ The queue owns its **own** IndexedDB database, `ln_api_queue` — entirely separate from the store's cache database. Nothing in `ln-data-store` reads or writes this database.
47
+
48
+ | Object store | Key path | Indexes | Purpose |
49
+ |---|---|---|---|
50
+ | `outbox` | `entryId` | `by_scope_chain` (`[scope, chainKey]`), `by_scope_seq` (`[scope, seq]`) | One row per pending mutation. `seq` is a monotonically increasing per-scope counter used to preserve global FIFO ordering within a scope; `by_scope_chain` is used to find the head entry per chain. |
51
+ | `_queue_meta` | `key` | — | Holds `seq:<scope>` counters and `paused:<scope>` auth-pause state. |
52
+
53
+ Each `outbox` entry carries: `entryId`, `scope`, `chainKey`, `seq`, `op`, `targetId`, `payload`, `expectedVersion`, `meta`, `attempts`, `status` (`pending` / `inflight` / `failed`), `leaseOwner`, `leaseUntil`, and timestamps. The queue never inspects `payload` — it passes it through untouched to whatever the coordinator sends on `ln-api-queue:send`.
54
+
55
+ Sequence allocation and entry insertion happen in one `readwrite` transaction
56
+ covering both `_queue_meta` and `outbox`. Concurrent enqueues therefore cannot
57
+ receive the same sequence number.
58
+
59
+ ---
60
+
61
+ ## JavaScript API Methods
62
+
63
+ Access the instance directly on the element via the `.lnApiQueue` property:
64
+
65
+ ```javascript
66
+ const queueEl = document.querySelector('[data-ln-api-queue]');
67
+ const queue = queueEl.lnApiQueue;
68
+
69
+ // Programmatic equivalents of the DOM commands below also exist,
70
+ // but the canonical interface is event-driven (see next section) —
71
+ // this keeps the coordinator's write-routing code identical whether
72
+ // it is reacting to a DOM event or calling a method directly.
73
+ ```
74
+
75
+ ---
76
+
77
+ ## DOM Events
78
+
79
+ ### Commands (Dispatched TO the queue)
80
+
81
+ | Event | `detail` Payload | Effect |
82
+ |-------|------------------|--------|
83
+ | `ln-api-queue:request-enqueue` | `{ chainKey, op, targetId, payload, expectedVersion, meta }` | Assigns `entryId` + `seq`, persists the entry, emits `enqueued` + `pending-count`, then attempts a drain. |
84
+ | `ln-api-queue:ack` | `{ entryId }` | Deletes the entry, emits `pending-count`, advances that chain (drain). |
85
+ | `ln-api-queue:nack` | `{ entryId, reason }` — `reason` is `'retry'`, `'drop'`, or `'auth'` | `retry` → schedules backoff and re-sends later; `drop` → deletes the entry and advances the chain; `auth` → pauses the scope and emits `auth-required`. |
86
+ | `ln-api-queue:request-remap` | `{ oldKey, newId }` | For pending entries whose chain is `oldKey`: any entry with `targetId === oldKey` gets `targetId = newId`; the chain itself is re-keyed from `oldKey` to `newId`; if `meta.action` contains `oldKey` as a substring, it is string-replaced with `newId` too (keeps a persisted per-record URL in sync after a create resolves). |
87
+ | `ln-api-queue:resolve-create` | `{ entryId, oldKey, newId }` | Atomically deletes the acknowledged create and remaps every queued sibling from the temp key to the authoritative id in one transaction, then advances the chain. |
88
+ | `ln-api-queue:request-resume` | `{}` | Clears the pause on this scope and resumes draining. |
89
+ | `ln-api-queue:request-drain` | `{}` | Manually triggers a drain attempt (e.g. to retry `failed` entries). |
90
+ | `ln-api-queue:request-clear` | `{}` | Deletes all entries for this scope (e.g. on logout). |
91
+
92
+ ### Notifications (Emitted BY the queue, bubble to the coordinator)
93
+
94
+ | Event | `detail` Payload | Meaning |
95
+ |-------|------------------|---------|
96
+ | `ln-api-queue:send` | `{ entryId, chainKey, op, targetId, payload, expectedVersion, idempotencyKey, meta }` | **Command to the coordinator** — execute transport for this head-of-chain entry. `idempotencyKey` is the stable `entryId`. The queue never calls `fetch` itself. |
97
+ | `ln-api-queue:enqueued` | `{ entryId, chainKey, count }` | An entry was persisted. |
98
+ | `ln-api-queue:pending-count` | `{ count, scope }` | Live outbox depth for the scope — the primary UI-facing signal (badge, banner). |
99
+ | `ln-api-queue:auth-required` | `{ entryId, chainKey }` | The scope paused after a `401`/`419` nack. The consumer drives re-authentication, then dispatches `request-resume`. |
100
+ | `ln-api-queue:paused` | `{ reason }` | Draining paused for this scope. |
101
+ | `ln-api-queue:resumed` | `{}` | Draining resumed for this scope. |
102
+ | `ln-api-queue:failed` | `{ entryId, chainKey, attempts }` | Retries exhausted (see backoff below); the entry is retained (not deleted) for a manual `request-drain`. |
103
+ | `ln-api-queue:drained` | `{ scope }` | The outbox for this scope is empty. |
104
+ | `ln-api-queue:error` | `{ operation, entryId?, error }` | IndexedDB or queue orchestration failed without silently discarding the entry. |
105
+ | `ln-api-queue:destroyed` | `{ scope }` | The instance was torn down. |
106
+
107
+ ---
108
+
109
+ ## FIFO-per-Chain Semantics
110
+
111
+ Entries are ordered **per `(scope, chainKey)`**, not globally. Each chain drains strictly in the order its entries were enqueued (via the `seq` counter), and the queue keeps **one inflight entry per chain** at a time — it will not dispatch `ln-api-queue:send` for the next entry in a chain until the current head is `ack`'d or `nack`'d with `'drop'`. A `failed` head blocks newer siblings until an explicit `request-drain` resets it. Different chains drain concurrently and independently of each other.
112
+
113
+ `chainKey` is typically the record id (or temp id, pre-confirmation) — this is what makes a create followed by an update on the same not-yet-confirmed record safe: the update waits behind the create in the same chain, and `request-remap` re-keys the chain once the create resolves.
114
+
115
+ Claiming is transactional. A ready head is changed from `pending` to
116
+ `inflight` in the same IndexedDB transaction that selects it. The claim carries
117
+ a 60-second lease; another queue instance or a repeated drain sees the lease
118
+ and cannot dispatch the same entry. If a tab crashes, the entry becomes
119
+ claimable again after `leaseUntil`.
120
+
121
+ Claim safety also applies when multiple tabs overlap: IndexedDB read/write
122
+ transactions serialize claims against the shared database, so mutual exclusion
123
+ does not depend on `BroadcastChannel`. `leaseOwner` identifies the queue
124
+ instance that owns the current claim; `leaseUntil` provides crash recovery.
125
+ This is not a cross-tab worker-pool contract: each instance immediately drains
126
+ its own enqueues, while another already-open tab may discover work only on an
127
+ existing drain trigger. Cross-tab messaging may be added later as a discovery
128
+ latency optimization, but it is not the correctness mechanism.
129
+
130
+ ---
131
+
132
+ ## Drain-on-Init
133
+
134
+ On construction, the queue restores the persisted per-scope auth pause and
135
+ attempts to drain every unpaused chain. Pending writes from a previous page
136
+ load resume automatically. A persisted `inflight` entry is not duplicated
137
+ while its lease is active and is recovered when the lease expires.
138
+
139
+ ---
140
+
141
+ ## Backoff & Retry Limits
142
+
143
+ On a `nack {reason:'retry'}` (5xx or network failure), the entry is retried with exponential backoff: **2s, 5s, 15s, 60s, 5min**. After **8 attempts** the entry's status becomes `'failed'` and `ln-api-queue:failed` fires — the entry is **retained**, not deleted, so a manual `ln-api-queue:request-drain` (or a UI-driven "retry failed" action) can attempt it again later.
144
+
145
+ ---
146
+
147
+ ## Auth Pause / Resume
148
+
149
+ A `nack {reason:'auth'}` (401/419) pauses the **entire scope** — not just the one chain — and emits `ln-api-queue:auth-required`. The optimistic write is **not** reverted; it stays pending in the cache. The consumer is expected to drive re-authentication (e.g. redirect to login, refresh a token) and then dispatch `ln-api-queue:request-resume` on the queue element to clear the pause and resume draining.
150
+
151
+ ---
152
+
153
+ ## Temp-ID Remap Collaboration
154
+
155
+ The queue itself has no idea what a "temp id" is — remap is purely a re-keying operation the coordinator asks for. On a successful `create`, the coordinator:
156
+
157
+ 1. Reconciles the store with an ordinary correlated `request-update`.
158
+ 2. Waits for `ln-data-store:updated` to confirm that the id rekey committed.
159
+ 3. Dispatches one `ln-api-queue:resolve-create { entryId, oldKey: tempId, newId: serverId }`.
160
+
161
+ The compound command removes the old async remap/ack race: the acknowledged
162
+ create is deleted and every waiting sibling is re-keyed before the transaction
163
+ commits or the chain advances.
164
+
165
+ ---
166
+
167
+ ## Connector- and ID-Blind by Design
168
+
169
+ The queue never imports or references a connector, never constructs a URL, and never distinguishes a temp id from a real id except as an opaque `chainKey`/`targetId` string it is told to match and re-key. All transport execution, id semantics, and REST/verb mapping live in the coordinator (`ln-data-coordinator`) and the connector it drives — see their READMEs for that half of the contract.
170
+
171
+ ---
172
+
173
+ ## 3-Tier Integration Architecture (Coordinator Example)
174
+
175
+ ```javascript
176
+ (function () {
177
+ const parent = document.querySelector('[data-ln-data-coordinator="documents"]');
178
+ const queueEl = parent.querySelector('[data-ln-api-queue]');
179
+ const connectorEl = parent.querySelector('[data-ln-api-connector]');
180
+
181
+ if (!queueEl || !connectorEl) return;
182
+
183
+ // The queue commands the coordinator to execute transport for its head entry.
184
+ queueEl.addEventListener('ln-api-queue:send', function (e) {
185
+ const { entryId, op, targetId, payload, expectedVersion, idempotencyKey, meta } = e.detail;
186
+
187
+ const call = op === 'create' ? connectorEl.lnConnector.create(payload)
188
+ : op === 'update' ? connectorEl.lnConnector.update(targetId, payload, expectedVersion)
189
+ : op === 'delete' ? connectorEl.lnConnector.delete(targetId)
190
+ : connectorEl.lnConnector.bulkDelete(meta.ids);
191
+
192
+ call.then(function (record) {
193
+ // For create: wait for the correlated store rekey, then dispatch
194
+ // resolve-create. Other operations use ack.
195
+ const eventName = op === 'create' ? 'ln-api-queue:resolve-create' : 'ln-api-queue:ack';
196
+ const detail = op === 'create'
197
+ ? { entryId, oldKey: meta.tempId, newId: record.id }
198
+ : { entryId };
199
+ queueEl.dispatchEvent(new CustomEvent(eventName, { detail }));
200
+ }).catch(function (err) {
201
+ const reason = (err.status === 401 || err.status === 419) ? 'auth'
202
+ : (!err.status || err.status >= 500) ? 'retry'
203
+ : 'drop';
204
+ queueEl.dispatchEvent(new CustomEvent('ln-api-queue:nack', { detail: { entryId, reason } }));
205
+ });
206
+ });
207
+
208
+ // Surface outbox depth for a UI badge.
209
+ queueEl.addEventListener('ln-api-queue:pending-count', function (e) {
210
+ document.querySelector('#pending-badge').textContent = e.detail.count;
211
+ });
212
+
213
+ // Drive re-login on auth pause, then resume.
214
+ queueEl.addEventListener('ln-api-queue:auth-required', function () {
215
+ // redirect / refresh token, then:
216
+ queueEl.dispatchEvent(new CustomEvent('ln-api-queue:request-resume'));
217
+ });
218
+ })();
219
+ ```
@@ -0,0 +1 @@
1
+ (function(){"use strict";if(typeof window<"u"){const r=console.warn;console.warn=function(...t){typeof t[0]=="string"&&(t[0].startsWith("[ln-")||t[0].startsWith("[lnCore"))&&!(document.documentElement.hasAttribute("data-ln-debug")||document.body&&document.body.hasAttribute("data-ln-debug"))||r.apply(console,t)}}const E={};function N(r,t){E[r]||(E[r]=document.querySelector('[data-ln-template="'+r+'"]'));const s=E[r];return s?s.content.cloneNode(!0):(console.warn("["+(t||"ln-core")+'] Template "'+r+'" not found'),null)}function y(r,t,s){r.dispatchEvent(new CustomEvent(t,{bubbles:!0,detail:s||{}}))}function S(r,t){if(!r||!t)return r;const s=r.querySelectorAll("[data-ln-field]");for(let o=0;o<s.length;o++){const i=s[o],d=i.getAttribute("data-ln-field");t[d]!=null&&(i.textContent=t[d])}const l=r.querySelectorAll("[data-ln-attr]");for(let o=0;o<l.length;o++){const i=l[o],d=i.getAttribute("data-ln-attr").split(",");for(let e=0;e<d.length;e++){const n=d[e].trim().split(":");if(n.length!==2)continue;const a=n[0].trim(),f=n[1].trim();t[f]!=null&&i.setAttribute(a,t[f])}}const c=r.querySelectorAll("[data-ln-show]");for(let o=0;o<c.length;o++){const i=c[o],d=i.getAttribute("data-ln-show");d in t&&i.classList.toggle("hidden",!t[d])}const u=r.querySelectorAll("[data-ln-class]");for(let o=0;o<u.length;o++){const i=u[o],d=i.getAttribute("data-ln-class").split(",");for(let e=0;e<d.length;e++){const n=d[e].trim().split(":");if(n.length!==2)continue;const a=n[0].trim(),f=n[1].trim();f in t&&i.classList.toggle(a,!!t[f])}}return r}function U(r,t){r.matches&&r.matches("[data-ln-form], [data-ln-fillable]")&&r.dispatchEvent(new CustomEvent("ln-fill",{detail:t??null,bubbles:!0}));const s=r.querySelectorAll("[data-ln-form], [data-ln-fillable]");for(let l=0;l<s.length;l++)s[l].dispatchEvent(new CustomEvent("ln-fill",{detail:t??null,bubbles:!0}));return r}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore._fillBound||(window.lnCore._fillBound=!0,document.addEventListener("ln-fill",function(r){if(!(!r.target.matches||!r.target.matches("[data-ln-fillable]")))if(r.detail)S(r.target,r.detail);else{const t=r.target.querySelectorAll("[data-ln-field]");for(let s=0;s<t.length;s++)t[s].textContent=""}})));function D(r,t){if(!r||!t)return r;const s=document.createTreeWalker(r,NodeFilter.SHOW_TEXT);for(;s.nextNode();){const u=s.currentNode;u.textContent.indexOf("{{")!==-1&&(u.textContent=u.textContent.replace(/\{\{\s*(\w+)\s*\}\}/g,function(o,i){return t[i]!==void 0?t[i]:""}))}const l=function(u,o){return t[o]!==void 0?t[o]:""},c=Array.from(r.querySelectorAll("*"));r.nodeType===1&&c.push(r);for(let u=0;u<c.length;u++){const o=c[u],i=o.attributes;for(let d=0;d<i.length;d++){const e=i[d];e.value.indexOf("{{")!==-1&&o.setAttribute(e.name,e.value.replace(/\{\{\s*(\w+)\s*\}\}/g,l))}}return r}function F(r,t,s,l,c,u){const o={};for(let d=0;d<r.children.length;d++){const e=r.children[d],n=e.getAttribute("data-ln-key");n&&(o[n]=e)}const i=document.createDocumentFragment();for(let d=0;d<t.length;d++){const e=t[d],n=String(l(e));let a=o[n];if(a)c(a,e,d);else{const f=N(s,u);if(!f||(D(f,e),a=f.firstElementChild,!a))continue;a.setAttribute("data-ln-key",n),c(a,e,d)}i.appendChild(a)}r.textContent="",r.appendChild(i)}function P(r,t){if(!document.body){document.addEventListener("DOMContentLoaded",function(){P(r,t)}),console.warn("["+t+'] Script loaded before <body> — add "defer" to your <script> tag');return}r()}function v(r,t,s,l){if(r.nodeType!==1)return;const u=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",o=Array.from(r.querySelectorAll(u));r.matches&&r.matches(u)&&o.push(r);for(const i of o)i[s]||(i[s]=new l(i))}function j(r,t,s,l,c={}){const u=c.extraAttributes||[],o=c.onAttributeChange||null,i=c.onInit||null;function d(e){const n=e||document.body;v(n,r,t,s),i&&i(n)}return P(function(){const e=new MutationObserver(function(a){for(let f=0;f<a.length;f++){const h=a[f];if(h.type==="childList"){for(let m=0;m<h.addedNodes.length;m++){const p=h.addedNodes[m];p.nodeType===1&&(v(p,r,t,s),i&&i(p))}for(let m=0;m<h.removedNodes.length;m++){const p=h.removedNodes[m];if(p.nodeType===1){const A=r.indexOf("[")!==-1||r.indexOf(".")!==-1||r.indexOf("#")!==-1?r:"["+r+"]",C=Array.from(p.querySelectorAll(A));p.matches&&p.matches(A)&&C.push(p);for(let I=0;I<C.length;I++){const M=C[I];if(!document.contains(M)){const L=M[t];L&&typeof L.destroy=="function"&&L.destroy()}}}}}else h.type==="attributes"&&(o&&h.target[t]?o(h.target,h.attributeName):(v(h.target,r,t,s),i&&i(h.target)))}});let n=[];if(r.indexOf("[")!==-1){const a=/\[([\w-]+)/g;let f;for(;(f=a.exec(r))!==null;)n.push(f[1])}else n.push(r);e.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:n.concat(u)})},l),window[t]=d,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){d(document.body)}):d(document.body),d}const T={};function W(r,t){T[r]=t}function V(r){return T[r]||{ingress:t=>t,egress:t=>t}}const Q={};function O(r,t){if(!r||typeof t!="object")return;const s=r.toLowerCase().split("-")[0];Q[s]=t}function H(r){if(!r)return null;const t=r.toLowerCase().split("-")[0];return Q[t]||null}O("mk",{monthsLong:["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],monthsShort:["јан","фев","мар","апр","мај","јун","јул","авг","септ","окт","ноем","дек"],daysLong:["недела","понеделник","вторник","среда","четврток","петок","сабота"],daysShort:["нед","пон","вт","ср","чет","пет","саб"]}),typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=W,window.lnCore.getDataMapper=V,window.lnCore.registerLocaleFallback=O,window.lnCore.getLocaleFallback=H,window.lnCore.fillTemplate=D,window.lnCore.fill=S,window.lnCore.lnFill=U,window.lnCore.renderList=F);function R(r){return(r||"").replace(/^#/,"")}function k(r){const t=r===void 0?location.hash:r,s={},l=R(t);if(!l)return s;const c=l.split("&");for(let u=0;u<c.length;u++){const o=c[u];if(!o)continue;const i=o.indexOf(":"),d=i>-1?o.slice(0,i):o,e=i>-1?o.slice(i+1):"";if(d)try{s[d]=decodeURIComponent(e)}catch{s[d]=e}}return s}function X(r){if(!r)return null;const t=k();return r in t?t[r]:null}function z(r,t){if(!r)return;const s=k();t==null?delete s[r]:s[r]=String(t);const c=Object.keys(s).map(function(u){const o=s[u];return o===""?u:u+":"+encodeURIComponent(o)}).join("&");R(location.hash)!==c&&(location.hash=c)}function G(r){return r.button===1||r.ctrlKey||r.metaKey||r.shiftKey?!1:(r.preventDefault(),!0)}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.hashParse=k,window.lnCore.hashGet=X,window.lnCore.hashSet=z,window.lnCore.hashLinkClick=G);const J="ln_api_queue",Y=2,_="outbox",w="_queue_meta";function g(r,t){return r.error||new Error(t)}function b(r,t){return r.bound([t,-1/0],[t,1/0])}function K(r){return"seq:"+r}function q(r){return"paused:"+r}function B(r){r.leaseOwner=null,r.leaseUntil=0}function Z(r,t,s){return typeof r!="string"||r.indexOf(t)===-1?r:r.split(t).join(s)}function $(r,t,s,l){const c=new Map,u=[],o=[];for(const i of r||[])c.has(i.chainKey)||c.set(i.chainKey,[]),c.get(i.chainKey).push(i);return c.forEach((i,d)=>{i.sort((n,a)=>n.seq-a.seq);const e=i[0];if(!(!e||e.status==="failed")){if(e.status==="inflight"&&(e.leaseUntil||0)>l){o.push({chainKey:d,at:e.leaseUntil});return}if((e.nextAttemptAt||0)>l){o.push({chainKey:d,at:e.nextAttemptAt});return}e.status="inflight",e.leaseOwner=t,e.leaseUntil=l+s,e.updatedAt=l,u.push(e)}}),{entries:u,wakeups:o}}function ee(r,t,s,l,c){const u=[],o=[];for(const i of r||[]){if(i.entryId===t){o.push(i.entryId);continue}i.chainKey===s&&(i.chainKey=l,i.targetId===s&&(i.targetId=l),i.meta&&i.meta.id===s&&(i.meta.id=l),i.meta&&typeof i.meta.action=="string"&&(i.meta.action=Z(i.meta.action,s,l)),i.updatedAt=c,u.push(i))}return{changed:u,deleted:o}}class te{constructor(t){t=t||{},this.indexedDB=t.indexedDB||globalThis.indexedDB,this.keyRange=t.IDBKeyRange||globalThis.IDBKeyRange,this.dbName=t.dbName||J,this.now=t.now||(()=>Date.now()),this.uuid=t.uuid||(()=>crypto.randomUUID()),this._db=null,this._ready=null}open(){return this._ready?this._ready:!this.indexedDB||!this.keyRange?Promise.resolve(null):(this._ready=new Promise((t,s)=>{const l=this.indexedDB.open(this.dbName,Y);l.onupgradeneeded=c=>{const u=c.target.result;let o;u.objectStoreNames.contains(_)?o=c.target.transaction.objectStore(_):o=u.createObjectStore(_,{keyPath:"entryId"}),o.indexNames.contains("by_scope_chain")||o.createIndex("by_scope_chain",["scope","chainKey"],{unique:!1}),o.indexNames.contains("by_scope_seq")||o.createIndex("by_scope_seq",["scope","seq"],{unique:!1}),u.objectStoreNames.contains(w)||u.createObjectStore(w,{keyPath:"key"})},l.onerror=()=>s(g(l,"Queue database open failed")),l.onsuccess=c=>{this._db=c.target.result,this._db.onversionchange=()=>this.close(),t(this._db)}}),this._ready)}close(){this._db&&this._db.close(),this._db=null,this._ready=null}deleteDatabase(){return this.close(),this.indexedDB?new Promise((t,s)=>{const l=this.indexedDB.deleteDatabase(this.dbName);l.onsuccess=()=>t(),l.onerror=()=>s(g(l,"Queue database delete failed")),l.onblocked=()=>s(new Error("Queue database delete blocked"))}):Promise.resolve()}allForScope(t){return this.open().then(s=>s?new Promise((l,c)=>{const o=s.transaction(_,"readonly").objectStore(_).index("by_scope_seq").getAll(b(this.keyRange,t));o.onsuccess=()=>l(o.result||[]),o.onerror=()=>c(g(o,"Queue scope read failed"))}):[])}enqueue(t,s){return s=s||{},this.open().then(l=>l?new Promise((c,u)=>{const o=l.transaction([w,_],"readwrite"),i=o.objectStore(w),d=o.objectStore(_),e=K(t);let n=null;const a=h=>{const m=h+1;n={entryId:this.uuid(),scope:t,chainKey:s.chainKey,seq:m,op:s.op,targetId:s.targetId!==void 0?s.targetId:null,payload:s.payload,expectedVersion:s.expectedVersion!==void 0?s.expectedVersion:null,meta:s.meta||{},attempts:0,nextAttemptAt:0,status:"pending",leaseOwner:null,leaseUntil:0,createdAt:this.now(),updatedAt:this.now()},i.put({key:e,value:m}),d.put(n)},f=i.get(e);f.onerror=()=>u(g(f,"Queue sequence read failed")),f.onsuccess=()=>{const h=f.result;if(h&&typeof h.value=="number"){a(h.value);return}const m=d.index("by_scope_seq").getAll(b(this.keyRange,t));m.onerror=()=>u(g(m,"Queue sequence migration failed")),m.onsuccess=()=>{const p=(m.result||[]).reduce((x,A)=>Math.max(x,A.seq||0),0);a(p)}},o.oncomplete=()=>c(n),o.onerror=()=>u(o.error||new Error("Queue enqueue transaction failed")),o.onabort=()=>u(o.error||new Error("Queue enqueue transaction aborted"))}):null)}claimReady(t,s,l){return this.open().then(c=>c?new Promise((u,o)=>{const i=c.transaction(_,"readwrite"),d=i.objectStore(_),e=d.index("by_scope_seq").getAll(b(this.keyRange,t)),n=this.now();let a={entries:[],wakeups:[]};e.onerror=()=>o(g(e,"Queue claim read failed")),e.onsuccess=()=>{a=$(e.result||[],s,l,n);for(const f of a.entries)d.put(f)},i.oncomplete=()=>u(a),i.onerror=()=>o(i.error||new Error("Queue claim transaction failed")),i.onabort=()=>o(i.error||new Error("Queue claim transaction aborted"))}):{entries:[],wakeups:[]})}ack(t,s){return this._updateEntry(t,s,(l,c)=>(c.delete(l.entryId),{status:"acked",entry:l}))}nack(t,s,l,c){c=c||{};const u=c.maxAttempts||8,o=c.backoff||[2e3,5e3,15e3,6e4,3e5];return this.open().then(i=>i?new Promise((d,e)=>{const n=i.transaction([_,w],"readwrite"),a=n.objectStore(_),f=n.objectStore(w),h=a.get(s);let m=null;h.onerror=()=>e(g(h,"Queue nack read failed")),h.onsuccess=()=>{const p=h.result;if(!(!p||p.scope!==t)){if(l==="drop"){a.delete(p.entryId),m={status:"dropped",entry:p};return}if(B(p),p.updatedAt=this.now(),l==="auth"){p.status="pending",a.put(p),f.put({key:q(t),value:!0}),m={status:"auth",entry:p};return}if(l==="retry"){if(p.attempts=(p.attempts||0)+1,p.attempts>=u){p.status="failed",p.nextAttemptAt=0,a.put(p),m={status:"failed",entry:p};return}const x=o[Math.min(p.attempts-1,o.length-1)];p.status="pending",p.nextAttemptAt=this.now()+x,a.put(p),m={status:"retry",entry:p,delay:x}}}},n.oncomplete=()=>d(m),n.onerror=()=>e(n.error||new Error("Queue nack transaction failed")),n.onabort=()=>e(n.error||new Error("Queue nack transaction aborted"))}):null)}remap(t,s,l){return this._remapTransaction(t,null,s,l)}resolveCreate(t,s,l,c){return this._remapTransaction(t,s,l,c)}_remapTransaction(t,s,l,c){return this.open().then(u=>u?new Promise((o,i)=>{const d=u.transaction(_,"readwrite"),e=d.objectStore(_),n=e.index("by_scope_seq").getAll(b(this.keyRange,t));let a={changed:[],deleted:[]};n.onerror=()=>i(g(n,"Queue remap read failed")),n.onsuccess=()=>{a=ee(n.result||[],s,l,c,this.now());for(const f of a.deleted)e.delete(f);for(const f of a.changed)e.put(f)},d.oncomplete=()=>o(a.changed),d.onerror=()=>i(d.error||new Error("Queue remap transaction failed")),d.onabort=()=>i(d.error||new Error("Queue remap transaction aborted"))}):[])}resetFailed(t){return this.open().then(s=>s?new Promise((l,c)=>{const u=s.transaction(_,"readwrite"),o=u.objectStore(_),i=o.index("by_scope_seq").getAll(b(this.keyRange,t));let d=0;i.onerror=()=>c(g(i,"Queue failed-entry read failed")),i.onsuccess=()=>{for(const e of i.result||[])e.status==="failed"&&(e.status="pending",e.attempts=0,e.nextAttemptAt=0,e.updatedAt=this.now(),B(e),o.put(e),d++)},u.oncomplete=()=>l(d),u.onerror=()=>c(u.error||new Error("Queue failed-entry reset failed")),u.onabort=()=>c(u.error||new Error("Queue failed-entry reset aborted"))}):0)}getPaused(t){return this.open().then(s=>s?new Promise((l,c)=>{const o=s.transaction(w,"readonly").objectStore(w).get(q(t));o.onsuccess=()=>l(!!(o.result&&o.result.value)),o.onerror=()=>c(g(o,"Queue pause-state read failed"))}):!1)}setPaused(t,s){return this.open().then(l=>{if(l)return new Promise((c,u)=>{const o=l.transaction(w,"readwrite");o.objectStore(w).put({key:q(t),value:!!s}),o.oncomplete=()=>c(),o.onerror=()=>u(o.error||new Error("Queue pause-state write failed")),o.onabort=()=>u(o.error||new Error("Queue pause-state write aborted"))})})}clear(t){return this.open().then(s=>{if(s)return new Promise((l,c)=>{const u=s.transaction([_,w],"readwrite"),i=u.objectStore(_).index("by_scope_seq").openCursor(b(this.keyRange,t));i.onsuccess=d=>{const e=d.target.result;e&&(e.delete(),e.continue())},i.onerror=()=>c(g(i,"Queue clear failed")),u.objectStore(w).delete(K(t)),u.objectStore(w).delete(q(t)),u.oncomplete=()=>l(),u.onerror=()=>c(u.error||new Error("Queue clear transaction failed")),u.onabort=()=>c(u.error||new Error("Queue clear transaction aborted"))})})}_updateEntry(t,s,l){return this.open().then(c=>c?new Promise((u,o)=>{const i=c.transaction(_,"readwrite"),d=i.objectStore(_),e=d.get(s);let n=null;e.onerror=()=>o(g(e,"Queue entry read failed")),e.onsuccess=()=>{const a=e.result;!a||a.scope!==t||(n=l(a,d))},i.oncomplete=()=>u(n),i.onerror=()=>o(i.error||new Error("Queue entry transaction failed")),i.onabort=()=>o(i.error||new Error("Queue entry transaction aborted"))}):null)}}(function(){const r="data-ln-api-queue",t="lnApiQueue",s=[2e3,5e3,15e3,6e4,3e5],l=8,c=6e4;if(window[t]!==void 0)return;function u(){try{return crypto.randomUUID()}catch{return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,n=>{const a=Math.random()*16|0;return(n==="x"?a:a&3|8).toString(16)})}}const o=new te({indexedDB:window.indexedDB,IDBKeyRange:window.IDBKeyRange,uuid:u});function i(e){this.dom=e,e[t]=this;const n=e.closest("[data-ln-data-coordinator]");this.scope=e.getAttribute(r)||(n?n.getAttribute("data-ln-data-coordinator"):null)||"default",this._paused=!1,this._timers=new Map,this._workerId=u(),this._drainPromise=null,this._onlineHandler=()=>this._drain(),this._bindEvents(),window.addEventListener("online",this._onlineHandler);const a=this;return o.open().then(f=>f?o.getPaused(a.scope):(console.warn("[ln-api-queue] IndexedDB not available — queue disabled"),!1)).then(f=>(a._paused=!!f,a._paused&&y(a.dom,"ln-api-queue:paused",{reason:"auth",restored:!0}),a._emitPendingCount())).then(()=>a._drain()).catch(f=>{console.error("[ln-api-queue] Initialization failed:",f),y(a.dom,"ln-api-queue:error",{operation:"initialize",error:f})}),this}i.prototype._isOnline=function(){const e=this.dom.getAttribute("data-ln-api-queue-online");return e==="true"?!0:e==="false"?!1:navigator.onLine},i.prototype._emitPendingCount=function(){const e=this;return o.allForScope(e.scope).then(n=>(y(e.dom,"ln-api-queue:pending-count",{count:n.length,scope:e.scope}),n.length===0&&y(e.dom,"ln-api-queue:drained",{scope:e.scope}),n))},i.prototype._clearTimer=function(e){const n=this._timers.get(e);n&&(clearTimeout(n),this._timers.delete(e))},i.prototype._scheduleTimer=function(e,n){const a=Math.max(0,n),f=this._timers.get(e);f&&clearTimeout(f);const h=this,m=setTimeout(()=>{h._timers.delete(e),h._drain()},a);this._timers.set(e,m)},i.prototype._drain=function(){const e=this;return e._paused||!e._isOnline()?Promise.resolve():(e._drainPromise||(e._drainPromise=o.claimReady(e.scope,e._workerId,c).then(n=>{for(const a of n.wakeups)e._scheduleTimer(a.chainKey,a.at-Date.now());for(const a of n.entries)e._clearTimer(a.chainKey),y(e.dom,"ln-api-queue:send",{entryId:a.entryId,chainKey:a.chainKey,op:a.op,targetId:a.targetId,payload:a.payload,expectedVersion:a.expectedVersion,idempotencyKey:a.entryId,meta:a.meta})}).catch(n=>{console.error("[ln-api-queue] Drain failed:",n),y(e.dom,"ln-api-queue:error",{operation:"drain",error:n})}).finally(()=>{e._drainPromise=null})),e._drainPromise)},i.prototype._onEnqueue=function(e){const n=this;return o.enqueue(n.scope,e.detail||{}).then(a=>{if(a)return n._emitPendingCount().then(f=>(y(n.dom,"ln-api-queue:enqueued",{entryId:a.entryId,chainKey:a.chainKey,count:f.length}),n._drain()))}).catch(a=>{y(n.dom,"ln-api-queue:error",{operation:"enqueue",error:a})})},i.prototype._onAck=function(e){const n=this,a=e.detail||{};return o.ack(n.scope,a.entryId).then(()=>n._emitPendingCount()).then(()=>n._drain()).catch(f=>{y(n.dom,"ln-api-queue:error",{operation:"ack",entryId:a.entryId,error:f})})},i.prototype._onNack=function(e){const n=this,a=e.detail||{};return o.nack(n.scope,a.entryId,a.reason,{maxAttempts:l,backoff:s}).then(f=>{if(f)return f.status==="failed"?y(n.dom,"ln-api-queue:failed",{entryId:f.entry.entryId,chainKey:f.entry.chainKey,attempts:f.entry.attempts}):f.status==="retry"?n._scheduleTimer(f.entry.chainKey,f.delay):f.status==="auth"&&(n._paused=!0,y(n.dom,"ln-api-queue:paused",{reason:"auth"}),y(n.dom,"ln-api-queue:auth-required",{entryId:f.entry.entryId,chainKey:f.entry.chainKey})),n._emitPendingCount().then(()=>{if(f.status==="dropped")return n._drain()})}).catch(f=>{y(n.dom,"ln-api-queue:error",{operation:"nack",entryId:a.entryId,error:f})})},i.prototype._onRemap=function(e){const n=this,a=e.detail||{};return o.remap(n.scope,a.oldKey,a.newId).catch(f=>{y(n.dom,"ln-api-queue:error",{operation:"remap",error:f})})},i.prototype._onResolveCreate=function(e){const n=this,a=e.detail||{};return o.resolveCreate(n.scope,a.entryId,a.oldKey,a.newId).then(()=>n._emitPendingCount()).then(()=>n._drain()).catch(f=>{y(n.dom,"ln-api-queue:error",{operation:"resolve-create",entryId:a.entryId,error:f})})},i.prototype._onResume=function(){const e=this;return o.setPaused(e.scope,!1).then(()=>(e._paused=!1,y(e.dom,"ln-api-queue:resumed",{}),e._drain())).catch(n=>{y(e.dom,"ln-api-queue:error",{operation:"resume",error:n})})},i.prototype._onDrain=function(){const e=this;return o.resetFailed(e.scope).then(()=>{const n=e._drainPromise;return n?n.then(()=>e._drain()):e._drain()}).catch(n=>{y(e.dom,"ln-api-queue:error",{operation:"manual-drain",error:n})})},i.prototype._onClear=function(){const e=this;return e._timers.forEach(n=>clearTimeout(n)),e._timers.clear(),o.clear(e.scope).then(()=>{e._paused=!1,y(e.dom,"ln-api-queue:pending-count",{count:0,scope:e.scope}),y(e.dom,"ln-api-queue:drained",{scope:e.scope})}).catch(n=>{y(e.dom,"ln-api-queue:error",{operation:"clear",error:n})})},i.prototype._bindEvents=function(){const e=this;e._handlers={enqueue:n=>e._onEnqueue(n),ack:n=>e._onAck(n),nack:n=>e._onNack(n),remap:n=>e._onRemap(n),resolveCreate:n=>e._onResolveCreate(n),resume:()=>e._onResume(),drain:()=>e._onDrain(),clear:()=>e._onClear()},e.dom.addEventListener("ln-api-queue:request-enqueue",e._handlers.enqueue),e.dom.addEventListener("ln-api-queue:ack",e._handlers.ack),e.dom.addEventListener("ln-api-queue:nack",e._handlers.nack),e.dom.addEventListener("ln-api-queue:request-remap",e._handlers.remap),e.dom.addEventListener("ln-api-queue:resolve-create",e._handlers.resolveCreate),e.dom.addEventListener("ln-api-queue:request-resume",e._handlers.resume),e.dom.addEventListener("ln-api-queue:request-drain",e._handlers.drain),e.dom.addEventListener("ln-api-queue:request-clear",e._handlers.clear)},i.prototype.destroy=function(){if(!this.dom[t])return;const e=this;e.dom.removeEventListener("ln-api-queue:request-enqueue",e._handlers.enqueue),e.dom.removeEventListener("ln-api-queue:ack",e._handlers.ack),e.dom.removeEventListener("ln-api-queue:nack",e._handlers.nack),e.dom.removeEventListener("ln-api-queue:request-remap",e._handlers.remap),e.dom.removeEventListener("ln-api-queue:resolve-create",e._handlers.resolveCreate),e.dom.removeEventListener("ln-api-queue:request-resume",e._handlers.resume),e.dom.removeEventListener("ln-api-queue:request-drain",e._handlers.drain),e.dom.removeEventListener("ln-api-queue:request-clear",e._handlers.clear),window.removeEventListener("online",e._onlineHandler),e._timers.forEach(n=>clearTimeout(n)),e._timers.clear(),y(e.dom,"ln-api-queue:destroyed",{scope:e.scope}),delete e.dom[t]};function d(e){const n=e[t];n&&n._drain()}j(r,t,i,"ln-api-queue",{extraAttributes:["data-ln-api-queue-online"],onAttributeChange:d})})()})();
@@ -0,0 +1,320 @@
1
+ import { registerComponent, dispatch } from '../../ln-core';
2
+ import { QueueStorage } from './queue-storage';
3
+
4
+ (function () {
5
+ const DOM_SELECTOR = 'data-ln-api-queue';
6
+ const DOM_ATTRIBUTE = 'lnApiQueue';
7
+ const BACKOFF_LADDER = [2000, 5000, 15000, 60000, 300000];
8
+ const MAX_ATTEMPTS = 8;
9
+ const LEASE_MS = 60000;
10
+
11
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
12
+
13
+ function _uuid() {
14
+ try { return crypto.randomUUID(); }
15
+ catch (_) {
16
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
17
+ const r = Math.random() * 16 | 0;
18
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
19
+ });
20
+ }
21
+ }
22
+
23
+ const _storage = new QueueStorage({
24
+ indexedDB: window.indexedDB,
25
+ IDBKeyRange: window.IDBKeyRange,
26
+ uuid: _uuid
27
+ });
28
+
29
+ function _component(dom) {
30
+ this.dom = dom;
31
+ dom[DOM_ATTRIBUTE] = this;
32
+
33
+ const coordinatorEl = dom.closest('[data-ln-data-coordinator]');
34
+ this.scope = dom.getAttribute(DOM_SELECTOR) || (coordinatorEl ? coordinatorEl.getAttribute('data-ln-data-coordinator') : null) || 'default';
35
+
36
+ this._paused = false;
37
+ this._timers = new Map();
38
+ this._workerId = _uuid();
39
+ this._drainPromise = null;
40
+ this._onlineHandler = () => this._drain();
41
+
42
+ this._bindEvents();
43
+ window.addEventListener('online', this._onlineHandler);
44
+
45
+ const self = this;
46
+ _storage.open()
47
+ .then(db => {
48
+ if (!db) {
49
+ console.warn('[ln-api-queue] IndexedDB not available — queue disabled');
50
+ return false;
51
+ }
52
+ return _storage.getPaused(self.scope);
53
+ })
54
+ .then(paused => {
55
+ self._paused = !!paused;
56
+ if (self._paused) dispatch(self.dom, 'ln-api-queue:paused', { reason: 'auth', restored: true });
57
+ return self._emitPendingCount();
58
+ })
59
+ .then(() => self._drain())
60
+ .catch(error => {
61
+ console.error('[ln-api-queue] Initialization failed:', error);
62
+ dispatch(self.dom, 'ln-api-queue:error', { operation: 'initialize', error });
63
+ });
64
+
65
+ return this;
66
+ }
67
+
68
+ _component.prototype._isOnline = function () {
69
+ const attr = this.dom.getAttribute('data-ln-api-queue-online');
70
+ if (attr === 'true') return true;
71
+ if (attr === 'false') return false;
72
+ return navigator.onLine;
73
+ };
74
+
75
+ _component.prototype._emitPendingCount = function () {
76
+ const self = this;
77
+ return _storage.allForScope(self.scope).then(entries => {
78
+ dispatch(self.dom, 'ln-api-queue:pending-count', { count: entries.length, scope: self.scope });
79
+ if (entries.length === 0) {
80
+ dispatch(self.dom, 'ln-api-queue:drained', { scope: self.scope });
81
+ }
82
+ return entries;
83
+ });
84
+ };
85
+
86
+ _component.prototype._clearTimer = function (chainKey) {
87
+ const timer = this._timers.get(chainKey);
88
+ if (timer) {
89
+ clearTimeout(timer);
90
+ this._timers.delete(chainKey);
91
+ }
92
+ };
93
+
94
+ _component.prototype._scheduleTimer = function (chainKey, delta) {
95
+ const delay = Math.max(0, delta);
96
+ const current = this._timers.get(chainKey);
97
+ if (current) clearTimeout(current);
98
+
99
+ const self = this;
100
+ const timer = setTimeout(() => {
101
+ self._timers.delete(chainKey);
102
+ self._drain();
103
+ }, delay);
104
+ this._timers.set(chainKey, timer);
105
+ };
106
+
107
+ _component.prototype._drain = function () {
108
+ const self = this;
109
+ if (self._paused || !self._isOnline()) return Promise.resolve();
110
+ if (self._drainPromise) return self._drainPromise;
111
+
112
+ self._drainPromise = _storage.claimReady(self.scope, self._workerId, LEASE_MS)
113
+ .then(result => {
114
+ for (const wakeup of result.wakeups) {
115
+ self._scheduleTimer(wakeup.chainKey, wakeup.at - Date.now());
116
+ }
117
+
118
+ for (const entry of result.entries) {
119
+ self._clearTimer(entry.chainKey);
120
+ dispatch(self.dom, 'ln-api-queue:send', {
121
+ entryId: entry.entryId,
122
+ chainKey: entry.chainKey,
123
+ op: entry.op,
124
+ targetId: entry.targetId,
125
+ payload: entry.payload,
126
+ expectedVersion: entry.expectedVersion,
127
+ idempotencyKey: entry.entryId,
128
+ meta: entry.meta
129
+ });
130
+ }
131
+ })
132
+ .catch(error => {
133
+ console.error('[ln-api-queue] Drain failed:', error);
134
+ dispatch(self.dom, 'ln-api-queue:error', { operation: 'drain', error });
135
+ })
136
+ .finally(() => {
137
+ self._drainPromise = null;
138
+ });
139
+
140
+ return self._drainPromise;
141
+ };
142
+
143
+ _component.prototype._onEnqueue = function (e) {
144
+ const self = this;
145
+ return _storage.enqueue(self.scope, e.detail || {})
146
+ .then(entry => {
147
+ if (!entry) return;
148
+ return self._emitPendingCount().then(entries => {
149
+ dispatch(self.dom, 'ln-api-queue:enqueued', {
150
+ entryId: entry.entryId,
151
+ chainKey: entry.chainKey,
152
+ count: entries.length
153
+ });
154
+ return self._drain();
155
+ });
156
+ })
157
+ .catch(error => {
158
+ dispatch(self.dom, 'ln-api-queue:error', { operation: 'enqueue', error });
159
+ });
160
+ };
161
+
162
+ _component.prototype._onAck = function (e) {
163
+ const self = this;
164
+ const detail = e.detail || {};
165
+ return _storage.ack(self.scope, detail.entryId)
166
+ .then(() => self._emitPendingCount())
167
+ .then(() => self._drain())
168
+ .catch(error => {
169
+ dispatch(self.dom, 'ln-api-queue:error', { operation: 'ack', entryId: detail.entryId, error });
170
+ });
171
+ };
172
+
173
+ _component.prototype._onNack = function (e) {
174
+ const self = this;
175
+ const detail = e.detail || {};
176
+
177
+ return _storage.nack(self.scope, detail.entryId, detail.reason, {
178
+ maxAttempts: MAX_ATTEMPTS,
179
+ backoff: BACKOFF_LADDER
180
+ }).then(result => {
181
+ if (!result) return;
182
+
183
+ if (result.status === 'failed') {
184
+ dispatch(self.dom, 'ln-api-queue:failed', {
185
+ entryId: result.entry.entryId,
186
+ chainKey: result.entry.chainKey,
187
+ attempts: result.entry.attempts
188
+ });
189
+ } else if (result.status === 'retry') {
190
+ self._scheduleTimer(result.entry.chainKey, result.delay);
191
+ } else if (result.status === 'auth') {
192
+ self._paused = true;
193
+ dispatch(self.dom, 'ln-api-queue:paused', { reason: 'auth' });
194
+ dispatch(self.dom, 'ln-api-queue:auth-required', {
195
+ entryId: result.entry.entryId,
196
+ chainKey: result.entry.chainKey
197
+ });
198
+ }
199
+
200
+ return self._emitPendingCount().then(() => {
201
+ if (result.status === 'dropped') return self._drain();
202
+ });
203
+ }).catch(error => {
204
+ dispatch(self.dom, 'ln-api-queue:error', { operation: 'nack', entryId: detail.entryId, error });
205
+ });
206
+ };
207
+
208
+ _component.prototype._onRemap = function (e) {
209
+ const self = this;
210
+ const detail = e.detail || {};
211
+ return _storage.remap(self.scope, detail.oldKey, detail.newId)
212
+ .catch(error => {
213
+ dispatch(self.dom, 'ln-api-queue:error', { operation: 'remap', error });
214
+ });
215
+ };
216
+
217
+ _component.prototype._onResolveCreate = function (e) {
218
+ const self = this;
219
+ const detail = e.detail || {};
220
+ return _storage.resolveCreate(self.scope, detail.entryId, detail.oldKey, detail.newId)
221
+ .then(() => self._emitPendingCount())
222
+ .then(() => self._drain())
223
+ .catch(error => {
224
+ dispatch(self.dom, 'ln-api-queue:error', {
225
+ operation: 'resolve-create',
226
+ entryId: detail.entryId,
227
+ error
228
+ });
229
+ });
230
+ };
231
+
232
+ _component.prototype._onResume = function () {
233
+ const self = this;
234
+ return _storage.setPaused(self.scope, false).then(() => {
235
+ self._paused = false;
236
+ dispatch(self.dom, 'ln-api-queue:resumed', {});
237
+ return self._drain();
238
+ }).catch(error => {
239
+ dispatch(self.dom, 'ln-api-queue:error', { operation: 'resume', error });
240
+ });
241
+ };
242
+
243
+ _component.prototype._onDrain = function () {
244
+ const self = this;
245
+ return _storage.resetFailed(self.scope).then(() => {
246
+ const activeDrain = self._drainPromise;
247
+ return activeDrain ? activeDrain.then(() => self._drain()) : self._drain();
248
+ }).catch(error => {
249
+ dispatch(self.dom, 'ln-api-queue:error', { operation: 'manual-drain', error });
250
+ });
251
+ };
252
+
253
+ _component.prototype._onClear = function () {
254
+ const self = this;
255
+ self._timers.forEach(timer => clearTimeout(timer));
256
+ self._timers.clear();
257
+ return _storage.clear(self.scope).then(() => {
258
+ self._paused = false;
259
+ dispatch(self.dom, 'ln-api-queue:pending-count', { count: 0, scope: self.scope });
260
+ dispatch(self.dom, 'ln-api-queue:drained', { scope: self.scope });
261
+ }).catch(error => {
262
+ dispatch(self.dom, 'ln-api-queue:error', { operation: 'clear', error });
263
+ });
264
+ };
265
+
266
+ _component.prototype._bindEvents = function () {
267
+ const self = this;
268
+ self._handlers = {
269
+ enqueue: e => self._onEnqueue(e),
270
+ ack: e => self._onAck(e),
271
+ nack: e => self._onNack(e),
272
+ remap: e => self._onRemap(e),
273
+ resolveCreate: e => self._onResolveCreate(e),
274
+ resume: () => self._onResume(),
275
+ drain: () => self._onDrain(),
276
+ clear: () => self._onClear()
277
+ };
278
+
279
+ self.dom.addEventListener('ln-api-queue:request-enqueue', self._handlers.enqueue);
280
+ self.dom.addEventListener('ln-api-queue:ack', self._handlers.ack);
281
+ self.dom.addEventListener('ln-api-queue:nack', self._handlers.nack);
282
+ self.dom.addEventListener('ln-api-queue:request-remap', self._handlers.remap);
283
+ self.dom.addEventListener('ln-api-queue:resolve-create', self._handlers.resolveCreate);
284
+ self.dom.addEventListener('ln-api-queue:request-resume', self._handlers.resume);
285
+ self.dom.addEventListener('ln-api-queue:request-drain', self._handlers.drain);
286
+ self.dom.addEventListener('ln-api-queue:request-clear', self._handlers.clear);
287
+ };
288
+
289
+ _component.prototype.destroy = function () {
290
+ if (!this.dom[DOM_ATTRIBUTE]) return;
291
+
292
+ const self = this;
293
+ self.dom.removeEventListener('ln-api-queue:request-enqueue', self._handlers.enqueue);
294
+ self.dom.removeEventListener('ln-api-queue:ack', self._handlers.ack);
295
+ self.dom.removeEventListener('ln-api-queue:nack', self._handlers.nack);
296
+ self.dom.removeEventListener('ln-api-queue:request-remap', self._handlers.remap);
297
+ self.dom.removeEventListener('ln-api-queue:resolve-create', self._handlers.resolveCreate);
298
+ self.dom.removeEventListener('ln-api-queue:request-resume', self._handlers.resume);
299
+ self.dom.removeEventListener('ln-api-queue:request-drain', self._handlers.drain);
300
+ self.dom.removeEventListener('ln-api-queue:request-clear', self._handlers.clear);
301
+ window.removeEventListener('online', self._onlineHandler);
302
+
303
+ self._timers.forEach(timer => clearTimeout(timer));
304
+ self._timers.clear();
305
+
306
+ dispatch(self.dom, 'ln-api-queue:destroyed', { scope: self.scope });
307
+ delete self.dom[DOM_ATTRIBUTE];
308
+ };
309
+
310
+ function _syncAttr(el) {
311
+ const instance = el[DOM_ATTRIBUTE];
312
+ if (!instance) return;
313
+ instance._drain();
314
+ }
315
+
316
+ registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-api-queue', {
317
+ extraAttributes: ['data-ln-api-queue-online'],
318
+ onAttributeChange: _syncAttr
319
+ });
320
+ })();