@rebasepro/server 0.24.0 → 0.24.1-canary.gcb918fc

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 (224) hide show
  1. package/README.md +27 -2
  2. package/dist/GCSStorageController-fv6JAZ87.js +402 -0
  3. package/dist/GCSStorageController-fv6JAZ87.js.map +1 -0
  4. package/dist/S3StorageController-BNg-pXiY.js +480 -0
  5. package/dist/S3StorageController-BNg-pXiY.js.map +1 -0
  6. package/dist/api/mount-path.d.ts +30 -0
  7. package/dist/api/mount.d.ts +6 -13
  8. package/dist/api/rest/api-generator.d.ts +53 -1
  9. package/dist/api/rest/auth-collection-writes.d.ts +28 -21
  10. package/dist/api/rest/field-access-query.d.ts +28 -8
  11. package/dist/api/rest/field-ops.d.ts +19 -7
  12. package/dist/api/rest/query-body.d.ts +10 -0
  13. package/dist/api/rest/write-validation.d.ts +7 -5
  14. package/dist/api/schema-editor-routes.d.ts +10 -1
  15. package/dist/api/sql-export-routes.d.ts +8 -0
  16. package/dist/api/types.d.ts +35 -0
  17. package/dist/{ast-schema-editor-CWqS_sLJ.js → ast-schema-editor-mCeSC03x.js} +3 -3
  18. package/dist/{ast-schema-editor-CWqS_sLJ.js.map → ast-schema-editor-mCeSC03x.js.map} +1 -1
  19. package/dist/auth/access.d.ts +57 -8
  20. package/dist/auth/admin-user-ops.d.ts +24 -2
  21. package/dist/auth/admin-users-route.d.ts +1 -0
  22. package/dist/auth/api-keys/api-key-middleware.d.ts +10 -3
  23. package/dist/auth/api-keys/api-key-routes.d.ts +4 -1
  24. package/dist/auth/builtin-auth-adapter.d.ts +13 -0
  25. package/dist/auth/email-change-routes.d.ts +3 -0
  26. package/dist/auth/expired-token-sweep.d.ts +5 -1
  27. package/dist/auth/interfaces.d.ts +10 -0
  28. package/dist/auth/jwks-routes.d.ts +5 -1
  29. package/dist/auth/jwt-crypto.d.ts +7 -0
  30. package/dist/auth/jwt-keys.d.ts +4 -0
  31. package/dist/auth/jwt.d.ts +282 -166
  32. package/dist/auth/magic-link-routes.d.ts +3 -0
  33. package/dist/auth/mfa-crypto.d.ts +9 -3
  34. package/dist/auth/mfa-gate.d.ts +4 -2
  35. package/dist/auth/mfa-routes.d.ts +6 -0
  36. package/dist/auth/middleware.d.ts +9 -2
  37. package/dist/auth/otp-routes.d.ts +3 -0
  38. package/dist/auth/rate-limiter.d.ts +61 -34
  39. package/dist/auth/registration-policy.d.ts +17 -0
  40. package/dist/auth/require-auth.d.ts +1 -1
  41. package/dist/auth/routes.d.ts +16 -0
  42. package/dist/auth/session-routes.d.ts +6 -0
  43. package/dist/auth/user-picking.d.ts +37 -0
  44. package/dist/auth/verify-credential.d.ts +25 -4
  45. package/dist/{auth-DMLngxn_.js → auth-GnONzteu.js} +2869 -1871
  46. package/dist/auth-GnONzteu.js.map +1 -0
  47. package/dist/auth_adapter-CIItCTc8.js +21 -0
  48. package/dist/auth_adapter-CIItCTc8.js.map +1 -0
  49. package/dist/backend/create-backend.d.ts +281 -0
  50. package/dist/backend/default-backend.d.ts +35 -0
  51. package/dist/backend/options.d.ts +912 -0
  52. package/dist/backend/scope.d.ts +23 -0
  53. package/dist/backend/services.d.ts +54 -0
  54. package/dist/backend-DTAOsLQc.js.map +1 -1
  55. package/dist/backup/backup-common.d.ts +14 -3
  56. package/dist/backup/backup-routes.d.ts +23 -2
  57. package/dist/backup/backup-storage.d.ts +3 -1
  58. package/dist/backup/index.d.ts +3 -1
  59. package/dist/{backup-CN0s50D2.js → backup-BnvkSLHi.js} +176 -35
  60. package/dist/backup-BnvkSLHi.js.map +1 -0
  61. package/dist/boot/boot.d.ts +2 -2
  62. package/dist/boot/driver.d.ts +3 -0
  63. package/dist/boot/env.d.ts +9 -2
  64. package/dist/boot/http-server.d.ts +22 -0
  65. package/dist/boot/options.d.ts +16 -1
  66. package/dist/boot/provision.d.ts +4 -2
  67. package/dist/boot/runtime-cors.d.ts +17 -0
  68. package/dist/boot/sources.d.ts +2 -0
  69. package/dist/boot/static-routing.d.ts +9 -0
  70. package/dist/{contract-routes-fz8i4pxs.js → contract-routes-Ey6ftnKH.js} +4 -20
  71. package/dist/contract-routes-Ey6ftnKH.js.map +1 -0
  72. package/dist/cron/cron-loader.d.ts +77 -18
  73. package/dist/cron/cron-routes.d.ts +1 -1
  74. package/dist/cron/cron-scheduler.d.ts +69 -3
  75. package/dist/cron/cron-store.d.ts +33 -5
  76. package/dist/cron/define-cron.d.ts +6 -2
  77. package/dist/cron/index.d.ts +3 -3
  78. package/dist/cron-loader-B-u0snqq.js +202 -0
  79. package/dist/cron-loader-B-u0snqq.js.map +1 -0
  80. package/dist/{cron-routes-Bc-SB0Se.js → cron-routes-OD6BstUj.js} +27 -4
  81. package/dist/cron-routes-OD6BstUj.js.map +1 -0
  82. package/dist/{cron-scheduler-CYQgco86.js → cron-scheduler-idtLPoN-.js} +299 -33
  83. package/dist/cron-scheduler-idtLPoN-.js.map +1 -0
  84. package/dist/cron-store-CZ30ZBi-.js +577 -0
  85. package/dist/cron-store-CZ30ZBi-.js.map +1 -0
  86. package/dist/email/smtp-email-service.d.ts +23 -0
  87. package/dist/email/types.d.ts +18 -0
  88. package/dist/env.d.ts +0 -37
  89. package/dist/filter-dialect-O1HK6Nvr.js +1628 -0
  90. package/dist/filter-dialect-O1HK6Nvr.js.map +1 -0
  91. package/dist/{function-loader-D7o5Epjj.js → function-loader-iKhOZatS.js} +68 -38
  92. package/dist/function-loader-iKhOZatS.js.map +1 -0
  93. package/dist/{function-routes-CaNG4waN.js → function-routes-j9TwpvQe.js} +5 -4
  94. package/dist/function-routes-j9TwpvQe.js.map +1 -0
  95. package/dist/functions/context.d.ts +9 -0
  96. package/dist/functions/define-function.d.ts +10 -6
  97. package/dist/functions/function-loader.d.ts +75 -18
  98. package/dist/functions/function-routes.d.ts +2 -1
  99. package/dist/functions/index.d.ts +5 -4
  100. package/dist/functions/index.js +42 -13
  101. package/dist/functions/index.js.map +1 -1
  102. package/dist/functions/internal.d.ts +2 -2
  103. package/dist/functions/proxy.d.ts +6 -2
  104. package/dist/functions/request-timeout.d.ts +7 -5
  105. package/dist/history/history-routes.d.ts +9 -0
  106. package/dist/history/retention-sweep.d.ts +81 -0
  107. package/dist/{history-recorder-Nr8zLvoU.js → history-recorder-BfWRwmPy.js} +3 -3
  108. package/dist/{history-recorder-Nr8zLvoU.js.map → history-recorder-BfWRwmPy.js.map} +1 -1
  109. package/dist/{history-store-rcAm_xFR.js → history-store-DPofGVDp.js} +2 -2
  110. package/dist/{history-store-rcAm_xFR.js.map → history-store-DPofGVDp.js.map} +1 -1
  111. package/dist/index.d.ts +23 -10
  112. package/dist/index.es.js +24423 -20038
  113. package/dist/index.es.js.map +1 -1
  114. package/dist/init/docs.d.ts +27 -10
  115. package/dist/init/process-safety.d.ts +7 -2
  116. package/dist/init/shutdown.d.ts +18 -3
  117. package/dist/init.d.ts +49 -865
  118. package/dist/internal-tables-L2t39RdT.js +136 -0
  119. package/dist/internal-tables-L2t39RdT.js.map +1 -0
  120. package/dist/jobs/job-queue.d.ts +8 -0
  121. package/dist/jobs/job-store.d.ts +24 -0
  122. package/dist/jobs/types.d.ts +25 -4
  123. package/dist/{jobs-DqYNfquG.js → jobs-BCbflZj2.js} +234 -24
  124. package/dist/jobs-BCbflZj2.js.map +1 -0
  125. package/dist/{jwt-R6bSPMjk.js → jwt-BlvuKoR6.js} +529 -454
  126. package/dist/jwt-BlvuKoR6.js.map +1 -0
  127. package/dist/{keys-GAVZqbqx.js → keys-COzQ2aE8.js} +2 -2
  128. package/dist/{keys-GAVZqbqx.js.map → keys-COzQ2aE8.js.map} +1 -1
  129. package/dist/load-failure-L5hvfDoG.js +80 -0
  130. package/dist/load-failure-L5hvfDoG.js.map +1 -0
  131. package/dist/mcp/mcp-routes.d.ts +6 -0
  132. package/dist/mcp/mcp-tools.d.ts +8 -0
  133. package/dist/mcp/oauth-routes.d.ts +7 -0
  134. package/dist/metrics/index.d.ts +18 -0
  135. package/dist/metrics/pool-wait.d.ts +50 -0
  136. package/dist/offline-QNQgsPOq-B2yGeXkn.js +3305 -0
  137. package/dist/offline-QNQgsPOq-B2yGeXkn.js.map +1 -0
  138. package/dist/offline-store-Cm5sAX81-CALiO4Gn.js +1915 -0
  139. package/dist/offline-store-Cm5sAX81-CALiO4Gn.js.map +1 -0
  140. package/dist/{openapi-generator-DAq_XVDu.js → openapi-generator-D9zUQmEp.js} +366 -82
  141. package/dist/openapi-generator-D9zUQmEp.js.map +1 -0
  142. package/dist/policy-PdzX-Opd.js +121 -0
  143. package/dist/policy-PdzX-Opd.js.map +1 -0
  144. package/dist/project_manifest-R6y_KHms.js +59 -0
  145. package/dist/project_manifest-R6y_KHms.js.map +1 -0
  146. package/dist/{proxy-qRlqeUmO.js → proxy-CkTlMGmh.js} +4 -3
  147. package/dist/proxy-CkTlMGmh.js.map +1 -0
  148. package/dist/{query-parser-BgiKJKvc.js → query-parser-4WkMoA_K.js} +510 -15
  149. package/dist/query-parser-4WkMoA_K.js.map +1 -0
  150. package/dist/{request-timeout-DgH7j8qO.js → request-timeout-Dnl2_OBc.js} +15 -8
  151. package/dist/request-timeout-Dnl2_OBc.js.map +1 -0
  152. package/dist/resumable-upload-threshold-CKv-Ry0l.js +24 -0
  153. package/dist/resumable-upload-threshold-CKv-Ry0l.js.map +1 -0
  154. package/dist/schema-edit/apply-schema-change.d.ts +10 -0
  155. package/dist/schema-edit/local-source.d.ts +29 -0
  156. package/dist/schema-edit/remote-source.d.ts +13 -3
  157. package/dist/schema-edit/source-editor.d.ts +28 -0
  158. package/dist/{schema-editor-routes-oIyuWl3L.js → schema-editor-routes-C11Tumyd.js} +54 -10
  159. package/dist/schema-editor-routes-C11Tumyd.js.map +1 -0
  160. package/dist/services/outbound-post.d.ts +50 -0
  161. package/dist/services/outbound-url-guard.d.ts +34 -5
  162. package/dist/services/routed-realtime-service.d.ts +5 -0
  163. package/dist/services/webhook-service.d.ts +66 -14
  164. package/dist/singleton.d.ts +9 -5
  165. package/dist/soft-delete-params-DfV3vVN5.js +668 -0
  166. package/dist/soft-delete-params-DfV3vVN5.js.map +1 -0
  167. package/dist/sql-export-routes-ClRwzIsC.js +240 -0
  168. package/dist/sql-export-routes-ClRwzIsC.js.map +1 -0
  169. package/dist/{src-CatHFUym.js → src-D5Y3N-S4.js} +2 -1
  170. package/dist/{src-CatHFUym.js.map → src-D5Y3N-S4.js.map} +1 -1
  171. package/dist/storage/GCSStorageController.d.ts +46 -2
  172. package/dist/storage/LocalStorageController.d.ts +67 -2
  173. package/dist/storage/S3StorageController.d.ts +37 -3
  174. package/dist/storage/image-transform.d.ts +5 -0
  175. package/dist/storage/maintenance-routes.d.ts +23 -0
  176. package/dist/storage/maintenance.d.ts +126 -0
  177. package/dist/storage/rendition-cache.d.ts +9 -11
  178. package/dist/storage/request-principals.d.ts +30 -0
  179. package/dist/storage/routes.d.ts +20 -0
  180. package/dist/storage/source-variables.d.ts +10 -0
  181. package/dist/storage/streams.d.ts +42 -0
  182. package/dist/storage/tus-handler.d.ts +46 -2
  183. package/dist/storage/types.d.ts +82 -7
  184. package/dist/storage/upload-validation.d.ts +13 -0
  185. package/dist/storage-resumable-VO-cMpse-C_YX71pO.js +125 -0
  186. package/dist/storage-resumable-VO-cMpse-C_YX71pO.js.map +1 -0
  187. package/dist/strings.es-BeZWAodn.js +256 -0
  188. package/dist/strings.es-BeZWAodn.js.map +1 -0
  189. package/dist/upload-validation-Dd-fnsbA.js +172 -0
  190. package/dist/upload-validation-Dd-fnsbA.js.map +1 -0
  191. package/dist/utils/byte-size.d.ts +10 -0
  192. package/dist/utils/compression.d.ts +6 -0
  193. package/dist/utils/load-failure.d.ts +14 -0
  194. package/package.json +5 -5
  195. package/dist/GCSStorageController-BSiP1c-f.js +0 -256
  196. package/dist/GCSStorageController-BSiP1c-f.js.map +0 -1
  197. package/dist/S3StorageController-CAwFRgjV.js +0 -276
  198. package/dist/S3StorageController-CAwFRgjV.js.map +0 -1
  199. package/dist/auth-DMLngxn_.js.map +0 -1
  200. package/dist/backup-CN0s50D2.js.map +0 -1
  201. package/dist/contract-routes-fz8i4pxs.js.map +0 -1
  202. package/dist/cron-loader-CwaANlOG.js +0 -118
  203. package/dist/cron-loader-CwaANlOG.js.map +0 -1
  204. package/dist/cron-routes-Bc-SB0Se.js.map +0 -1
  205. package/dist/cron-scheduler-CYQgco86.js.map +0 -1
  206. package/dist/cron-store-D2Q9-Aco.js +0 -300
  207. package/dist/cron-store-D2Q9-Aco.js.map +0 -1
  208. package/dist/dynamic-import-X40pTZUQ.js +0 -23
  209. package/dist/dynamic-import-X40pTZUQ.js.map +0 -1
  210. package/dist/function-loader-D7o5Epjj.js.map +0 -1
  211. package/dist/function-routes-CaNG4waN.js.map +0 -1
  212. package/dist/jobs-DqYNfquG.js.map +0 -1
  213. package/dist/jwt-R6bSPMjk.js.map +0 -1
  214. package/dist/openapi-generator-DAq_XVDu.js.map +0 -1
  215. package/dist/proxy-qRlqeUmO.js.map +0 -1
  216. package/dist/query-parser-BgiKJKvc.js.map +0 -1
  217. package/dist/request-timeout-DgH7j8qO.js.map +0 -1
  218. package/dist/schema-editor-routes-oIyuWl3L.js.map +0 -1
  219. package/dist/soft-delete-params-BWPilMPF.js +0 -59
  220. package/dist/soft-delete-params-BWPilMPF.js.map +0 -1
  221. package/dist/src-I3aG1PcY.js +0 -6870
  222. package/dist/src-I3aG1PcY.js.map +0 -1
  223. package/dist/types-BfKcm9do.js +0 -39
  224. package/dist/types-BfKcm9do.js.map +0 -1
@@ -0,0 +1,3305 @@
1
+ import { createRequire as __rebaseCreateRequire } from "module";
2
+ import __rebaseProcess from "process";
3
+ globalThis.process ??= __rebaseProcess;
4
+ __rebaseCreateRequire(import.meta.url);
5
+ import { c as RebaseClientError, l as isUnsupported, s as RebaseApiError, t as splitSearchTerms } from "./strings.es-BeZWAodn.js";
6
+ import { D as Vector, E as GeoPoint, S as hasFieldOperation, T as EntityRelation, _ as normalizeOrderBy, c as collectAllPages, l as paginateFind, u as resolveFindWindow, w as EntityReference } from "./filter-dialect-O1HK6Nvr.js";
7
+ import { $ as parseRelationAggregateSort, tt as toCanonicalOp } from "./src-D5Y3N-S4.js";
8
+ import { a as byIdRead, f as rebaseReviver, h as unref, i as buildQueryString, n as MemoryOfflineStore, p as etagOf, r as SDKQueryBuilder, s as createMutationId, t as IndexedDBOfflineStore, u as invalidBulkBody } from "./offline-store-Cm5sAX81-CALiO4Gn.js";
9
+ //#region ../client/dist/offline-QNQgsPOq.js
10
+ /**
11
+ * Lossless round-tripping of rows through the offline store.
12
+ *
13
+ * Both persistence backends move values by structured clone, which keeps
14
+ * `Date` but flattens every class instance to a plain object. For
15
+ * `EntityReference`/`EntityRelation` that is harmless — they carry their own
16
+ * `__type` discriminator, so the JSON reviver can rebuild them — but
17
+ * `GeoPoint` and `Vector` do not, and would come back out of the cache as
18
+ * anonymous `{ latitude, longitude }` / `{ value }` bags. A row read from the
19
+ * cache must be indistinguishable from the same row read from the network, so
20
+ * those two are tagged on the way in and revived on the way out.
21
+ *
22
+ * Type tests here are structural rather than `instanceof`, because a structured
23
+ * clone can arrive from another realm — an iframe, a worker, or the polyfill
24
+ * the tests run against — where the constructor identity differs but the value
25
+ * is the real thing. Only *plain* objects are walked; anything else is passed
26
+ * through whole, so a class instance is never quietly reduced to `{}`.
27
+ */
28
+ function isDate(value) {
29
+ return Object.prototype.toString.call(value) === "[object Date]";
30
+ }
31
+ /** An object literal — not a Date, RegExp, Map, or any class instance. */
32
+ function isPlainObject$1(value) {
33
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
34
+ const proto = Object.getPrototypeOf(value);
35
+ if (proto === null || proto === Object.prototype) return true;
36
+ return proto.constructor?.name === "Object";
37
+ }
38
+ /**
39
+ * `out[key] = value`, as an own property whatever the key. Assigned, a key
40
+ * read from stored data that is literally `__proto__` (which `JSON.parse`
41
+ * creates as an own property) set the new object's prototype instead, and the
42
+ * column was gone from the row.
43
+ */
44
+ function setOwn(out, key, value) {
45
+ Object.defineProperty(out, key, {
46
+ value,
47
+ enumerable: true,
48
+ writable: true,
49
+ configurable: true
50
+ });
51
+ }
52
+ function dehydrateValue(value) {
53
+ if (value === null || value === void 0) return value;
54
+ if (value instanceof GeoPoint) return {
55
+ __type: "GeoPoint",
56
+ latitude: value.latitude,
57
+ longitude: value.longitude
58
+ };
59
+ if (value instanceof Vector) return {
60
+ __type: "Vector",
61
+ value: [...value.value]
62
+ };
63
+ if (value instanceof EntityReference || value instanceof EntityRelation) return value;
64
+ if (Array.isArray(value)) return value.map(dehydrateValue);
65
+ if (isPlainObject$1(value)) {
66
+ const out = {};
67
+ for (const [key, inner] of Object.entries(value)) setOwn(out, key, dehydrateValue(inner));
68
+ return out;
69
+ }
70
+ return value;
71
+ }
72
+ function hydrateValue(value) {
73
+ if (value === null || value === void 0 || isDate(value)) return value;
74
+ if (Array.isArray(value)) return value.map(hydrateValue);
75
+ if (typeof value === "object") {
76
+ const revived = rebaseReviver("", value);
77
+ if (revived !== value) return revived;
78
+ if (!isPlainObject$1(value)) return value;
79
+ const out = {};
80
+ for (const [key, inner] of Object.entries(value)) setOwn(out, key, hydrateValue(inner));
81
+ return out;
82
+ }
83
+ return value;
84
+ }
85
+ /** Prepare a row for the store. */
86
+ function dehydrateRow(row) {
87
+ return dehydrateValue(row);
88
+ }
89
+ /** Restore a row read back from the store. */
90
+ function hydrateRow(row) {
91
+ return hydrateValue(row);
92
+ }
93
+ /**
94
+ * A queued write as the store keeps it, and back. Its payloads are rows, and
95
+ * a row read back from the queue has to be the row that was queued: stored
96
+ * raw, a pending `GeoPoint` came back after a reload as a bare
97
+ * `{ latitude, longitude }`, was overlaid onto the server's rows, and was
98
+ * written back to the cache that way.
99
+ */
100
+ function mapMutation(op, map) {
101
+ const rows = (value) => value === void 0 ? value : map(value);
102
+ return {
103
+ ...op,
104
+ ...op.data !== void 0 ? { data: rows(op.data) } : {},
105
+ ...op.parts ? { parts: op.parts.map((part) => map(part)) } : {},
106
+ ...op.updates ? { updates: op.updates.map((u) => ({
107
+ id: u.id,
108
+ data: map(u.data)
109
+ })) } : {},
110
+ ...op.rollback ? { rollback: { rows: Object.fromEntries(Object.entries(op.rollback.rows).map(([id, row]) => [id, row === null ? null : map(row)])) } } : {},
111
+ ...op.sent?.data !== void 0 ? { sent: {
112
+ ...op.sent,
113
+ data: rows(op.sent.data)
114
+ } } : {}
115
+ };
116
+ }
117
+ /** Prepare a queued write for the store. */
118
+ function dehydrateMutation(op) {
119
+ return mapMutation(op, dehydrateValue);
120
+ }
121
+ /** Restore a queued write read back from the store. */
122
+ function hydrateMutation(op) {
123
+ return mapMutation(op, hydrateValue);
124
+ }
125
+ /**
126
+ * Whether the network is worth trying, and when to try again after it wasn't.
127
+ *
128
+ * `navigator.onLine` is necessary but not sufficient: it reports the state of
129
+ * the network interface, so it stays `true` behind a captive portal, on a
130
+ * connection that resolves DNS but reaches nothing, and while the API itself
131
+ * is down. This tracks what actually happened to requests as well, so the
132
+ * first failure is the only one an app pays for — everything after it inside
133
+ * the backoff window skips the doomed round trip and answers from the local
134
+ * store immediately, which is the difference between an app that freezes when
135
+ * the wifi drops and one that does not.
136
+ */
137
+ /**
138
+ * The request never reached the server, so nothing was decided by it.
139
+ *
140
+ * One question, one answer: `status === 0` is how a transport spells "there
141
+ * was no response at all", and the transport now wraps every `fetch` rejection
142
+ * into a `RebaseApiError` carrying it. This used to also sniff for the shapes
143
+ * a raw `fetch` rejects with — a `TypeError` in a browser, an `AbortError`, a
144
+ * `TimeoutError` — which is a list that has to be kept in step with three
145
+ * runtimes and was wrong in both directions: it missed anything they add, and
146
+ * it claimed a `TypeError` thrown by application code inside a callback was a
147
+ * network failure, which quietly served a cached answer in its place.
148
+ */
149
+ function isNetworkError(error) {
150
+ return error instanceof RebaseApiError && error.status === 0;
151
+ }
152
+ /**
153
+ * Statuses that mean "not now" rather than "not ever": a queued write that
154
+ * gets one of these is worth replaying, while a 400 or a 403 never will be.
155
+ * 500 is deliberately absent — an unhandled server error is far more often a
156
+ * bug the same payload will hit again than a blip, and retrying it forever
157
+ * jams every write behind it.
158
+ */
159
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
160
+ 408,
161
+ 425,
162
+ 429,
163
+ 502,
164
+ 503,
165
+ 504
166
+ ]);
167
+ /**
168
+ * The server holds this key for a request it has not answered yet.
169
+ *
170
+ * It is a 409 like a duplicate row is a 409, and nothing but the code separates
171
+ * them — one means "your write is already there", the other means "your write
172
+ * may not have happened at all, ask again".
173
+ */
174
+ var IDEMPOTENCY_IN_PROGRESS = "IDEMPOTENCY_KEY_IN_PROGRESS";
175
+ /**
176
+ * Is the server still answering an earlier attempt of this same write?
177
+ *
178
+ * The only correct response is to ask again — which is exactly what the
179
+ * server's own message says, and exactly what this SDK used not to do.
180
+ */
181
+ function isIdempotencyInProgressError(error) {
182
+ return error instanceof RebaseApiError && error.status === 409 && error.code === IDEMPOTENCY_IN_PROGRESS;
183
+ }
184
+ /**
185
+ * Is this key held for a different request than the one just sent with it?
186
+ *
187
+ * For a create the queue already sent once, that is an answer rather than a
188
+ * failure: the key is live only because the first attempt got through and
189
+ * committed, and only its response went missing.
190
+ */
191
+ function isIdempotencyKeyReusedError(error) {
192
+ return error instanceof RebaseApiError && error.status === 422 && error.code === "IDEMPOTENCY_KEY_REUSED";
193
+ }
194
+ /** Is this failure worth another attempt later? */
195
+ function isRetryableError(error) {
196
+ if (isNetworkError(error)) return true;
197
+ if (!(error instanceof RebaseApiError)) return false;
198
+ if (isIdempotencyInProgressError(error)) return true;
199
+ return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
200
+ }
201
+ /**
202
+ * Did this write fail because the row is already there?
203
+ *
204
+ * Matched on the SQLSTATE the server passes through (`23505`, unique_violation)
205
+ * and on 409, never on the message — a duplicate-key message names the
206
+ * constraint and the values, so it is neither stable nor safe to parse.
207
+ *
208
+ * The queue uses this to recognise its own earlier attempt. A create whose
209
+ * response was lost is replayed, and for a row carrying an id the SDK generated
210
+ * the server can only be rejecting it because the first attempt actually landed.
211
+ *
212
+ * Which is why the status alone cannot decide it: `IDEMPOTENCY_KEY_IN_PROGRESS`
213
+ * is a 409 that means the opposite — the row may not exist at all. Read as a
214
+ * duplicate, the queue looked for a row that was never written, found nothing,
215
+ * concluded there was nothing left to do and deleted the write from the queue.
216
+ */
217
+ function isDuplicateKeyError(error) {
218
+ if (!(error instanceof RebaseApiError)) return false;
219
+ if (error.code === "23505") return true;
220
+ return error.status === 409 && !isIdempotencyInProgressError(error);
221
+ }
222
+ /**
223
+ * The longest delay `setTimeout` holds. Past it the delay overflows a signed
224
+ * 32-bit integer and the timer fires after 1 ms — so a `syncIntervalMs` of a
225
+ * month retried continuously instead of almost never.
226
+ */
227
+ var MAX_TIMER_DELAY_MS = 2147483647;
228
+ var ConnectivityMonitor = class {
229
+ state = "online";
230
+ backoffMs;
231
+ initialBackoffMs;
232
+ maxBackoffMs;
233
+ retryAt = 0;
234
+ timer;
235
+ listeners = /* @__PURE__ */ new Set();
236
+ respectBackoff;
237
+ now;
238
+ setTimer;
239
+ clearTimer;
240
+ /** Called when the backoff window expires, to drive an automatic retry. */
241
+ onRetryDue;
242
+ /**
243
+ * Called when the browser says the connection is back. Its own hook rather
244
+ * than {@link onRetryDue}: a client with automatic retries off sets no retry
245
+ * callback, and the `online` event has to wake it all the same.
246
+ */
247
+ onOnline;
248
+ handleOnline = () => {
249
+ this.retryAt = 0;
250
+ this.backoffMs = this.initialBackoffMs;
251
+ this.clearPendingTimer();
252
+ this.setState("online");
253
+ (this.onOnline ?? this.onRetryDue)?.();
254
+ };
255
+ handleOffline = () => {
256
+ this.setState("offline");
257
+ };
258
+ constructor(options = {}) {
259
+ this.initialBackoffMs = options.initialBackoffMs ?? 1e3;
260
+ this.maxBackoffMs = Math.max(this.initialBackoffMs, options.maxBackoffMs ?? 6e4);
261
+ this.backoffMs = this.initialBackoffMs;
262
+ this.respectBackoff = options.respectBackoff ?? true;
263
+ this.now = options.now ?? (() => Date.now());
264
+ this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
265
+ this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
266
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
267
+ window.addEventListener("online", this.handleOnline);
268
+ window.addEventListener("offline", this.handleOffline);
269
+ }
270
+ if (typeof navigator !== "undefined" && navigator.onLine === false) this.state = "offline";
271
+ }
272
+ /** What the app should be told: are we connected? */
273
+ isOnline() {
274
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return false;
275
+ return this.state === "online";
276
+ }
277
+ /**
278
+ * Should this request even be sent? False means "answer from the local
279
+ * store instead" — the request would only burn a timeout to reach the same
280
+ * conclusion the last one already did.
281
+ */
282
+ shouldAttempt() {
283
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return false;
284
+ if (this.state === "online" || !this.respectBackoff) return true;
285
+ return this.now() >= this.retryAt;
286
+ }
287
+ /** A request reached the server. */
288
+ markSuccess() {
289
+ this.backoffMs = this.initialBackoffMs;
290
+ this.retryAt = 0;
291
+ this.clearPendingTimer();
292
+ this.setState("online");
293
+ }
294
+ /** A request did not reach the server: we are offline until proven otherwise. */
295
+ markFailure() {
296
+ this.deferRetry();
297
+ this.setState("offline");
298
+ }
299
+ /**
300
+ * Back off and try again later without claiming the connection is gone.
301
+ * This is what a 429 or a 503 deserves — the server answered, so the app
302
+ * is demonstrably online; it just should not hammer.
303
+ */
304
+ deferRetry() {
305
+ const jitter = .8 + Math.random() * .4;
306
+ this.retryAt = this.now() + this.backoffMs * jitter;
307
+ const delay = Math.max(0, this.retryAt - this.now());
308
+ this.backoffMs = Math.min(this.maxBackoffMs, this.backoffMs * 2);
309
+ this.scheduleRetry(delay);
310
+ }
311
+ /** Milliseconds until the next attempt is allowed; 0 when one is allowed now. */
312
+ msUntilRetry() {
313
+ if (this.state === "online") return 0;
314
+ return Math.max(0, this.retryAt - this.now());
315
+ }
316
+ onChange(listener) {
317
+ this.listeners.add(listener);
318
+ return () => this.listeners.delete(listener);
319
+ }
320
+ dispose() {
321
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
322
+ window.removeEventListener("online", this.handleOnline);
323
+ window.removeEventListener("offline", this.handleOffline);
324
+ }
325
+ this.clearPendingTimer();
326
+ this.listeners.clear();
327
+ this.onRetryDue = void 0;
328
+ this.onOnline = void 0;
329
+ }
330
+ scheduleRetry(delay) {
331
+ this.clearPendingTimer();
332
+ if (!this.onRetryDue) return;
333
+ this.timer = this.setTimer(() => {
334
+ this.timer = void 0;
335
+ this.onRetryDue?.();
336
+ }, Math.min(delay, MAX_TIMER_DELAY_MS));
337
+ unref(this.timer);
338
+ }
339
+ clearPendingTimer() {
340
+ if (this.timer !== void 0) {
341
+ this.clearTimer(this.timer);
342
+ this.timer = void 0;
343
+ }
344
+ }
345
+ setState(next) {
346
+ if (this.state === next) return;
347
+ this.state = next;
348
+ const online = this.isOnline();
349
+ for (const listener of this.listeners) listener(online);
350
+ }
351
+ };
352
+ /**
353
+ * A local evaluator for `FindParams`, so cached rows can answer a query the
354
+ * client has never sent to the server — and so a row written offline shows up
355
+ * in every filtered list it belongs to, not just in unfiltered ones.
356
+ *
357
+ * This mirrors the Postgres driver's semantics rather than JavaScript's:
358
+ *
359
+ * - Comparing against NULL is *unknown*, not false-or-true. `status != "done"`
360
+ * excludes rows where `status` is null, exactly as SQL does — a JS `!==`
361
+ * would have included them.
362
+ * - `ORDER BY` puts nulls last ascending and first descending, which is the
363
+ * Postgres default.
364
+ * - A filter value may arrive as a string (off a URL, say), so a string is
365
+ * compared numerically against a number and as an instant against a date.
366
+ * `["==", "3"]` matches the number `3`, as it does server-side. Two strings
367
+ * compare as text: `"01234"` is not `"1234"`.
368
+ *
369
+ * Two things it deliberately approximates, both flagged by
370
+ * {@link isExactlyEvaluable}: `searchString` becomes a case-insensitive
371
+ * substring scan over the row's string fields (the server runs real full-text
372
+ * search over the collection's configured columns), and `include` cannot be
373
+ * evaluated at all, because the related rows live in collections this query
374
+ * knows nothing about.
375
+ */
376
+ var collator = typeof Intl !== "undefined" && typeof Intl.Collator === "function" ? new Intl.Collator(void 0, {
377
+ numeric: false,
378
+ sensitivity: "variant"
379
+ }) : void 0;
380
+ function isNullish(value) {
381
+ return value === null || value === void 0;
382
+ }
383
+ /**
384
+ * Reduce a value to something comparable. Relations compare by the id they
385
+ * point at — the column holds a foreign key, so that is what the server
386
+ * compares too.
387
+ */
388
+ function toComparable(value) {
389
+ if (value instanceof Date) return value.getTime();
390
+ if (value instanceof EntityRelation) return value.id;
391
+ if (value && typeof value === "object") {
392
+ const record = value;
393
+ if (typeof record.__type === "string" && "id" in record) return record.id;
394
+ }
395
+ return value;
396
+ }
397
+ /**
398
+ * Three-way compare with SQL's type coercion but not its collation. Returns
399
+ * `undefined` when the two values are not ordered relative to each other,
400
+ * which is how NULL propagates through a comparison.
401
+ */
402
+ function compareValues(a, b) {
403
+ const left = toComparable(a);
404
+ const right = toComparable(b);
405
+ if (isNullish(left) || isNullish(right)) return void 0;
406
+ if (typeof left === "boolean" || typeof right === "boolean") return (left === true || left === "true" || left === 1 ? 1 : 0) - (right === true || right === "true" || right === 1 ? 1 : 0);
407
+ if (isNumeric(left) || isNumeric(right)) {
408
+ const leftNum = numericOrNaN(left);
409
+ const rightNum = numericOrNaN(right);
410
+ if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) return leftNum < rightNum ? -1 : leftNum > rightNum ? 1 : 0;
411
+ }
412
+ if (typeof left === "number" || typeof right === "number") {
413
+ const leftTime = toTime(left);
414
+ const rightTime = toTime(right);
415
+ if (leftTime !== void 0 && rightTime !== void 0) return leftTime < rightTime ? -1 : leftTime > rightTime ? 1 : 0;
416
+ }
417
+ const leftStr = String(left);
418
+ const rightStr = String(right);
419
+ if (collator) return collator.compare(leftStr, rightStr);
420
+ return leftStr < rightStr ? -1 : leftStr > rightStr ? 1 : 0;
421
+ }
422
+ function isNumeric(value) {
423
+ return typeof value === "number" || typeof value === "bigint";
424
+ }
425
+ function numericOrNaN(value) {
426
+ if (typeof value === "number") return value;
427
+ if (typeof value === "string" && value.trim() !== "") {
428
+ const n = Number(value);
429
+ return Number.isNaN(n) ? NaN : n;
430
+ }
431
+ if (typeof value === "bigint") return Number(value);
432
+ return NaN;
433
+ }
434
+ function toTime(value) {
435
+ if (typeof value === "number") return value;
436
+ if (typeof value === "string") {
437
+ const t = Date.parse(value);
438
+ return Number.isNaN(t) ? void 0 : t;
439
+ }
440
+ }
441
+ /**
442
+ * Equality with a string filter value allowed to name a number, a boolean or
443
+ * an instant, but never across NULL. Two strings are equal only when they are
444
+ * the same string, as text is in Postgres.
445
+ */
446
+ function looseEquals(a, b) {
447
+ const left = toComparable(a);
448
+ const right = toComparable(b);
449
+ if (isNullish(left) || isNullish(right)) return isNullish(left) && isNullish(right);
450
+ if (left === right) return true;
451
+ if (typeof left === "string" && typeof right === "string") return false;
452
+ return compareValues(left, right) === 0;
453
+ }
454
+ /**
455
+ * Translate a SQL `LIKE` pattern to an anchored regular expression.
456
+ * `%` matches any run of characters, `_` exactly one, and a backslash escapes
457
+ * either of them.
458
+ */
459
+ function likeToRegExp(pattern, caseInsensitive) {
460
+ let source = "^";
461
+ let lastWasWildcard = false;
462
+ for (let i = 0; i < pattern.length; i++) {
463
+ const char = pattern[i];
464
+ if (char === "\\" && i + 1 < pattern.length) {
465
+ source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
466
+ i++;
467
+ lastWasWildcard = false;
468
+ } else if (char === "%") {
469
+ if (!lastWasWildcard) source += "[\\s\\S]*";
470
+ lastWasWildcard = true;
471
+ } else if (char === "_") {
472
+ source += "[\\s\\S]";
473
+ lastWasWildcard = false;
474
+ } else {
475
+ source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
476
+ lastWasWildcard = false;
477
+ }
478
+ }
479
+ return new RegExp(source + "$", caseInsensitive ? "i" : "");
480
+ }
481
+ function asArray(value) {
482
+ if (Array.isArray(value)) return value;
483
+ if (value === void 0) return [];
484
+ return [value];
485
+ }
486
+ /** Evaluate one canonical operator against one row value. */
487
+ function matchesOperator(rowValue, op, filterValue) {
488
+ switch (op) {
489
+ case "is-null": return isNullish(rowValue);
490
+ case "is-not-null": return !isNullish(rowValue);
491
+ case "==": return looseEquals(rowValue, filterValue);
492
+ case "!=":
493
+ if (isNullish(rowValue)) return false;
494
+ return !looseEquals(rowValue, filterValue);
495
+ case "<":
496
+ case "<=":
497
+ case ">":
498
+ case ">=": {
499
+ const cmp = compareValues(rowValue, filterValue);
500
+ if (cmp === void 0) return false;
501
+ if (op === "<") return cmp < 0;
502
+ if (op === "<=") return cmp <= 0;
503
+ if (op === ">") return cmp > 0;
504
+ return cmp >= 0;
505
+ }
506
+ case "in":
507
+ if (isNullish(rowValue)) return false;
508
+ return asArray(filterValue).some((v) => looseEquals(rowValue, v));
509
+ case "not-in":
510
+ if (isNullish(rowValue)) return false;
511
+ return !asArray(filterValue).some((v) => looseEquals(rowValue, v));
512
+ case "array-contains":
513
+ if (!Array.isArray(rowValue)) return false;
514
+ return rowValue.some((v) => looseEquals(v, filterValue));
515
+ case "array-contains-any": {
516
+ if (!Array.isArray(rowValue)) return false;
517
+ const wanted = asArray(filterValue);
518
+ return rowValue.some((v) => wanted.some((w) => looseEquals(v, w)));
519
+ }
520
+ case "like":
521
+ case "not-like":
522
+ case "ilike":
523
+ case "not-ilike": {
524
+ if (isNullish(rowValue)) return false;
525
+ const insensitive = op === "ilike" || op === "not-ilike";
526
+ const negated = op === "not-like" || op === "not-ilike";
527
+ const matched = likeToRegExp(String(filterValue), insensitive).test(String(rowValue));
528
+ return negated ? !matched : matched;
529
+ }
530
+ default: return true;
531
+ }
532
+ }
533
+ function isTuple(value) {
534
+ return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && toCanonicalOp(value[0]) !== void 0;
535
+ }
536
+ /**
537
+ * One field's condition, as the tuples this evaluator can read.
538
+ *
539
+ * The single reading of a condition, for {@link matchesWhere} and
540
+ * {@link isExactlyEvaluable} both. Each used to have its own, and only the
541
+ * first was hardened: `{ status: undefined }` (a skipped filter) and the bare
542
+ * wire string `"eq.active"` both reached `.filter` on a non-array in the
543
+ * second and threw.
544
+ *
545
+ * `unreadable` is set when the condition is there but yields nothing this
546
+ * build understands — a wire string, an operator from a newer server. Such a
547
+ * condition filters nothing locally while the server does filter on it, so an
548
+ * answer that ignored it would be wider than the server's.
549
+ */
550
+ function conditionTuples(condition) {
551
+ if (condition === void 0) return {
552
+ tuples: [],
553
+ unreadable: false
554
+ };
555
+ if (isTuple(condition)) return {
556
+ tuples: [condition],
557
+ unreadable: false
558
+ };
559
+ if (!Array.isArray(condition)) return {
560
+ tuples: [],
561
+ unreadable: true
562
+ };
563
+ const tuples = condition.filter(isTuple);
564
+ return {
565
+ tuples,
566
+ unreadable: tuples.length !== condition.length || tuples.length === 0
567
+ };
568
+ }
569
+ /** Evaluate a `where` clause: every field, and every tuple on a field, AND-ed. */
570
+ function matchesWhere(row, where) {
571
+ if (!where) return true;
572
+ for (const [field, condition] of Object.entries(where)) {
573
+ const { tuples } = conditionTuples(condition);
574
+ for (const [rawOp, value] of tuples) {
575
+ const op = toCanonicalOp(rawOp) ?? rawOp;
576
+ if (!matchesOperator(row[field], op, value)) return false;
577
+ }
578
+ }
579
+ return true;
580
+ }
581
+ /** Evaluate a nested and/or tree. */
582
+ function matchesLogical(row, condition) {
583
+ if (!condition) return true;
584
+ if ("type" in condition) {
585
+ const children = condition.conditions ?? [];
586
+ if (children.length === 0) return true;
587
+ return condition.type === "or" ? children.some((c) => matchesLogical(row, c)) : children.every((c) => matchesLogical(row, c));
588
+ }
589
+ const op = toCanonicalOp(condition.operator) ?? condition.operator;
590
+ return matchesOperator(row[condition.column], op, condition.value);
591
+ }
592
+ /**
593
+ * Approximate the server's full-text search with a case-insensitive substring
594
+ * scan over the row's own string fields. Narrower than the real thing (no
595
+ * stemming, no configured search columns), and it never matches a field the
596
+ * cached row does not carry — a local list may therefore be missing rows the
597
+ * server would have returned, which is why {@link isExactlyEvaluable} refuses
598
+ * to call a search query exact.
599
+ *
600
+ * Every term has to match, but they may match different fields — the same rule
601
+ * both drivers apply, and the one that finds `sebastian melendez` on a row that
602
+ * keeps the two halves of a name in separate fields. Matching the typed string
603
+ * as a whole found neither that row nor, because the space was part of the
604
+ * needle, anything at all for `sebastian `.
605
+ */
606
+ function matchesSearch(row, searchString) {
607
+ if (!searchString) return true;
608
+ const terms = splitSearchTerms(searchString).map((term) => term.toLowerCase());
609
+ if (terms.length === 0) return true;
610
+ const values = Object.values(row);
611
+ return terms.every((term) => values.some((value) => {
612
+ if (typeof value === "string") return value.toLowerCase().includes(term);
613
+ if (typeof value === "number") return String(value).includes(term);
614
+ return false;
615
+ }));
616
+ }
617
+ /** Does this row belong in the result set for `params`, ignoring pagination? */
618
+ function matchesParams(row, params) {
619
+ if (!params) return true;
620
+ return matchesWhere(row, params.where) && matchesLogical(row, params.logical) && matchesSearch(row, params.searchString);
621
+ }
622
+ /**
623
+ * Sort in place, Postgres-style: nulls last ascending, first descending, with
624
+ * the row id as a tiebreak so paging through an unsorted-but-equal run does
625
+ * not shuffle rows between pages.
626
+ *
627
+ * The tiebreak runs *descending*, which is not a taste: every server-side sort
628
+ * ends on `id DESC` — `FetchService.buildOrderExpressions` appends it to make
629
+ * the ordering total, and the keyset cursor is built to match. This ran
630
+ * ascending, so two rows sharing a sort value came back from the local overlay
631
+ * in the opposite order to the server's, and {@link isLocallySortable} called
632
+ * that page exactly reproducible while it was not.
633
+ */
634
+ function sortRows(rows, orderBy) {
635
+ const keys = normalizeOrderBy(orderBy);
636
+ if (!keys) return rows;
637
+ return rows.sort((a, b) => {
638
+ for (const [field, direction = "asc"] of keys) {
639
+ const cmp = compareOnKey(a, b, field, direction);
640
+ if (cmp !== 0) return cmp;
641
+ }
642
+ return tiebreak(a, b);
643
+ });
644
+ }
645
+ /** One key's verdict: negative, positive, or 0 for "these two are equal here". */
646
+ function compareOnKey(a, b, field, direction) {
647
+ const av = a[field];
648
+ const bv = b[field];
649
+ const aNull = isNullish(toComparable(av));
650
+ const bNull = isNullish(toComparable(bv));
651
+ if (aNull || bNull) {
652
+ if (aNull && bNull) return 0;
653
+ return (aNull ? 1 : -1) * (direction === "desc" ? -1 : 1);
654
+ }
655
+ const cmp = compareValues(av, bv);
656
+ if (cmp === void 0 || cmp === 0) return 0;
657
+ return cmp * (direction === "desc" ? -1 : 1);
658
+ }
659
+ /** The last word, and the server's: `id DESC`. */
660
+ function tiebreak(a, b) {
661
+ const cmp = compareValues(a.id, b.id);
662
+ return cmp === void 0 ? 0 : -cmp;
663
+ }
664
+ /**
665
+ * Resolve `page`/`offset`/`limit` the way the server does.
666
+ *
667
+ * It did not: this defaulted an absent limit to 20 while `/api/data` pages by
668
+ * 50, so the same `observe()` answered with 20 rows from the local database and
669
+ * 50 from the network — a list that changed length depending on which side
670
+ * answered, with `page` striding differently on each. Delegated now, so the
671
+ * sentence above is true by construction rather than by agreement.
672
+ */
673
+ function resolvePagination(params) {
674
+ const { limit, offset } = resolveFindWindow(params);
675
+ return {
676
+ limit,
677
+ offset
678
+ };
679
+ }
680
+ /** `<`, `<=`, `>`, `>=` — the operators whose answer depends on a collation. */
681
+ var ORDERING_OPS = /* @__PURE__ */ new Set([
682
+ "<",
683
+ "<=",
684
+ ">",
685
+ ">="
686
+ ]);
687
+ /**
688
+ * Does any condition in this `where` clause order its operands — or say
689
+ * something this evaluator cannot read at all? Either way the local answer is
690
+ * not the server's.
691
+ */
692
+ function whereNotExact(where) {
693
+ if (!where) return false;
694
+ for (const condition of Object.values(where)) {
695
+ const { tuples, unreadable } = conditionTuples(condition);
696
+ if (unreadable) return true;
697
+ if (tuples.some(([op]) => ORDERING_OPS.has(toCanonicalOp(op) ?? op))) return true;
698
+ }
699
+ return false;
700
+ }
701
+ /** The same question, through an `and(...)`/`or(...)` tree. */
702
+ function logicalOrders(condition, depth = 0) {
703
+ if (!condition || depth > 32) return false;
704
+ if ("type" in condition) return (condition.conditions ?? []).some((c) => logicalOrders(c, depth + 1));
705
+ return ORDERING_OPS.has(condition.operator);
706
+ }
707
+ /**
708
+ * Can a locally evaluated answer to `params` be trusted to match the server's,
709
+ * assuming the cache holds every row of the collection?
710
+ *
711
+ * `include` pulls in rows from other collections that this evaluator never
712
+ * sees, and `searchString` is only approximated — both make the local answer a
713
+ * best effort rather than an equivalent one.
714
+ *
715
+ * **Ordering comparisons are refused, and that is the interesting one.**
716
+ * `compareValues` falls back to an `Intl.Collator` for operands it cannot read
717
+ * as numbers or instants. PostgreSQL orders text by the *database's* collation,
718
+ * which is a property of the server this process has never been told: under the
719
+ * C collation `'apple' < 'Banana'` is false, under `en_US.UTF-8` it is true,
720
+ * and the collator says true. So `["<", "Banana"]` selects a different set here
721
+ * than it does there — silently, and in whichever direction the deployment
722
+ * happens to have been created.
723
+ *
724
+ * The refusal covers *every* ordering comparison rather than only the ones with
725
+ * a string operand, because the operand type does not settle it: a numeric
726
+ * bound against a text column (`["<", 10]` on a `varchar`) also reaches the
727
+ * collator, and nothing in `params` says what the column holds. Conservative on
728
+ * purpose — the cost is that a query combining an ordering filter with
729
+ * *unsynced local writes* stops placing those writes optimistically, which is a
730
+ * degraded answer rather than a wrong one. Claiming exactness we do not have is
731
+ * the other way round.
732
+ *
733
+ * This says nothing about ordering *results*; that is a separate claim with a
734
+ * separate answer, because a sort changes which rows come first and not which
735
+ * rows match. See {@link isLocallySortable}.
736
+ */
737
+ /**
738
+ * Whether an `include` asks for anything, in any of its spellings.
739
+ *
740
+ * `include` is a list of names, a list of dotted paths, or a tree — and only
741
+ * the first two have a `length`. A `.length > 0` test read the tree form as
742
+ * "no relations", which is the answer that makes the local cache claim it can
743
+ * evaluate a relational query exactly.
744
+ */
745
+ function hasAnyInclude(include) {
746
+ if (!include) return false;
747
+ if (Array.isArray(include)) return include.length > 0;
748
+ return Object.keys(include).length > 0;
749
+ }
750
+ function isExactlyEvaluable(params) {
751
+ if (!params) return true;
752
+ if (hasAnyInclude(params.include)) return false;
753
+ if (params.searchString) return false;
754
+ if (params.vectorSearch) return false;
755
+ if (whereNotExact(params.where)) return false;
756
+ if (logicalOrders(params.logical)) return false;
757
+ if (whereReachesThroughRelation(params.where)) return false;
758
+ if (logicalReachesThroughRelation(params.logical)) return false;
759
+ return true;
760
+ }
761
+ /** Does any filter key reach outside this row — `applications.status`? */
762
+ function whereReachesThroughRelation(where) {
763
+ return where ? Object.keys(where).some((field) => field.includes(".")) : false;
764
+ }
765
+ /** The same question, through an `and(...)`/`or(...)` tree. */
766
+ function logicalReachesThroughRelation(condition, depth = 0) {
767
+ if (!condition || depth > 32) return false;
768
+ if ("type" in condition) return (condition.conditions ?? []).some((c) => logicalReachesThroughRelation(c, depth + 1));
769
+ return typeof condition.column === "string" && condition.column.includes(".");
770
+ }
771
+ /**
772
+ * Would sorting `rows` locally reproduce the order the server would have sent?
773
+ *
774
+ * Asked of the rows rather than of the query, because unlike a filter this one
775
+ * *is* decidable from the data in hand: {@link compareValues} reaches the
776
+ * collator only when neither operand is a number, and `toComparable` has
777
+ * already turned dates and relations into numbers and ids by then. If every
778
+ * value on the sort column normalises to a number, the collator is unreachable
779
+ * and the local order is the server's order.
780
+ *
781
+ * A text column is therefore refused — see {@link isExactlyEvaluable} for why
782
+ * the two cannot be made to agree — including one holding only digits, which
783
+ * Postgres orders as text ("10", "100", "9").
784
+ *
785
+ * Nulls are fine either way: they are ordered by an explicit rule (last
786
+ * ascending, first descending) that matches Postgres and never reaches the
787
+ * comparator.
788
+ */
789
+ function isLocallySortable(rows, orderBy) {
790
+ const keys = normalizeOrderBy(orderBy);
791
+ if (!keys) return true;
792
+ if (keys.some(([field]) => parseRelationAggregateSort(field))) return false;
793
+ return keys.every(([field]) => rows.every((row) => {
794
+ const value = toComparable(row[field]);
795
+ if (isNullish(value)) return true;
796
+ if (typeof value === "number" || typeof value === "boolean") return true;
797
+ if (typeof value === "bigint") return true;
798
+ return false;
799
+ }));
800
+ }
801
+ /** Run a full query — filter, sort, paginate — over a set of rows. */
802
+ function runLocalQuery(rows, params) {
803
+ const matched = rows.filter((row) => matchesParams(row, params));
804
+ sortRows(matched, params?.orderBy);
805
+ const { limit, offset } = resolvePagination(params);
806
+ const page = matched.slice(offset, offset + limit);
807
+ return {
808
+ data: page,
809
+ meta: {
810
+ total: matched.length,
811
+ limit,
812
+ offset,
813
+ hasMore: offset + page.length < matched.length
814
+ }
815
+ };
816
+ }
817
+ function offlineError(message) {
818
+ return new RebaseApiError(message, {
819
+ status: 0,
820
+ code: "OFFLINE"
821
+ });
822
+ }
823
+ /**
824
+ * What a write discarded because an earlier write it depends on was refused is
825
+ * reported with. It was never sent; the refusal that doomed it is its `cause`.
826
+ */
827
+ function dependencyRejected(refused, error) {
828
+ return new RebaseApiError(`Not sent: an earlier queued write to the same row was refused (${error.message}).`, {
829
+ status: error instanceof RebaseApiError ? error.status : void 0,
830
+ code: "DEPENDENCY_REJECTED",
831
+ details: { rejectedMutationId: refused.mutationId },
832
+ cause: error
833
+ });
834
+ }
835
+ /**
836
+ * A refusal about what a write contains — a field the collection no longer
837
+ * has, one the caller may not write, a value a constraint refuses — rather
838
+ * than about the row or the caller as a whole.
839
+ */
840
+ function isFieldRefusal(error) {
841
+ if (!(error instanceof RebaseApiError)) return false;
842
+ return error.status === 400 || error.status === 403 || error.status === 422;
843
+ }
844
+ /**
845
+ * `value` with every occurrence of `from` replaced by `to`, walking plain
846
+ * objects and arrays — and whether anything was replaced.
847
+ *
848
+ * Only ever called with an id the SDK minted for an offline create (a UUID, or
849
+ * a negative number from the clock), which nothing but a reference to that row
850
+ * can equal. That is what makes replacing by value sound without knowing which
851
+ * columns are references.
852
+ */
853
+ function replaceMintedId(value, from, to) {
854
+ if (value === from) return {
855
+ value: to,
856
+ changed: true
857
+ };
858
+ if (Array.isArray(value)) {
859
+ let changed = false;
860
+ const out = value.map((item) => {
861
+ const replaced = replaceMintedId(item, from, to);
862
+ changed ||= replaced.changed;
863
+ return replaced.value;
864
+ });
865
+ return changed ? {
866
+ value: out,
867
+ changed
868
+ } : {
869
+ value,
870
+ changed
871
+ };
872
+ }
873
+ if (isPlainObject(value)) {
874
+ let changed = false;
875
+ const out = {};
876
+ for (const [key, item] of Object.entries(value)) {
877
+ const replaced = replaceMintedId(item, from, to);
878
+ changed ||= replaced.changed;
879
+ setOwn(out, key, replaced.value);
880
+ }
881
+ return changed ? {
882
+ value: out,
883
+ changed
884
+ } : {
885
+ value,
886
+ changed
887
+ };
888
+ }
889
+ return {
890
+ value,
891
+ changed: false
892
+ };
893
+ }
894
+ /**
895
+ * A plain object from any realm — a queued payload read back from the store is
896
+ * a structured clone, whose `Object.prototype` need not be this one. A class
897
+ * instance (a `GeoPoint`, a `Date`) is not walked.
898
+ */
899
+ function isPlainObject(value) {
900
+ if (value === null || typeof value !== "object") return false;
901
+ const proto = Object.getPrototypeOf(value);
902
+ return proto === null || Object.getPrototypeOf(proto) === null;
903
+ }
904
+ /** Whether a queued write's payload mentions `id` anywhere — see {@link replaceMintedId}. */
905
+ function mentionsMintedId(op, id) {
906
+ return [op.data, ...(op.updates ?? []).map((u) => u.data)].some((payload) => replaceMintedId(payload, id, "").changed);
907
+ }
908
+ /**
909
+ * A page that local rows were added to, cut back to the `limit` it was asked
910
+ * with — as a refetch would return it.
911
+ *
912
+ * Sorted, the page is cut where the order puts it. Unsorted, the rows created
913
+ * here (the last `added`) stand where they were placed, and the server's rows
914
+ * make room from the end of the page.
915
+ */
916
+ function fitToLimit(rows, limit, added, sorted) {
917
+ if (!(limit > 0) || rows.length <= limit) return rows;
918
+ if (sorted || added === 0) return rows.slice(0, limit);
919
+ const local = rows.slice(rows.length - added);
920
+ return [...rows.slice(0, rows.length - added).slice(0, Math.max(0, limit - local.length)), ...local].slice(0, limit);
921
+ }
922
+ /**
923
+ * Call the app back without letting it break the engine.
924
+ *
925
+ * A status badge that throws while rendering used to abort a replay half way
926
+ * (and skip every listener registered after it); the replay's own failure
927
+ * handling then had an error that was never about the replay. The app's error
928
+ * is still reported, on the console, where an uncaught one would have gone.
929
+ */
930
+ function callSafely(callback) {
931
+ try {
932
+ callback();
933
+ } catch (error) {
934
+ console.error("[rebase offline] A listener threw:", error);
935
+ }
936
+ }
937
+ /** The server has no such row — deleted by someone else, or never visible to this user. */
938
+ /** A `412`: the row is no longer the version an `If-Match` named. */
939
+ function isPreconditionFailed(error) {
940
+ return error instanceof RebaseApiError && error.status === 412;
941
+ }
942
+ function isNotFoundError(error) {
943
+ return error instanceof RebaseApiError && error.status === 404;
944
+ }
945
+ function generateOfflineStringId() {
946
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
947
+ return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
948
+ }
949
+ /**
950
+ * The last temporary numeric id minted, so the next one is smaller.
951
+ *
952
+ * Module-scoped and strictly decreasing: two temporary ids in the same session
953
+ * must not collide, and neither must one minted a millisecond after another.
954
+ */
955
+ var lastOfflineNumericId = 0;
956
+ /**
957
+ * A temporary id for a collection whose ids are numbers.
958
+ *
959
+ * **Negative**, which is the convention every local-first engine settles on for
960
+ * the same reason: a real key is a positive integer or an identity sequence, so
961
+ * the sign alone says "this one is not the server's yet" — and it stays a
962
+ * number, which is what matters. A UUID string was minted for every collection,
963
+ * so a `create` while offline handed back `{ id: "526e2faf-…" }` on a row the
964
+ * generated types declare `id: number`. Anything that put that in a URL, a
965
+ * foreign key or an `id.toFixed()` was wrong or threw until the replay
966
+ * reconciled it — and the SDK's own types said it could not happen.
967
+ */
968
+ function generateOfflineNumericId() {
969
+ const candidate = -Date.now();
970
+ lastOfflineNumericId = lastOfflineNumericId === 0 ? candidate : Math.min(candidate, lastOfflineNumericId - 1);
971
+ return lastOfflineNumericId;
972
+ }
973
+ /**
974
+ * The row an optimistic write puts in the cache before the server has seen it.
975
+ *
976
+ * It is NOT an `M`, and saying so is the point of collecting this in one place.
977
+ * `M` is the row as the database has it — every server default, every generated
978
+ * column, every `afterRead` transform. What an optimistic write has is the
979
+ * fields the caller supplied (plus, on an update, whatever was already cached)
980
+ * and an id, which may itself be a locally-minted placeholder.
981
+ *
982
+ * The two are reconciled when the queued write drains and the server's row
983
+ * replaces this one. Until then the SDK hands the caller this, under `M`,
984
+ * because that is what the offline API's return type promises — and there is
985
+ * nothing in this file that can make that promise true.
986
+ *
987
+ * So: one deliberate assertion, named, rather than five spread across the
988
+ * create / createMany / update / upsert paths, each free to assemble the fields
989
+ * differently. Fixing it properly means the offline surface saying what it
990
+ * actually returns, which is a change to its public types.
991
+ */
992
+ function optimisticRow(fields, id) {
993
+ return {
994
+ ...fields,
995
+ id
996
+ };
997
+ }
998
+ /**
999
+ * Whether a query answers with something narrower than whole rows: `fields`
1000
+ * returns only the columns it names (and the key), and `distinct` collapses
1001
+ * rows into their distinct projections. Either way what comes back is not a
1002
+ * row of the collection, and must not be stored as one.
1003
+ */
1004
+ function isProjection(params) {
1005
+ return (params?.fields?.length ?? 0) > 0 || params?.distinct === true;
1006
+ }
1007
+ /** `row` narrowed to `keys`: a local row in the shape a projection has. */
1008
+ function pick(row, keys) {
1009
+ const out = {};
1010
+ for (const key of keys) if (Object.hasOwn(row, key)) setOwn(out, key, row[key]);
1011
+ return out;
1012
+ }
1013
+ /** A snapshot as read back from the store, its projected rows revived. */
1014
+ function readSnapshot(value) {
1015
+ const snapshot = value;
1016
+ return snapshot.rows ? {
1017
+ ...snapshot,
1018
+ rows: snapshot.rows.map((row) => hydrateRow(row))
1019
+ } : snapshot;
1020
+ }
1021
+ var MISSING = "\0missing";
1022
+ /**
1023
+ * Replays to spend on a mutation whose idempotency key the server is still
1024
+ * holding, when the app has not asked for more.
1025
+ *
1026
+ * Retries double from a second and cap at the sync interval, so the default
1027
+ * budget of five covers about half a minute — less than the lease a server
1028
+ * gives a claim nobody came back for. This many outlast it with room for a slow
1029
+ * batch, and the count is what stops a server that never releases the key from
1030
+ * blocking the queue behind it indefinitely.
1031
+ */
1032
+ var IN_PROGRESS_MIN_RETRIES = 12;
1033
+ /** The scope of whatever is cached and queued while nobody is signed in. */
1034
+ var SIGNED_OUT_SCOPE = "anon";
1035
+ /** The most rows one folded replay sends: the server's default bulk limit. */
1036
+ var MAX_REPLAY_BATCH = 1e3;
1037
+ /**
1038
+ * Where {@link OfflineConfig.keepLastUser} keeps the last signed-in user's id.
1039
+ * In the cache area but under no user's scope, so clearing one user's data
1040
+ * leaves it alone.
1041
+ */
1042
+ var LAST_USER_KEY = "rebase-offline:last-user";
1043
+ var OfflineManager = class {
1044
+ store;
1045
+ maxCachedQueries;
1046
+ maxCachedRows;
1047
+ maxRetries;
1048
+ onSyncError;
1049
+ onConflict;
1050
+ createInner;
1051
+ inners = /* @__PURE__ */ new Map();
1052
+ connectivity;
1053
+ keepLastUser;
1054
+ replayBatchSize;
1055
+ scope = SIGNED_OUT_SCOPE;
1056
+ /**
1057
+ * The scope is the last signed-in user's, taken without a confirmed
1058
+ * session ({@link resumeLastUser}). Nothing replays while it is set: the
1059
+ * queue is sent under the credentials of whoever is signed in, and nobody
1060
+ * is yet.
1061
+ */
1062
+ unverified = false;
1063
+ /**
1064
+ * Set while the manager does not know whose data it holds: from
1065
+ * {@link holdScope} until the next {@link setScope}. `scope` is not
1066
+ * consulted meanwhile — no operation gets a {@link ScopeTicket}, and
1067
+ * nothing reads or writes the store.
1068
+ */
1069
+ scopeHold;
1070
+ /**
1071
+ * Bumped by every change of user, and by `clear()`.
1072
+ *
1073
+ * The load paths always re-checked the scope after their awaits; the write
1074
+ * paths did not. A `find` sent for user A that answered after B had signed
1075
+ * in wrote A's RLS-filtered rows into B's local database, under B's keys,
1076
+ * and showed them to B's observers; a replay acknowledged after the switch
1077
+ * dequeued a key under B's scope that did not exist, so A's write stayed
1078
+ * queued and was sent again when A came back. Every write now carries the
1079
+ * {@link ScopeTicket} its operation started under, and one that is stale
1080
+ * touches nothing that belongs to the new user.
1081
+ */
1082
+ scopeEpoch = 0;
1083
+ /** The local database: normalized rows and query snapshots per collection. */
1084
+ collections = /* @__PURE__ */ new Map();
1085
+ /** In-memory mirror of the current scope's queue, in replay order. */
1086
+ queueEntries = [];
1087
+ get queue() {
1088
+ return this.queueEntries;
1089
+ }
1090
+ set queue(next) {
1091
+ this.queueEntries = next;
1092
+ this.pendingIndex = void 0;
1093
+ }
1094
+ /**
1095
+ * Per collection, the row ids the queue writes to and the ones it creates
1096
+ * — {@link hasPending} and {@link isLocallyCreated}, which answering a
1097
+ * page asks once or twice per row. Each used to scan the whole queue: a
1098
+ * 500-row page against 200 queued writes read 80,000 queue entries, on
1099
+ * every emit. Built on first use after the queue changes; whatever changes
1100
+ * it in place calls {@link queueChangedInPlace}.
1101
+ */
1102
+ pendingIndex;
1103
+ /**
1104
+ * The mutations currently on the wire: one, or the members of a folded
1105
+ * run (`replayBatchSize`).
1106
+ *
1107
+ * `flush` awaits `replay(op)` with `op` still at the head of `queue`, so for
1108
+ * the whole duration of that request the in-flight op is also the queue's
1109
+ * *tail* whenever it is the only entry. Both shortcuts in `enqueue` reach
1110
+ * for the tail, and neither may touch an op the server is already reading:
1111
+ *
1112
+ * - Coalescing an update into it mutates a payload that has already been
1113
+ * serialized and sent, and `drop` then removes the whole entry on ACK —
1114
+ * so the second edit is neither sent nor kept. A silently lost write.
1115
+ * - Cancelling it out against a delete assumes the server never saw the
1116
+ * create. It is seeing it right now, so the row would be created and the
1117
+ * delete never queued — an orphan row nothing will ever remove.
1118
+ *
1119
+ * Guarding on ids rather than on a boolean is what keeps this correct
1120
+ * when a folded run sends several at once, its last member the tail.
1121
+ */
1122
+ inFlight = /* @__PURE__ */ new Set();
1123
+ queueLoad;
1124
+ /** Serializes enqueues so concurrent writes keep the order the app made them. */
1125
+ enqueueChain = Promise.resolve();
1126
+ flushPromise;
1127
+ queueListeners = /* @__PURE__ */ new Set();
1128
+ statusListeners = /* @__PURE__ */ new Set();
1129
+ observers = /* @__PURE__ */ new Map();
1130
+ refreshPending = /* @__PURE__ */ new Set();
1131
+ revCounter = 0;
1132
+ disposed = false;
1133
+ /** Whether {@link requestPersistence} has asked. */
1134
+ persistenceRequested = false;
1135
+ currentStatus = {
1136
+ online: true,
1137
+ syncing: false,
1138
+ pending: 0
1139
+ };
1140
+ channel;
1141
+ tabId = createMutationId();
1142
+ api;
1143
+ constructor(config, createInner) {
1144
+ this.store = config.store ?? (typeof indexedDB !== "undefined" ? new IndexedDBOfflineStore() : new MemoryOfflineStore());
1145
+ this.maxCachedQueries = config.maxCachedQueriesPerCollection ?? 50;
1146
+ this.maxCachedRows = config.maxCachedRowsPerCollection ?? 5e3;
1147
+ this.maxRetries = config.maxRetries ?? 5;
1148
+ this.onSyncError = config.onSyncError;
1149
+ this.onConflict = config.onConflict;
1150
+ this.keepLastUser = config.keepLastUser === true;
1151
+ const batch = Math.floor(config.replayBatchSize ?? 1);
1152
+ this.replayBatchSize = Number.isFinite(batch) ? Math.min(Math.max(batch, 1), MAX_REPLAY_BATCH) : 1;
1153
+ this.createInner = createInner;
1154
+ const maxBackoffMs = config.syncIntervalMs ?? 6e4;
1155
+ this.connectivity = new ConnectivityMonitor({
1156
+ maxBackoffMs: Math.max(1e3, maxBackoffMs),
1157
+ respectBackoff: maxBackoffMs > 0
1158
+ });
1159
+ if (maxBackoffMs > 0) this.connectivity.onRetryDue = () => {
1160
+ this.sync().catch(() => void 0);
1161
+ };
1162
+ this.connectivity.onOnline = () => {
1163
+ this.sync().catch(() => void 0);
1164
+ };
1165
+ this.connectivity.onChange((online) => {
1166
+ this.patchStatus({ online });
1167
+ if (online) this.revalidateAll();
1168
+ });
1169
+ this.currentStatus.online = this.connectivity.isOnline();
1170
+ if ((config.crossTab ?? this.store instanceof IndexedDBOfflineStore) && typeof BroadcastChannel !== "undefined") try {
1171
+ this.channel = new BroadcastChannel("rebase-offline");
1172
+ this.channel.onmessage = (event) => this.onBroadcast(event.data);
1173
+ unref(this.channel);
1174
+ } catch {}
1175
+ this.api = {
1176
+ sync: () => this.sync(),
1177
+ pending: async (options) => {
1178
+ if (options?.orphaned) {
1179
+ await this.scopeKnown();
1180
+ if (this.scope === SIGNED_OUT_SCOPE) return [];
1181
+ return await this.readQueue(`${SIGNED_OUT_SCOPE}|`);
1182
+ }
1183
+ await this.ensureQueueLoaded();
1184
+ return this.queue.map((m) => hydrateMutation(structuredClone(dehydrateMutation(m))));
1185
+ },
1186
+ status: () => ({ ...this.currentStatus }),
1187
+ onStatusChange: (listener) => {
1188
+ this.statusListeners.add(listener);
1189
+ return () => this.statusListeners.delete(listener);
1190
+ },
1191
+ clear: async (options) => {
1192
+ await this.scopeKnown();
1193
+ if (options?.orphaned) {
1194
+ if (this.scope === SIGNED_OUT_SCOPE) return;
1195
+ await this.store.clear(`${SIGNED_OUT_SCOPE}|`);
1196
+ this.broadcast({ type: "queue" }, SIGNED_OUT_SCOPE);
1197
+ return;
1198
+ }
1199
+ this.scopeEpoch++;
1200
+ await this.store.clear(`${this.scope}|`);
1201
+ this.queue = [];
1202
+ this.resetCollections();
1203
+ this.patchStatus({
1204
+ pending: 0,
1205
+ lastError: void 0
1206
+ });
1207
+ this.notifyQueue();
1208
+ for (const slug of this.observers.keys()) this.notifyCollection(slug, false);
1209
+ },
1210
+ onQueueChange: (listener) => {
1211
+ this.queueListeners.add(listener);
1212
+ return () => this.queueListeners.delete(listener);
1213
+ }
1214
+ };
1215
+ }
1216
+ /**
1217
+ * Cache and queue are partitioned per signed-in user: cached rows are
1218
+ * RLS-filtered for the user who fetched them, and queued writes must
1219
+ * replay under the credentials that made them — so neither may ever leak
1220
+ * across a sign-out/sign-in on a shared browser.
1221
+ */
1222
+ setScope(uid, options) {
1223
+ const next = uid || SIGNED_OUT_SCOPE;
1224
+ const unverified = next !== SIGNED_OUT_SCOPE && options?.unverified === true;
1225
+ if (!unverified) this.rememberUser(uid);
1226
+ const hold = this.scopeHold;
1227
+ if (!hold && next === this.scope) {
1228
+ if (this.unverified && !unverified) {
1229
+ this.unverified = false;
1230
+ this.patchStatus({ unverifiedUser: void 0 });
1231
+ this.sync().catch(() => void 0);
1232
+ }
1233
+ return;
1234
+ }
1235
+ this.scope = next;
1236
+ this.unverified = unverified;
1237
+ this.scopeHold = void 0;
1238
+ this.leaveScope();
1239
+ this.patchStatus({ unverifiedUser: unverified || void 0 });
1240
+ hold?.release();
1241
+ this.revalidateAll();
1242
+ this.sync().catch(() => void 0);
1243
+ }
1244
+ /** Whether the manager was created with {@link OfflineConfig.keepLastUser}. */
1245
+ get keepsLastUser() {
1246
+ return this.keepLastUser;
1247
+ }
1248
+ /**
1249
+ * Take the scope of the last user who signed in on this device, without a
1250
+ * session — for a load whose session restore could not reach the server.
1251
+ * See {@link OfflineConfig.keepLastUser}. With no user remembered (or the
1252
+ * option off) this is the signed-out scope, as it would have been anyway.
1253
+ *
1254
+ * Only while the scope is still held: an auth event that settled it while
1255
+ * the id was being read is newer news.
1256
+ */
1257
+ async resumeLastUser() {
1258
+ let uid;
1259
+ if (this.keepLastUser) try {
1260
+ const entry = await this.store.getCache(LAST_USER_KEY);
1261
+ if (typeof entry?.value === "string" && entry.value) uid = entry.value;
1262
+ } catch {}
1263
+ if (!this.scopeHold) return;
1264
+ this.setScope(uid, { unverified: uid !== void 0 });
1265
+ }
1266
+ /**
1267
+ * Keep the signed-in user's id for {@link resumeLastUser}, or forget it at
1268
+ * sign-out. Forgetting does not depend on the option, so turning it off
1269
+ * does not leave the last id behind for good.
1270
+ */
1271
+ rememberUser(uid) {
1272
+ (uid ? this.keepLastUser ? this.store.setCache(LAST_USER_KEY, {
1273
+ value: uid,
1274
+ cachedAt: Date.now()
1275
+ }) : void 0 : this.store.deleteCache([LAST_USER_KEY]))?.catch(() => void 0);
1276
+ }
1277
+ /**
1278
+ * Forget whose data this is until the next {@link setScope}.
1279
+ *
1280
+ * For the time before the client knows who is signed in — a session being
1281
+ * restored on load. Taking that time for "signed out" would put the user's
1282
+ * own writes in the signed-out queue, which no sign-in replays, and show
1283
+ * them the signed-out local database instead of theirs.
1284
+ *
1285
+ * Meanwhile the manager has no scope at all. Every operation — a read, a
1286
+ * write, a replay, `pending()`, `clear()` — waits for one and then runs
1287
+ * for whoever it turns out to be, and nothing reads or writes the store.
1288
+ * A realtime frame arriving in between is handed on as the server sent it
1289
+ * and kept nowhere.
1290
+ */
1291
+ holdScope() {
1292
+ if (this.scopeHold) return;
1293
+ let release;
1294
+ const known = new Promise((resolve) => {
1295
+ release = resolve;
1296
+ });
1297
+ this.scopeHold = {
1298
+ known,
1299
+ release
1300
+ };
1301
+ this.leaveScope();
1302
+ }
1303
+ /** Let go of the current user's rows and queue: in memory, and on screen. */
1304
+ leaveScope() {
1305
+ this.scopeEpoch++;
1306
+ this.queueLoad = void 0;
1307
+ this.queue = [];
1308
+ this.resetCollections();
1309
+ this.patchStatus({
1310
+ pending: 0,
1311
+ lastError: void 0
1312
+ });
1313
+ this.notifyQueue();
1314
+ for (const slug of this.observers.keys()) this.notifyCollection(slug, false);
1315
+ }
1316
+ /** Settles once the manager knows whose data it holds — see {@link holdScope}. */
1317
+ async scopeKnown() {
1318
+ while (this.scopeHold) await this.scopeHold.known;
1319
+ }
1320
+ /**
1321
+ * The user an operation is running for — see {@link scopeEpoch} — or
1322
+ * nothing while that is not known ({@link holdScope}). An operation takes
1323
+ * it as `this.ticket() ?? await this.nextTicket()`, before its first await.
1324
+ */
1325
+ ticket() {
1326
+ if (this.scopeHold) return void 0;
1327
+ return {
1328
+ scope: this.scope,
1329
+ epoch: this.scopeEpoch
1330
+ };
1331
+ }
1332
+ /** The ticket of whoever the held scope turns out to be. */
1333
+ async nextTicket() {
1334
+ await this.scopeKnown();
1335
+ return {
1336
+ scope: this.scope,
1337
+ epoch: this.scopeEpoch
1338
+ };
1339
+ }
1340
+ /** Whether an operation's user is still the signed-in one. */
1341
+ isCurrent(ticket) {
1342
+ return ticket.epoch === this.scopeEpoch;
1343
+ }
1344
+ /**
1345
+ * Throw away every local row, for a scope change or an explicit clear.
1346
+ *
1347
+ * The state objects are replaced rather than emptied, so a load still in
1348
+ * flight for the previous user fails its identity check and discards what
1349
+ * it read instead of grafting it onto the new one. The replacements are
1350
+ * marked ready: nothing needs loading until something asks, and observers
1351
+ * have to be told *now* that the rows they are showing are gone.
1352
+ */
1353
+ resetCollections() {
1354
+ const slugs = [...this.collections.keys()];
1355
+ this.collections = /* @__PURE__ */ new Map();
1356
+ for (const slug of slugs) this.collections.set(slug, {
1357
+ rows: /* @__PURE__ */ new Map(),
1358
+ snapshots: /* @__PURE__ */ new Map(),
1359
+ fresh: /* @__PURE__ */ new Set(),
1360
+ freshRows: /* @__PURE__ */ new Set(),
1361
+ absent: /* @__PURE__ */ new Set(),
1362
+ ready: true
1363
+ });
1364
+ }
1365
+ /** Release listeners, timers and the cross-tab channel (client.close()). */
1366
+ dispose() {
1367
+ this.disposed = true;
1368
+ this.connectivity.dispose();
1369
+ try {
1370
+ this.channel?.close();
1371
+ } catch {}
1372
+ this.observers.clear();
1373
+ this.queueListeners.clear();
1374
+ this.statusListeners.clear();
1375
+ }
1376
+ wrap(slug, inner) {
1377
+ this.inners.set(slug, inner);
1378
+ /**
1379
+ * `find`, and the page reader of `iterate()` / `findAll()` when `walk`
1380
+ * is set.
1381
+ *
1382
+ * A walk reads `meta.hasMore` as the server's word on whether to go
1383
+ * on. A page the server never answered here is evaluated over whatever
1384
+ * rows happen to be cached, and its `hasMore` is about those rows — so
1385
+ * offline, `findAll()` stopped at the first such page and returned a
1386
+ * fraction of the collection as the whole of it, with nothing to tell
1387
+ * the two apart. A walk refuses that page instead.
1388
+ */
1389
+ const findPage = async (params, walk) => {
1390
+ const ticket = this.ticket() ?? await this.nextTicket();
1391
+ const state = await this.ensureCollection(slug);
1392
+ if (this.connectivity.shouldAttempt()) try {
1393
+ const res = await inner.find(params);
1394
+ this.connectivity.markSuccess();
1395
+ if (!this.isCurrent(ticket)) return res;
1396
+ await this.ingest(slug, res.data ?? [], ticket, isProjection(params));
1397
+ if (!this.isCurrent(ticket)) return res;
1398
+ const snapshot = this.recordSnapshot(slug, params, res, ticket);
1399
+ const answer = this.answer(slug, params, snapshot);
1400
+ this.notifyCollection(slug, false);
1401
+ return {
1402
+ data: answer.data,
1403
+ meta: answer.meta
1404
+ };
1405
+ } catch (error) {
1406
+ if (!isNetworkError(error)) {
1407
+ if (isRetryableError(error) && this.hasLocalAnswer(state, slug, params) && !(walk && !this.snapshotFor(slug, params))) {
1408
+ const answer = this.answer(slug, params, this.snapshotFor(slug, params));
1409
+ return {
1410
+ data: answer.data,
1411
+ meta: answer.meta
1412
+ };
1413
+ }
1414
+ throw error;
1415
+ }
1416
+ this.connectivity.markFailure();
1417
+ }
1418
+ if (walk && !this.snapshotFor(slug, params)) throw unwalkable();
1419
+ const answer = this.localFind(slug, params);
1420
+ this.notifyCollection(slug, false);
1421
+ return {
1422
+ data: answer.data,
1423
+ meta: answer.meta
1424
+ };
1425
+ };
1426
+ const unwalkable = () => offlineError(`Offline: a page of "${slug}" the server has not answered on this device is needed to read the collection to the end, and the local database cannot say what is on it.`);
1427
+ const wrapped = {
1428
+ find: (params) => findPage(params, false),
1429
+ iterate: (params) => paginateFind((p) => findPage(p, true), params, slug),
1430
+ findAll: (params) => collectAllPages((p) => findPage(p, true), params, slug),
1431
+ findById: async (id, options) => {
1432
+ const ticket = this.ticket() ?? await this.nextTicket();
1433
+ await this.ensureCollection(slug);
1434
+ if (this.connectivity.shouldAttempt()) try {
1435
+ const row = await inner.findById(id, options);
1436
+ this.connectivity.markSuccess();
1437
+ if (!this.isCurrent(ticket)) return row;
1438
+ if (row !== void 0) await this.ingest(slug, [row], ticket);
1439
+ else if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
1440
+ this.notifyCollection(slug, false);
1441
+ return this.localRow(slug, id);
1442
+ } catch (error) {
1443
+ if (!isNetworkError(error)) throw error;
1444
+ this.connectivity.markFailure();
1445
+ }
1446
+ const local = this.localRow(slug, id);
1447
+ if (local !== void 0 || this.hasPending(slug, id)) return local;
1448
+ if (this.collections.get(slug)?.absent.has(String(id))) return void 0;
1449
+ throw offlineError(`Offline: "${slug}" row ${String(id)} is not in the local database.`);
1450
+ },
1451
+ get: async (id) => {
1452
+ const row = await wrapped.findById(id);
1453
+ if (row === void 0) throw new RebaseApiError(`No record with id ${JSON.stringify(String(id))} in "${slug}".`, {
1454
+ status: 404,
1455
+ code: "NOT_FOUND"
1456
+ });
1457
+ return row;
1458
+ },
1459
+ create: async (data, id, options) => {
1460
+ const ticket = this.ticket() ?? await this.nextTicket();
1461
+ await this.ensureCollection(slug);
1462
+ const idempotencyKey = options?.idempotencyKey ?? createMutationId();
1463
+ let sent = false;
1464
+ if (this.connectivity.shouldAttempt()) try {
1465
+ const row = await inner.create(data, id, {
1466
+ ...options,
1467
+ idempotencyKey
1468
+ });
1469
+ this.connectivity.markSuccess();
1470
+ await this.ingest(slug, [row], ticket);
1471
+ this.notifyCollection(slug);
1472
+ this.scheduleRefresh(slug);
1473
+ return row;
1474
+ } catch (error) {
1475
+ if (!isNetworkError(error)) throw error;
1476
+ this.connectivity.markFailure();
1477
+ sent = true;
1478
+ }
1479
+ const providedId = id ?? data.id;
1480
+ const rowId = providedId ?? this.mintOfflineId(slug);
1481
+ const row = optimisticRow(data, rowId);
1482
+ await this.enqueue({
1483
+ collection: slug,
1484
+ type: "create",
1485
+ id: rowId,
1486
+ data: row,
1487
+ generatedId: providedId === void 0,
1488
+ ...sent ? { sent: {
1489
+ idempotencyKey,
1490
+ data: { ...data },
1491
+ id
1492
+ } } : {},
1493
+ rollback: { rows: { [String(rowId)]: this.rawLocalRow(slug, rowId) ?? null } }
1494
+ }, ticket);
1495
+ this.setLocalRow(slug, rowId, row, ticket);
1496
+ this.notifyCollection(slug);
1497
+ return row;
1498
+ },
1499
+ createMany: async (data, options) => {
1500
+ const ticket = this.ticket() ?? await this.nextTicket();
1501
+ await this.ensureCollection(slug);
1502
+ if (!Array.isArray(data)) throw invalidBulkBody("createMany expects an array of records.");
1503
+ if (data.length === 0) return [];
1504
+ const idempotencyKey = options?.idempotencyKey ?? createMutationId();
1505
+ let sent = false;
1506
+ if (this.connectivity.shouldAttempt()) try {
1507
+ const rows = await inner.createMany(data, {
1508
+ ...options,
1509
+ idempotencyKey
1510
+ });
1511
+ this.connectivity.markSuccess();
1512
+ await this.ingest(slug, rows, ticket);
1513
+ this.notifyCollection(slug);
1514
+ this.scheduleRefresh(slug);
1515
+ return rows;
1516
+ } catch (error) {
1517
+ if (!isNetworkError(error)) throw error;
1518
+ this.connectivity.markFailure();
1519
+ sent = true;
1520
+ }
1521
+ if (!sent && options?.onConflict?.length) throw new RebaseClientError(`Cannot upsert into "${slug}" while offline: the rows it would replace can only be found by the server. Upserting on the primary key, with the key in each row, is queued; upserting on a natural key is not.`, { code: "OFFLINE_UPSERT_UNSUPPORTED" });
1522
+ const rows = data.map((r) => optimisticRow(r, r.id ?? this.mintOfflineId(slug)));
1523
+ const rollback = {};
1524
+ for (const row of rows) {
1525
+ const key = String(row.id);
1526
+ rollback[key] = this.rawLocalRow(slug, row.id) ?? null;
1527
+ }
1528
+ await this.enqueue({
1529
+ collection: slug,
1530
+ type: "createMany",
1531
+ data: rows,
1532
+ upsert: options?.upsert,
1533
+ ...sent ? { sent: {
1534
+ idempotencyKey,
1535
+ data: data.map((r) => ({ ...r })),
1536
+ ...options?.onConflict?.length ? { onConflict: [...options.onConflict] } : {}
1537
+ } } : {},
1538
+ rollback: { rows: rollback }
1539
+ }, ticket);
1540
+ for (const row of rows) this.setLocalRow(slug, row.id, row, ticket);
1541
+ this.notifyCollection(slug);
1542
+ return rows;
1543
+ },
1544
+ /**
1545
+ * Upsert, with the one case that cannot be resolved without the
1546
+ * server refused rather than guessed.
1547
+ *
1548
+ * On the primary key an upsert is create-or-replace, which the
1549
+ * local store can do and the queue already carries (`createMany`
1550
+ * with `upsert`). On a natural key it is not: deciding whether a
1551
+ * row with that email exists means an index this store does not
1552
+ * keep, and inventing an id would insert a duplicate the moment the
1553
+ * queue drains — the exact outcome upsert exists to prevent.
1554
+ */
1555
+ upsert: async (data, options) => {
1556
+ const ticket = this.ticket() ?? await this.nextTicket();
1557
+ await this.ensureCollection(slug);
1558
+ if (this.connectivity.shouldAttempt()) try {
1559
+ const row = await inner.upsert(data, options);
1560
+ this.connectivity.markSuccess();
1561
+ await this.ingest(slug, [row], ticket);
1562
+ this.notifyCollection(slug);
1563
+ this.scheduleRefresh(slug);
1564
+ return row;
1565
+ } catch (error) {
1566
+ if (!isNetworkError(error)) throw error;
1567
+ this.connectivity.markFailure();
1568
+ }
1569
+ const rowId = data.id;
1570
+ if (options?.onConflict?.length || rowId === void 0) throw new RebaseClientError(`Cannot upsert into "${slug}" while offline: the row it would replace can only be found by the server. Upserting on the primary key, with the key in the row, is queued; upserting on a natural key is not.`, { code: "OFFLINE_UPSERT_UNSUPPORTED" });
1571
+ const row = optimisticRow(data, rowId);
1572
+ await this.enqueue({
1573
+ collection: slug,
1574
+ type: "createMany",
1575
+ data: [row],
1576
+ upsert: true,
1577
+ rollback: { rows: { [String(rowId)]: this.rawLocalRow(slug, rowId) ?? null } }
1578
+ }, ticket);
1579
+ this.setLocalRow(slug, rowId, row, ticket);
1580
+ this.notifyCollection(slug);
1581
+ return row;
1582
+ },
1583
+ updateMany: async (updates, options) => {
1584
+ const ticket = this.ticket() ?? await this.nextTicket();
1585
+ await this.ensureCollection(slug);
1586
+ if (!Array.isArray(updates)) throw invalidBulkBody("updateMany expects an array of { id, data } entries.");
1587
+ if (updates.length === 0) return [];
1588
+ const anyPending = updates.some((u) => this.hasPending(slug, u.id));
1589
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
1590
+ const rows = await inner.updateMany(updates, options);
1591
+ this.connectivity.markSuccess();
1592
+ await this.ingest(slug, rows, ticket);
1593
+ this.notifyCollection(slug);
1594
+ return rows;
1595
+ } catch (error) {
1596
+ if (!isNetworkError(error)) throw error;
1597
+ this.connectivity.markFailure();
1598
+ }
1599
+ const rollback = {};
1600
+ const optimistic = [];
1601
+ for (const { id, data } of updates) {
1602
+ const base = this.rawLocalRow(slug, id);
1603
+ rollback[String(id)] = base ?? null;
1604
+ optimistic.push(optimisticRow({
1605
+ ...base ?? {},
1606
+ ...data
1607
+ }, id));
1608
+ }
1609
+ await this.enqueue({
1610
+ collection: slug,
1611
+ type: "updateMany",
1612
+ updates: updates.map((u) => ({
1613
+ id: u.id,
1614
+ data: u.data
1615
+ })),
1616
+ rollback: { rows: rollback }
1617
+ }, ticket);
1618
+ for (const row of optimistic) this.setLocalRow(slug, row.id, row, ticket);
1619
+ this.notifyCollection(slug);
1620
+ return optimistic;
1621
+ },
1622
+ deleteMany: async (ids, options) => {
1623
+ const ticket = this.ticket() ?? await this.nextTicket();
1624
+ await this.ensureCollection(slug);
1625
+ if (!Array.isArray(ids)) throw invalidBulkBody("deleteMany expects an array of ids.");
1626
+ if (ids.length === 0) return;
1627
+ const anyPending = ids.some((id) => this.hasPending(slug, id));
1628
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
1629
+ await inner.deleteMany(ids, options);
1630
+ this.connectivity.markSuccess();
1631
+ for (const id of ids) this.removeLocalRow(slug, id, ticket, true);
1632
+ this.notifyCollection(slug);
1633
+ this.scheduleRefresh(slug);
1634
+ return;
1635
+ } catch (error) {
1636
+ if (!isNetworkError(error)) throw error;
1637
+ this.connectivity.markFailure();
1638
+ }
1639
+ const rollback = {};
1640
+ for (const id of ids) rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
1641
+ await this.enqueue({
1642
+ collection: slug,
1643
+ type: "deleteMany",
1644
+ ids,
1645
+ rollback: { rows: rollback }
1646
+ }, ticket);
1647
+ for (const id of ids) this.removeLocalRow(slug, id, ticket, false);
1648
+ this.notifyCollection(slug);
1649
+ },
1650
+ update: async (id, data, options) => {
1651
+ const ticket = this.ticket() ?? await this.nextTicket();
1652
+ await this.ensureCollection(slug);
1653
+ const idempotencyKey = options?.idempotencyKey ?? createMutationId();
1654
+ let sent = false;
1655
+ let sendFailure;
1656
+ if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
1657
+ const row = await inner.update(id, data, {
1658
+ ...options,
1659
+ idempotencyKey
1660
+ });
1661
+ this.connectivity.markSuccess();
1662
+ await this.ingest(slug, [row], ticket);
1663
+ this.notifyCollection(slug);
1664
+ return row;
1665
+ } catch (error) {
1666
+ if (!isNetworkError(error)) throw error;
1667
+ this.connectivity.markFailure();
1668
+ sent = true;
1669
+ sendFailure = error;
1670
+ }
1671
+ if (hasFieldOperation(data)) {
1672
+ if (sent) throw new RebaseApiError(`The connection failed after a field operation on "${slug}" was sent, so it may or may not have been applied. Retry it with the idempotencyKey in \`details\` to apply it at most once.`, {
1673
+ status: 0,
1674
+ code: "NETWORK_ERROR",
1675
+ details: { idempotencyKey },
1676
+ cause: sendFailure
1677
+ });
1678
+ throw new RebaseClientError(`Cannot apply a field operation to "${slug}" while offline: it is evaluated against the stored value, which this device does not have a current copy of. Send the value itself, or retry when the connection is back.`, { code: "OFFLINE_FIELD_OP_UNSUPPORTED" });
1679
+ }
1680
+ const base = this.rawLocalRow(slug, id);
1681
+ const ifMatch = this.onConflict ? options?.ifMatch ?? (this.hasPending(slug, id) ? void 0 : this.etagFor(slug, id)) : void 0;
1682
+ await this.enqueue({
1683
+ collection: slug,
1684
+ type: "update",
1685
+ id,
1686
+ data,
1687
+ ...sent ? { sent: { idempotencyKey } } : {},
1688
+ ...ifMatch ? { ifMatch } : {},
1689
+ rollback: { rows: { [String(id)]: base ?? null } }
1690
+ }, ticket);
1691
+ const optimistic = optimisticRow({
1692
+ ...base ?? {},
1693
+ ...data
1694
+ }, id);
1695
+ this.setLocalRow(slug, id, optimistic, ticket);
1696
+ this.notifyCollection(slug);
1697
+ return optimistic;
1698
+ },
1699
+ delete: async (id, options) => {
1700
+ const ticket = this.ticket() ?? await this.nextTicket();
1701
+ await this.ensureCollection(slug);
1702
+ const idempotencyKey = options?.idempotencyKey ?? createMutationId();
1703
+ let sent = false;
1704
+ if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
1705
+ await inner.delete(id, {
1706
+ ...options,
1707
+ idempotencyKey
1708
+ });
1709
+ this.connectivity.markSuccess();
1710
+ this.removeLocalRow(slug, id, ticket, true);
1711
+ this.notifyCollection(slug);
1712
+ this.scheduleRefresh(slug);
1713
+ return;
1714
+ } catch (error) {
1715
+ if (!isNetworkError(error)) throw error;
1716
+ this.connectivity.markFailure();
1717
+ sent = true;
1718
+ }
1719
+ await this.enqueue({
1720
+ collection: slug,
1721
+ type: "delete",
1722
+ id,
1723
+ ...sent ? { sent: { idempotencyKey } } : {},
1724
+ rollback: { rows: { [String(id)]: this.rawLocalRow(slug, id) ?? null } }
1725
+ }, ticket);
1726
+ this.removeLocalRow(slug, id, ticket);
1727
+ this.notifyCollection(slug);
1728
+ },
1729
+ count: async (params) => {
1730
+ const ticket = this.ticket() ?? await this.nextTicket();
1731
+ await this.ensureCollection(slug);
1732
+ if (this.connectivity.shouldAttempt()) try {
1733
+ const n = await inner.count(params);
1734
+ this.connectivity.markSuccess();
1735
+ if (!this.isCurrent(ticket)) return n;
1736
+ this.writeCache(this.countKey(slug, params), n);
1737
+ return Math.max(0, n + this.pendingDelta(slug, params));
1738
+ } catch (error) {
1739
+ if (!isNetworkError(error)) throw error;
1740
+ this.connectivity.markFailure();
1741
+ }
1742
+ const cached = await this.readCache(this.countKey(slug, params));
1743
+ if (cached !== void 0) return Math.max(0, cached + this.pendingDelta(slug, params));
1744
+ throw offlineError(`Offline: no cached count for "${slug}".`);
1745
+ },
1746
+ observe: (params, onResult, onError, options) => this.observe(slug, wrapped, inner, params, onResult, onError, options),
1747
+ observeById: (id, onResult, onError, options) => this.observeById(slug, wrapped, inner, id, onResult, onError, options),
1748
+ where(columnOrCondition, operator, value) {
1749
+ const builder = new SDKQueryBuilder(wrapped);
1750
+ if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
1751
+ return builder.where(columnOrCondition, operator, value);
1752
+ },
1753
+ orderBy: (column, direction, nulls) => new SDKQueryBuilder(wrapped).orderBy(column, direction, nulls),
1754
+ limit: (count) => new SDKQueryBuilder(wrapped).limit(count),
1755
+ offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
1756
+ search: (searchString, options) => new SDKQueryBuilder(wrapped).search(searchString, options),
1757
+ vectorSearch: (property, vector, options) => new SDKQueryBuilder(wrapped).vectorSearch(property, vector, options),
1758
+ include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations),
1759
+ fields: (...columns) => new SDKQueryBuilder(wrapped).fields(...columns),
1760
+ distinct: (enabled) => new SDKQueryBuilder(wrapped).distinct(enabled),
1761
+ after: (cursor) => new SDKQueryBuilder(wrapped).after(cursor),
1762
+ trash: this.wrapTrash(slug, inner),
1763
+ aggregate: inner.aggregate,
1764
+ listen: inner.listen,
1765
+ listenById: inner.listenById,
1766
+ listenCount: inner.listenCount
1767
+ };
1768
+ if (!isUnsupported(inner.listen)) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
1769
+ const ticket = this.ticket();
1770
+ if (!ticket) {
1771
+ onUpdate(response);
1772
+ return;
1773
+ }
1774
+ this.ingest(slug, response.data ?? [], ticket, isProjection(params)).then(() => {
1775
+ if (!this.isCurrent(ticket)) return;
1776
+ const snapshot = this.recordSnapshot(slug, params, response, ticket);
1777
+ const answer = this.answer(slug, params, snapshot);
1778
+ this.notifyCollection(slug, false);
1779
+ onUpdate({
1780
+ ...response,
1781
+ data: answer.data,
1782
+ meta: answer.meta
1783
+ });
1784
+ });
1785
+ }, onError);
1786
+ if (!isUnsupported(inner.listenById)) wrapped.listenById = (id, onUpdate, onError, options) => inner.listenById(id, (row) => {
1787
+ const ticket = this.ticket();
1788
+ if (!ticket) {
1789
+ onUpdate(row);
1790
+ return;
1791
+ }
1792
+ if (!row) {
1793
+ if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
1794
+ this.notifyCollection(slug, false);
1795
+ onUpdate(row);
1796
+ return;
1797
+ }
1798
+ this.ingest(slug, [row], ticket).then(() => {
1799
+ if (!this.isCurrent(ticket)) return;
1800
+ this.notifyCollection(slug, false);
1801
+ onUpdate(this.localRow(slug, id) ?? row);
1802
+ });
1803
+ }, onError, options);
1804
+ return wrapped;
1805
+ }
1806
+ /**
1807
+ * `client.data.<slug>.trash` on an offline client: every call goes to the
1808
+ * server, and none is cached or queued.
1809
+ *
1810
+ * The local database holds live rows only — a trashed row read into it
1811
+ * would show up in every live query that matches it — so it cannot answer
1812
+ * a trash read, and a restore or a purge queued offline would act on a row
1813
+ * this device has never seen. Each of them rejects with `OFFLINE` instead.
1814
+ *
1815
+ * What the server answers does reach the local database: a restored row
1816
+ * is live again and joins it, and a purged row leaves it. A row with a
1817
+ * write still queued is synced first, so a purge never overtakes the
1818
+ * queued delete it follows; if the queue cannot drain, the call is refused.
1819
+ */
1820
+ wrapTrash(slug, inner) {
1821
+ const online = async (what, call) => {
1822
+ const message = `Offline: ${what} on the trash of "${slug}" needs the server; the trash is never cached on this device.`;
1823
+ if (!this.connectivity.shouldAttempt()) throw offlineError(message);
1824
+ try {
1825
+ const result = await call();
1826
+ this.connectivity.markSuccess();
1827
+ return result;
1828
+ } catch (error) {
1829
+ if (!isNetworkError(error)) throw error;
1830
+ this.connectivity.markFailure();
1831
+ throw new RebaseApiError(message, {
1832
+ status: 0,
1833
+ code: "OFFLINE",
1834
+ cause: error
1835
+ });
1836
+ }
1837
+ };
1838
+ const settleQueued = async (what, ids) => {
1839
+ if (!ids.some((id) => this.hasPending(slug, id))) return;
1840
+ await this.sync().catch(() => void 0);
1841
+ const stuck = ids.find((id) => this.hasPending(slug, id));
1842
+ if (stuck === void 0) return;
1843
+ throw new RebaseClientError(`Cannot ${what} "${slug}" row ${String(stuck)} yet: a write to it is still queued, and the trash acts on the server's copy. Retry once \`client.offline.sync()\` has sent it.`, {
1844
+ code: "TRASH_PENDING_WRITE",
1845
+ details: { id: stuck }
1846
+ });
1847
+ };
1848
+ const restored = async (rows, ticket) => {
1849
+ await this.ingest(slug, rows.filter((row) => row && typeof row === "object"), ticket);
1850
+ this.notifyCollection(slug);
1851
+ this.scheduleRefresh(slug);
1852
+ };
1853
+ const purged = (ids, ticket) => {
1854
+ for (const id of ids) this.removeLocalRow(slug, id, ticket, true);
1855
+ this.notifyCollection(slug);
1856
+ this.scheduleRefresh(slug);
1857
+ };
1858
+ return {
1859
+ find: (params) => online("find()", () => inner.trash.find(params)),
1860
+ count: (params) => online("count()", () => inner.trash.count(params)),
1861
+ restore: async (id, options) => {
1862
+ const ticket = this.ticket() ?? await this.nextTicket();
1863
+ await this.ensureCollection(slug);
1864
+ await settleQueued("restore", [id]);
1865
+ const row = await online("restore()", () => inner.trash.restore(id, options));
1866
+ await restored(row ? [row] : [], ticket);
1867
+ return row;
1868
+ },
1869
+ restoreMany: async (ids, options) => {
1870
+ const ticket = this.ticket() ?? await this.nextTicket();
1871
+ await this.ensureCollection(slug);
1872
+ if (!Array.isArray(ids)) throw invalidBulkBody("trash.restoreMany expects an array of ids.");
1873
+ await settleQueued("restore", ids);
1874
+ const rows = await online("restoreMany()", () => inner.trash.restoreMany(ids, options));
1875
+ await restored(rows, ticket);
1876
+ return rows;
1877
+ },
1878
+ purge: async (id, options) => {
1879
+ const ticket = this.ticket() ?? await this.nextTicket();
1880
+ await this.ensureCollection(slug);
1881
+ await settleQueued("purge", [id]);
1882
+ await online("purge()", () => inner.trash.purge(id, options));
1883
+ purged([id], ticket);
1884
+ },
1885
+ purgeMany: async (ids, options) => {
1886
+ const ticket = this.ticket() ?? await this.nextTicket();
1887
+ await this.ensureCollection(slug);
1888
+ if (!Array.isArray(ids)) throw invalidBulkBody("trash.purgeMany expects an array of ids.");
1889
+ if (ids.length === 0) return;
1890
+ await settleQueued("purge", ids);
1891
+ await online("purgeMany()", () => inner.trash.purgeMany(ids, options));
1892
+ purged(ids, ticket);
1893
+ }
1894
+ };
1895
+ }
1896
+ observe(slug, wrapped, inner, params, onResult, onError, options) {
1897
+ let closed = false;
1898
+ let unlisten;
1899
+ const observer = {
1900
+ slug,
1901
+ params,
1902
+ settled: false,
1903
+ refresh: () => wrapped.find(params).catch(() => void 0),
1904
+ emit: () => {
1905
+ if (closed || !this.collections.get(slug)?.ready) return;
1906
+ const result = this.answer(slug, params, this.snapshotFor(slug, params));
1907
+ const signature = `${result.fromCache ? "c" : "s"}${result.hasPendingWrites ? "p" : "-"}` + this.signature(slug, result.data, result.meta.total);
1908
+ if (observer.settled && signature === observer.signature) return;
1909
+ observer.signature = signature;
1910
+ observer.settled = true;
1911
+ onResult(observer.error ? {
1912
+ ...result,
1913
+ error: observer.error
1914
+ } : result);
1915
+ }
1916
+ };
1917
+ this.observersFor(slug).add(observer);
1918
+ (async () => {
1919
+ await this.ensureCollection(slug);
1920
+ if (closed) return;
1921
+ if (this.hasLocalAnswer(this.collections.get(slug), slug, params)) observer.emit();
1922
+ try {
1923
+ await wrapped.find(params);
1924
+ observer.error = void 0;
1925
+ } catch (error) {
1926
+ observer.error = error;
1927
+ if (closed) return;
1928
+ if (!observer.settled) {
1929
+ onError?.(error);
1930
+ return;
1931
+ }
1932
+ }
1933
+ if (!closed) observer.emit();
1934
+ })();
1935
+ if (options?.realtime !== false && !isUnsupported(inner.listen)) unlisten = inner.listen(params, (response) => {
1936
+ const ticket = this.ticket();
1937
+ if (!ticket) return;
1938
+ this.ingest(slug, response.data ?? [], ticket, isProjection(params)).then(() => {
1939
+ this.recordSnapshot(slug, params, response, ticket);
1940
+ this.notifyCollection(slug, false);
1941
+ });
1942
+ }, onError);
1943
+ return () => {
1944
+ closed = true;
1945
+ this.observersFor(slug).delete(observer);
1946
+ unlisten?.();
1947
+ };
1948
+ }
1949
+ observeById(slug, wrapped, inner, id, onResult, onError, options) {
1950
+ const read = byIdRead(options);
1951
+ let closed = false;
1952
+ let unlisten;
1953
+ const observer = {
1954
+ slug,
1955
+ id,
1956
+ settled: false,
1957
+ refresh: () => wrapped.findById(id, read).catch(() => void 0),
1958
+ emit: () => {
1959
+ if (closed || !this.collections.get(slug)?.ready) return;
1960
+ const row = this.localRow(slug, id);
1961
+ const entry = this.collections.get(slug)?.rows.get(String(id));
1962
+ const fromCache = !this.collections.get(slug)?.freshRows.has(String(id));
1963
+ const hasPendingWrites = this.hasPending(slug, id);
1964
+ const signature = `${fromCache ? "c" : "s"}${hasPendingWrites ? "p" : "-"}|` + (row === void 0 ? MISSING : `${String(id)}:${entry?.rev ?? 0}`);
1965
+ if (observer.settled && signature === observer.signature) return;
1966
+ observer.signature = signature;
1967
+ observer.settled = true;
1968
+ onResult(row, {
1969
+ fromCache,
1970
+ hasPendingWrites
1971
+ });
1972
+ }
1973
+ };
1974
+ this.observersFor(slug).add(observer);
1975
+ (async () => {
1976
+ await this.ensureCollection(slug);
1977
+ if (closed) return;
1978
+ if (this.localRow(slug, id) !== void 0) observer.emit();
1979
+ try {
1980
+ await wrapped.findById(id, read);
1981
+ } catch (error) {
1982
+ if (closed) return;
1983
+ if (!observer.settled) {
1984
+ onError?.(error);
1985
+ return;
1986
+ }
1987
+ }
1988
+ if (!closed) observer.emit();
1989
+ })();
1990
+ if (options?.realtime !== false && !isUnsupported(inner.listenById)) unlisten = inner.listenById(id, (row) => {
1991
+ const ticket = this.ticket();
1992
+ if (!ticket) return;
1993
+ if (!row) {
1994
+ if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
1995
+ this.notifyCollection(slug, false);
1996
+ return;
1997
+ }
1998
+ this.ingest(slug, [row], ticket).then(() => this.notifyCollection(slug, false));
1999
+ }, onError, read);
2000
+ return () => {
2001
+ closed = true;
2002
+ this.observersFor(slug).delete(observer);
2003
+ unlisten?.();
2004
+ };
2005
+ }
2006
+ observersFor(slug) {
2007
+ let set = this.observers.get(slug);
2008
+ if (!set) {
2009
+ set = /* @__PURE__ */ new Set();
2010
+ this.observers.set(slug, set);
2011
+ }
2012
+ return set;
2013
+ }
2014
+ /** Cheap change detection: which rows, in what order, at which revision. */
2015
+ signature(slug, rows, total) {
2016
+ const state = this.collections.get(slug);
2017
+ return `${total}|${rows.map((row) => {
2018
+ const key = String(row.id);
2019
+ const entry = state?.rows.get(key);
2020
+ return entry?.row === row ? `${key}:${entry.rev}` : `${key}=${JSON.stringify(row)}`;
2021
+ }).join(",")}`;
2022
+ }
2023
+ notifyCollection(slug, broadcast = true) {
2024
+ const set = this.observers.get(slug);
2025
+ if (set) for (const observer of [...set]) callSafely(() => observer.emit());
2026
+ if (broadcast) this.broadcast({
2027
+ type: "rows",
2028
+ slugs: [slug]
2029
+ });
2030
+ }
2031
+ /** Connectivity came back (or the user changed): re-read everything live. */
2032
+ revalidateAll() {
2033
+ for (const slug of this.observers.keys()) {
2034
+ this.notifyCollection(slug, false);
2035
+ this.scheduleRefresh(slug);
2036
+ }
2037
+ }
2038
+ collectionState(slug) {
2039
+ let state = this.collections.get(slug);
2040
+ if (!state) {
2041
+ state = {
2042
+ rows: /* @__PURE__ */ new Map(),
2043
+ snapshots: /* @__PURE__ */ new Map(),
2044
+ fresh: /* @__PURE__ */ new Set(),
2045
+ freshRows: /* @__PURE__ */ new Set(),
2046
+ absent: /* @__PURE__ */ new Set(),
2047
+ ready: false
2048
+ };
2049
+ this.collections.set(slug, state);
2050
+ }
2051
+ return state;
2052
+ }
2053
+ ensureCollection(slug) {
2054
+ if (this.scopeHold) return this.scopeKnown().then(() => this.ensureCollection(slug));
2055
+ const state = this.collectionState(slug);
2056
+ if (!state.loaded) {
2057
+ const scope = this.scope;
2058
+ state.loaded = (async () => {
2059
+ await this.ensureQueueLoaded();
2060
+ const [rows, snapshots, absent] = await Promise.all([
2061
+ this.store.listCacheEntries(`${scope}|row|${slug}|`).catch(() => []),
2062
+ this.store.listCacheEntries(`${scope}|q|${slug}|`).catch(() => []),
2063
+ this.store.listCache(`${scope}|abs|${slug}|`).catch(() => [])
2064
+ ]);
2065
+ if (this.scope !== scope || this.collections.get(slug) !== state) return;
2066
+ for (const entry of rows) {
2067
+ const row = entry.value;
2068
+ if (!row || row.id === void 0 || row.id === null) continue;
2069
+ state.rows.set(String(row.id), {
2070
+ row: hydrateRow(row),
2071
+ cachedAt: entry.cachedAt,
2072
+ rev: ++this.revCounter
2073
+ });
2074
+ }
2075
+ for (const entry of snapshots) {
2076
+ const key = entry.key.slice(`${scope}|q|${slug}|`.length);
2077
+ if (entry.value) state.snapshots.set(key, readSnapshot(entry.value));
2078
+ }
2079
+ for (const entry of absent) state.absent.add(entry.key.slice(`${scope}|abs|${slug}|`.length));
2080
+ })().catch(() => void 0).finally(() => {
2081
+ state.ready = true;
2082
+ });
2083
+ }
2084
+ return state.loaded.then(() => state);
2085
+ }
2086
+ snapshotFor(slug, params) {
2087
+ return this.collections.get(slug)?.snapshots.get(buildQueryString(params));
2088
+ }
2089
+ hasLocalAnswer(state, slug, params) {
2090
+ if (!state) return false;
2091
+ return state.snapshots.has(buildQueryString(params)) || state.rows.size > 0;
2092
+ }
2093
+ /**
2094
+ * Answer a query from the local database.
2095
+ *
2096
+ * With a snapshot, the server's own page — its ids, order and total — is
2097
+ * the skeleton, and the local rows fill it in: rows deleted locally drop
2098
+ * out, rows edited locally show the edit, and rows *created* locally join
2099
+ * the first page if they match. Without one, the query is evaluated
2100
+ * outright over every cached row, which is the best that can be done for a
2101
+ * query the server has never answered here.
2102
+ */
2103
+ answer(slug, params, snapshot) {
2104
+ const state = this.collections.get(slug);
2105
+ const exact = isExactlyEvaluable(params);
2106
+ const fromCache = !state?.fresh.has(buildQueryString(params));
2107
+ if (!state) return {
2108
+ data: [],
2109
+ meta: {
2110
+ ...resolvePagination(params),
2111
+ total: 0,
2112
+ hasMore: false
2113
+ },
2114
+ fromCache: true,
2115
+ hasPendingWrites: false,
2116
+ partial: true
2117
+ };
2118
+ if (!snapshot) {
2119
+ const local = runLocalQuery([...state.rows.values()].map((e) => e.row), params);
2120
+ return {
2121
+ ...local,
2122
+ fromCache,
2123
+ hasPendingWrites: local.data.some((row) => this.hasPending(slug, row.id)),
2124
+ partial: true
2125
+ };
2126
+ }
2127
+ if (snapshot.rows) return this.projectedAnswer(slug, params, snapshot, snapshot.rows, fromCache);
2128
+ const rows = [];
2129
+ const seen = /* @__PURE__ */ new Set();
2130
+ /** Rows the server counted that we know are no longer in the result. */
2131
+ let removed = 0;
2132
+ for (const id of snapshot.ids) {
2133
+ const key = String(id);
2134
+ const entry = state.rows.get(key);
2135
+ if (!entry) {
2136
+ if (state.absent.has(key) || this.hasPending(slug, key)) removed++;
2137
+ continue;
2138
+ }
2139
+ if (exact && this.hasPending(slug, key) && !matchesParams(entry.row, params)) {
2140
+ removed++;
2141
+ continue;
2142
+ }
2143
+ rows.push(entry.row);
2144
+ seen.add(key);
2145
+ }
2146
+ let added = 0;
2147
+ const offset = snapshot.offset ?? 0;
2148
+ if (exact && offset === 0) for (const [key, entry] of state.rows) {
2149
+ if (seen.has(key) || !this.hasPending(slug, key)) continue;
2150
+ if (!this.isLocallyCreated(slug, key) && !this.movedIntoFilter(slug, key, params)) continue;
2151
+ if (!matchesParams(entry.row, params)) continue;
2152
+ rows.push(entry.row);
2153
+ added++;
2154
+ }
2155
+ const orderIsLocal = isLocallySortable(rows, params?.orderBy);
2156
+ const sorted = Boolean(params?.orderBy && orderIsLocal);
2157
+ if (sorted) sortRows(rows, params.orderBy);
2158
+ const total = Math.max(rows.length, snapshot.total - removed + added);
2159
+ const page = fitToLimit(rows, snapshot.limit, added, sorted);
2160
+ return {
2161
+ data: page,
2162
+ meta: {
2163
+ total,
2164
+ limit: snapshot.limit,
2165
+ offset,
2166
+ hasMore: snapshot.hasMore || page.length < rows.length
2167
+ },
2168
+ fromCache,
2169
+ hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id)),
2170
+ partial: !exact || !orderIsLocal
2171
+ };
2172
+ }
2173
+ /**
2174
+ * Answer a projected query (`fields`, `distinct`) from the server's own
2175
+ * answer to it.
2176
+ *
2177
+ * The server's rows are the answer. The local database contributes only
2178
+ * what it knows better: a row with unsynced writes shows them, narrowed to
2179
+ * the columns the server sent, or drops out if it was deleted here or
2180
+ * edited out of a filter this side can evaluate; and rows created here
2181
+ * join the first page when there is somewhere to put them. A distinct
2182
+ * query, or an order on a column the projection does not carry, leaves
2183
+ * them out, and the result says it is partial.
2184
+ */
2185
+ projectedAnswer(slug, params, snapshot, served, fromCache) {
2186
+ const state = this.collectionState(slug);
2187
+ const exact = isExactlyEvaluable(params);
2188
+ const rows = [];
2189
+ const seen = /* @__PURE__ */ new Set();
2190
+ let removed = 0;
2191
+ for (const row of served) {
2192
+ const key = row.id === void 0 || row.id === null ? void 0 : String(row.id);
2193
+ if (key !== void 0) seen.add(key);
2194
+ if (key === void 0 || !this.hasPending(slug, key)) {
2195
+ rows.push(row);
2196
+ continue;
2197
+ }
2198
+ const local = state.rows.get(key)?.row;
2199
+ if (!local || exact && !matchesParams(local, params)) {
2200
+ removed++;
2201
+ continue;
2202
+ }
2203
+ rows.push(pick(local, Object.keys(row)));
2204
+ }
2205
+ let added = 0;
2206
+ let unplaced = false;
2207
+ const offset = snapshot.offset ?? 0;
2208
+ if (exact && offset === 0) {
2209
+ const shape = served.length > 0 ? Object.keys(served[0]) : ["id", ...params?.fields ?? []];
2210
+ const placeable = !params?.distinct && (normalizeOrderBy(params?.orderBy) ?? []).every(([field]) => shape.includes(field));
2211
+ for (const [key, entry] of state.rows) {
2212
+ if (seen.has(key) || !this.hasPending(slug, key)) continue;
2213
+ if (!this.isLocallyCreated(slug, key) && !this.movedIntoFilter(slug, key, params)) continue;
2214
+ if (!matchesParams(entry.row, params)) continue;
2215
+ if (!placeable) {
2216
+ unplaced = true;
2217
+ continue;
2218
+ }
2219
+ rows.push(pick(entry.row, shape));
2220
+ added++;
2221
+ }
2222
+ }
2223
+ const orderIsLocal = added === 0 || isLocallySortable(rows, params?.orderBy);
2224
+ const sorted = Boolean(added > 0 && params?.orderBy && orderIsLocal);
2225
+ if (sorted) sortRows(rows, params.orderBy);
2226
+ const page = fitToLimit(rows, snapshot.limit, added, sorted);
2227
+ return {
2228
+ data: page,
2229
+ meta: {
2230
+ total: Math.max(rows.length, snapshot.total - removed + added),
2231
+ limit: snapshot.limit,
2232
+ offset,
2233
+ hasMore: snapshot.hasMore || page.length < rows.length
2234
+ },
2235
+ fromCache,
2236
+ hasPendingWrites: rows.some((row) => row.id !== void 0 && row.id !== null && this.hasPending(slug, row.id)),
2237
+ partial: !exact || !orderIsLocal || unplaced
2238
+ };
2239
+ }
2240
+ localFind(slug, params) {
2241
+ const state = this.collections.get(slug);
2242
+ const snapshot = this.snapshotFor(slug, params);
2243
+ state?.fresh.delete(buildQueryString(params));
2244
+ if (!snapshot && (!state || state.rows.size === 0)) throw offlineError(`Offline: no cached data for "${slug}".`);
2245
+ const answer = this.answer(slug, params, snapshot);
2246
+ return snapshot ? answer : {
2247
+ ...answer,
2248
+ partial: true
2249
+ };
2250
+ }
2251
+ /**
2252
+ * A temporary id of the type this collection's ids actually have.
2253
+ *
2254
+ * There is no schema here to read it off — the generated `Database` types
2255
+ * are erased at compile time, and the whole point of this path is that the
2256
+ * server is unreachable. So it is read off the rows the local database
2257
+ * already holds: a collection whose cached ids are numbers gets a negative
2258
+ * number, and everything else gets a UUID.
2259
+ *
2260
+ * A collection with nothing cached falls back to a UUID, which is the same
2261
+ * guess as before — but that is a first write on a device that has never
2262
+ * read the collection, which is also the case where nothing local can tell
2263
+ * the difference. Reading one row first is enough to fix it.
2264
+ */
2265
+ mintOfflineId(slug) {
2266
+ for (const entry of this.collections.get(slug)?.rows.values() ?? []) {
2267
+ const id = entry.row.id;
2268
+ if (typeof id === "number") return generateOfflineNumericId();
2269
+ if (typeof id === "string") break;
2270
+ }
2271
+ return generateOfflineStringId();
2272
+ }
2273
+ /**
2274
+ * Keep the version a server row came with, or forget the one held when it
2275
+ * came with none: a row from a list carries no version, and may be newer
2276
+ * than the one held. Only with `onConflict`, the one reader of it.
2277
+ */
2278
+ noteETag(state, key, row) {
2279
+ if (!this.onConflict) return;
2280
+ const etag = etagOf(row);
2281
+ if (etag) (state.etags ??= /* @__PURE__ */ new Map()).set(key, etag);
2282
+ else state.etags?.delete(key);
2283
+ }
2284
+ etagFor(slug, id) {
2285
+ return this.collections.get(slug)?.etags?.get(String(id));
2286
+ }
2287
+ rawLocalRow(slug, id) {
2288
+ const entry = this.collections.get(slug)?.rows.get(String(id));
2289
+ return entry ? { ...entry.row } : void 0;
2290
+ }
2291
+ localRow(slug, id) {
2292
+ return this.collections.get(slug)?.rows.get(String(id))?.row;
2293
+ }
2294
+ setLocalRow(slug, id, row, ticket) {
2295
+ if (!this.isCurrent(ticket)) return;
2296
+ const state = this.collectionState(slug);
2297
+ const key = String(id);
2298
+ const cachedAt = Date.now();
2299
+ state.rows.set(key, {
2300
+ row: { ...row },
2301
+ cachedAt,
2302
+ rev: ++this.revCounter
2303
+ });
2304
+ state.freshRows.delete(key);
2305
+ this.forgetTombstone(slug, key);
2306
+ this.writeCache(this.rowKey(slug, key), dehydrateRow(row), cachedAt);
2307
+ this.evictRows(slug);
2308
+ }
2309
+ /**
2310
+ * Drop a row and, when the server is the one saying it is gone, remember
2311
+ * that. "I looked it up and it does not exist" is real knowledge: without
2312
+ * it, opening a deleted row while offline would report a missing local
2313
+ * database instead of a missing row.
2314
+ */
2315
+ removeLocalRow(slug, id, ticket, known = false) {
2316
+ if (!this.isCurrent(ticket)) return;
2317
+ const state = this.collectionState(slug);
2318
+ const key = String(id);
2319
+ const existed = state.rows.delete(key);
2320
+ state.etags?.delete(key);
2321
+ if (known) {
2322
+ state.absent.add(key);
2323
+ state.freshRows.add(key);
2324
+ this.writeCache(this.absentKey(slug, key), true);
2325
+ this.capTombstones(slug);
2326
+ } else state.freshRows.delete(key);
2327
+ if (existed) this.deleteCache([this.rowKey(slug, key)]);
2328
+ }
2329
+ forgetTombstone(slug, key) {
2330
+ if (!this.collectionState(slug).absent.delete(key)) return;
2331
+ this.deleteCache([this.absentKey(slug, key)]);
2332
+ }
2333
+ /**
2334
+ * Merge server rows into the local database. A row with unsynced local
2335
+ * writes keeps them: the server's copy is the base the queued mutations
2336
+ * are re-applied to, not a replacement for what the user did.
2337
+ *
2338
+ * Rows that came back unchanged keep their identity and revision, so a
2339
+ * refetch that changed nothing does not re-render every live query that
2340
+ * touches them — or rewrite them all to disk.
2341
+ *
2342
+ * A `projection` (see {@link isProjection}) only refreshes the columns it
2343
+ * carries on rows already held, and never creates one. It is not a row:
2344
+ * stored as one, it replaced the full row every other query reads and
2345
+ * wrote the narrowed copy to disk, so a list lost its columns because a
2346
+ * dropdown elsewhere asked for titles.
2347
+ */
2348
+ async ingest(slug, rows, ticket, projection = false) {
2349
+ if (rows.length === 0 || !this.isCurrent(ticket)) return;
2350
+ const state = await this.ensureCollection(slug);
2351
+ if (!this.isCurrent(ticket)) return;
2352
+ const cachedAt = Date.now();
2353
+ const writes = [];
2354
+ const deletes = [];
2355
+ for (const raw of rows) {
2356
+ if (!raw || raw.id === void 0 || raw.id === null) continue;
2357
+ const key = String(raw.id);
2358
+ this.noteETag(state, key, raw);
2359
+ const existing = state.rows.get(key);
2360
+ if (projection && !existing) continue;
2361
+ const base = projection && existing ? {
2362
+ ...existing.row,
2363
+ ...raw
2364
+ } : { ...raw };
2365
+ const merged = this.hasPending(slug, key) ? this.applyPendingToRow(slug, key, base) : base;
2366
+ if (merged === void 0) {
2367
+ state.rows.delete(key);
2368
+ deletes.push(this.rowKey(slug, key));
2369
+ continue;
2370
+ }
2371
+ this.forgetTombstone(slug, key);
2372
+ if (!projection) state.freshRows.add(key);
2373
+ if (existing && JSON.stringify(existing.row) === JSON.stringify(merged)) {
2374
+ existing.cachedAt = cachedAt;
2375
+ continue;
2376
+ }
2377
+ state.rows.set(key, {
2378
+ row: merged,
2379
+ cachedAt,
2380
+ rev: ++this.revCounter
2381
+ });
2382
+ writes.push({
2383
+ key: this.rowKey(slug, key),
2384
+ entry: {
2385
+ value: dehydrateRow(merged),
2386
+ cachedAt
2387
+ }
2388
+ });
2389
+ }
2390
+ if (writes.length > 0) this.store.setCacheMany(writes).catch(() => void 0);
2391
+ if (deletes.length > 0) this.deleteCache(deletes);
2392
+ this.evictRows(slug);
2393
+ }
2394
+ /**
2395
+ * Fold the queued mutations for one row over a base, newest last.
2396
+ * `afterMutationId` skips everything up to and including that mutation,
2397
+ * which is how a just-replayed write avoids being applied on top of the
2398
+ * server's response to it.
2399
+ */
2400
+ applyPendingToRow(slug, idKey, base, afterMutationId) {
2401
+ let row = base;
2402
+ let skipping = afterMutationId !== void 0;
2403
+ for (const op of this.queue) {
2404
+ if (skipping) {
2405
+ if (op.mutationId === afterMutationId) skipping = false;
2406
+ continue;
2407
+ }
2408
+ if (op.collection !== slug) continue;
2409
+ if (op.type === "createMany") {
2410
+ const match = op.data?.find((r) => String(r.id) === idKey);
2411
+ if (match) row = { ...match };
2412
+ continue;
2413
+ }
2414
+ if (op.type === "updateMany") {
2415
+ const update = op.updates?.find((u) => String(u.id) === idKey);
2416
+ if (update) row = {
2417
+ ...row ?? {},
2418
+ ...update.data,
2419
+ id: update.id
2420
+ };
2421
+ continue;
2422
+ }
2423
+ if (op.type === "deleteMany") {
2424
+ if (op.ids?.some((id) => String(id) === idKey)) row = void 0;
2425
+ continue;
2426
+ }
2427
+ if (op.id === void 0 || String(op.id) !== idKey) continue;
2428
+ if (op.type === "create") row = { ...op.data };
2429
+ else if (op.type === "update") row = {
2430
+ ...row ?? {},
2431
+ ...op.data,
2432
+ id: op.id
2433
+ };
2434
+ else if (op.type === "delete") row = void 0;
2435
+ }
2436
+ return row;
2437
+ }
2438
+ recordSnapshot(slug, params, result, ticket) {
2439
+ const window = resolvePagination(params);
2440
+ const meta = result.meta ?? {
2441
+ total: result.data?.length ?? 0,
2442
+ ...window,
2443
+ hasMore: false
2444
+ };
2445
+ const snapshot = {
2446
+ ids: (result.data ?? []).map((row) => row.id).filter((id) => id !== void 0),
2447
+ total: meta.total ?? result.data?.length ?? 0,
2448
+ limit: meta.limit ?? window.limit,
2449
+ offset: meta.offset ?? window.offset,
2450
+ hasMore: meta.hasMore ?? false,
2451
+ ...isProjection(params) ? { rows: (result.data ?? []).map((row) => ({ ...row })) } : {}
2452
+ };
2453
+ if (!this.isCurrent(ticket)) return snapshot;
2454
+ const state = this.collectionState(slug);
2455
+ const key = buildQueryString(params);
2456
+ state.snapshots.set(key, snapshot);
2457
+ state.fresh.add(key);
2458
+ this.writeCache(`${this.scope}|q|${slug}|${key}`, snapshot.rows ? {
2459
+ ...snapshot,
2460
+ rows: snapshot.rows.map((row) => dehydrateRow(row))
2461
+ } : snapshot);
2462
+ this.evictSnapshots(slug);
2463
+ return snapshot;
2464
+ }
2465
+ /**
2466
+ * A write changed which rows belong in a list, and only the server can say
2467
+ * how — a row it generated is in no cached page, and the totals moved.
2468
+ * Re-run every live query on the collection; queries nobody is watching
2469
+ * are corrected by their next `find`.
2470
+ *
2471
+ * Coalesced per microtask so a burst of writes costs one round trip, and
2472
+ * skipped entirely while offline, where the local database is already the
2473
+ * best answer available.
2474
+ */
2475
+ scheduleRefresh(slug) {
2476
+ if (this.refreshPending.has(slug)) return;
2477
+ const observers = this.observers.get(slug);
2478
+ if (!observers || observers.size === 0) return;
2479
+ this.refreshPending.add(slug);
2480
+ Promise.resolve().then(() => {
2481
+ this.refreshPending.delete(slug);
2482
+ if (this.disposed || !this.connectivity.shouldAttempt()) return;
2483
+ for (const observer of [...this.observers.get(slug) ?? []]) observer.refresh();
2484
+ });
2485
+ }
2486
+ evictRows(slug) {
2487
+ const state = this.collections.get(slug);
2488
+ if (!state || state.rows.size <= this.maxCachedRows) return;
2489
+ const evictable = [...state.rows.entries()].filter(([key]) => !this.hasPending(slug, key)).sort((a, b) => a[1].cachedAt - b[1].cachedAt);
2490
+ const excess = state.rows.size - this.maxCachedRows;
2491
+ const doomed = evictable.slice(0, excess);
2492
+ for (const [key] of doomed) {
2493
+ state.rows.delete(key);
2494
+ state.etags?.delete(key);
2495
+ }
2496
+ if (doomed.length > 0) this.deleteCache(doomed.map(([key]) => this.rowKey(slug, key)));
2497
+ }
2498
+ /**
2499
+ * Tombstones are tiny but unbounded — every row the app ever asked for and
2500
+ * did not find leaves one. Capped against the row budget, oldest first,
2501
+ * whenever one is added: the cap used to sit behind the row cache's own
2502
+ * early return, so it only applied once the rows were over theirs, and
2503
+ * looking up missing ids grew the set without limit.
2504
+ */
2505
+ capTombstones(slug) {
2506
+ const state = this.collections.get(slug);
2507
+ if (!state || state.absent.size <= this.maxCachedRows) return;
2508
+ const stale = [...state.absent].slice(0, state.absent.size - this.maxCachedRows);
2509
+ for (const key of stale) state.absent.delete(key);
2510
+ this.deleteCache(stale.map((key) => this.absentKey(slug, key)));
2511
+ }
2512
+ evictSnapshots(slug) {
2513
+ const state = this.collections.get(slug);
2514
+ if (!state || state.snapshots.size <= this.maxCachedQueries) return;
2515
+ const excess = state.snapshots.size - this.maxCachedQueries;
2516
+ const doomed = [...state.snapshots.keys()].slice(0, excess);
2517
+ for (const key of doomed) {
2518
+ state.snapshots.delete(key);
2519
+ state.fresh.delete(key);
2520
+ }
2521
+ this.deleteCache(doomed.map((key) => `${this.scope}|q|${slug}|${key}`));
2522
+ }
2523
+ ensureQueueLoaded() {
2524
+ if (this.scopeHold) return this.scopeKnown().then(() => this.ensureQueueLoaded());
2525
+ if (!this.queueLoad) {
2526
+ const scope = this.scope;
2527
+ this.queueLoad = this.readQueue(`${scope}|`).then((queue) => {
2528
+ if (this.scope !== scope) return;
2529
+ this.queue = queue;
2530
+ this.patchStatus({ pending: queue.length });
2531
+ this.notifyQueue();
2532
+ }).catch(() => void 0);
2533
+ }
2534
+ return this.queueLoad;
2535
+ }
2536
+ enqueue(mutation, ticket) {
2537
+ const result = this.enqueueChain.then(async () => {
2538
+ await this.ensureQueueLoaded();
2539
+ if (!this.isCurrent(ticket)) {
2540
+ const orphan = {
2541
+ ...mutation,
2542
+ mutationId: createMutationId(),
2543
+ queuedAt: Date.now(),
2544
+ schemaVersion: 1
2545
+ };
2546
+ await this.persistMutation(`${ticket.scope}|${orphan.mutationId}`, orphan);
2547
+ return;
2548
+ }
2549
+ if (mutation.type === "update") {
2550
+ const tail = this.queue[this.queue.length - 1];
2551
+ if (tail && !this.inFlight.has(tail.mutationId) && tail.sent === void 0 && (tail.schemaVersion ?? 1) <= 1 && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id) {
2552
+ if (tail.type === "update") tail.parts = [...tail.parts ?? [{ ...tail.data }], { ...mutation.data }];
2553
+ tail.data = {
2554
+ ...tail.data,
2555
+ ...mutation.data,
2556
+ id: tail.id
2557
+ };
2558
+ await this.persistMutation(this.queueKey(tail), tail);
2559
+ return;
2560
+ }
2561
+ }
2562
+ if (mutation.type === "delete") {
2563
+ if (this.queue.some((m) => m.collection === mutation.collection && m.type === "create" && m.id === mutation.id && m.generatedId === true && m.sent === void 0 && !this.inFlight.has(m.mutationId))) {
2564
+ const doomed = this.queue.filter((m) => m.collection === mutation.collection && m.id === mutation.id && (m.type === "create" || m.type === "update") && !this.inFlight.has(m.mutationId));
2565
+ for (const op of doomed) await this.store.dequeue(this.queueKey(op));
2566
+ this.queue = this.queue.filter((m) => !doomed.includes(m));
2567
+ this.afterQueueChange();
2568
+ return;
2569
+ }
2570
+ }
2571
+ const full = {
2572
+ ...mutation,
2573
+ mutationId: createMutationId(),
2574
+ queuedAt: Date.now(),
2575
+ schemaVersion: 1
2576
+ };
2577
+ await this.persistMutation(this.queueKey(full), full);
2578
+ this.queue.push(full);
2579
+ this.queueChangedInPlace();
2580
+ this.afterQueueChange();
2581
+ this.requestPersistence();
2582
+ });
2583
+ this.enqueueChain = result.catch(() => void 0);
2584
+ return result;
2585
+ }
2586
+ /**
2587
+ * Ask the browser to keep the offline database, once, when there is first
2588
+ * something in it worth keeping. Without it IndexedDB is best-effort
2589
+ * storage, evicted under pressure (and by Safari after seven days without
2590
+ * a visit) with the unsent writes in it.
2591
+ */
2592
+ requestPersistence() {
2593
+ if (this.persistenceRequested || !(this.store instanceof IndexedDBOfflineStore)) return;
2594
+ this.persistenceRequested = true;
2595
+ const storage = globalThis.navigator?.storage;
2596
+ if (typeof storage?.persist !== "function") return;
2597
+ storage.persist().then((persisted) => this.patchStatus({ persisted }), () => void 0);
2598
+ }
2599
+ /** The queue's row ids for one collection — see {@link pendingIndex}. */
2600
+ pendingIds(slug) {
2601
+ if (!this.pendingIndex) {
2602
+ const index = /* @__PURE__ */ new Map();
2603
+ const note = (entry, key, op) => {
2604
+ entry.writes.add(key);
2605
+ if (!entry.first.has(key)) entry.first.set(key, op);
2606
+ };
2607
+ for (const op of this.queue) {
2608
+ let entry = index.get(op.collection);
2609
+ if (!entry) index.set(op.collection, entry = {
2610
+ writes: /* @__PURE__ */ new Set(),
2611
+ creates: /* @__PURE__ */ new Set(),
2612
+ first: /* @__PURE__ */ new Map()
2613
+ });
2614
+ if (op.type === "createMany") for (const row of op.data ?? []) {
2615
+ note(entry, String(row.id), op);
2616
+ entry.creates.add(String(row.id));
2617
+ }
2618
+ else if (op.type === "updateMany") for (const update of op.updates ?? []) note(entry, String(update.id), op);
2619
+ else if (op.type === "deleteMany") for (const id of op.ids ?? []) note(entry, String(id), op);
2620
+ else if (op.id !== void 0) {
2621
+ note(entry, String(op.id), op);
2622
+ if (op.type === "create") entry.creates.add(String(op.id));
2623
+ }
2624
+ }
2625
+ this.pendingIndex = index;
2626
+ }
2627
+ return this.pendingIndex.get(slug);
2628
+ }
2629
+ /**
2630
+ * Did the queued writes to this row bring it into the query's filter? The
2631
+ * row as it was before the first of them is that write's rollback; one
2632
+ * that did not match then is in no server page of the query, so only
2633
+ * this side can put it on screen.
2634
+ */
2635
+ movedIntoFilter(slug, idKey, params) {
2636
+ const before = this.pendingIds(slug)?.first.get(idKey)?.rollback?.rows?.[idKey];
2637
+ return before !== void 0 && before !== null && !matchesParams(before, params);
2638
+ }
2639
+ /** The queue's entries were changed without replacing the array. */
2640
+ queueChangedInPlace() {
2641
+ this.pendingIndex = void 0;
2642
+ }
2643
+ hasPending(slug, id) {
2644
+ return this.pendingIds(slug)?.writes.has(String(id)) ?? false;
2645
+ }
2646
+ /** Is this row one the server has never been told about? */
2647
+ isLocallyCreated(slug, idKey) {
2648
+ return this.pendingIds(slug)?.creates.has(idKey) ?? false;
2649
+ }
2650
+ /** How many rows the queue adds to (or removes from) a server-side count. */
2651
+ pendingDelta(slug, params) {
2652
+ if (!isExactlyEvaluable(params)) return 0;
2653
+ const counts = (row) => row && matchesParams(row, params) ? 1 : 0;
2654
+ const before = (op, id) => id === void 0 ? void 0 : op.rollback?.rows?.[String(id)];
2655
+ let delta = 0;
2656
+ for (const op of this.queue) {
2657
+ if (op.collection !== slug) continue;
2658
+ if (op.type === "create" || op.type === "createMany") {
2659
+ const rows = op.type === "create" ? [op.data] : op.data ?? [];
2660
+ for (const row of rows) delta += counts(row) - counts(before(op, row?.id));
2661
+ } else if (op.type === "update") {
2662
+ const was = before(op, op.id);
2663
+ if (was) delta += counts({
2664
+ ...was,
2665
+ ...op.data
2666
+ }) - counts(was);
2667
+ } else if (op.type === "updateMany") for (const update of op.updates ?? []) {
2668
+ const was = before(op, update.id);
2669
+ if (was) delta += counts({
2670
+ ...was,
2671
+ ...update.data
2672
+ }) - counts(was);
2673
+ }
2674
+ else if (op.type === "delete") delta -= counts(before(op, op.id));
2675
+ else if (op.type === "deleteMany") for (const id of op.ids ?? []) delta -= counts(before(op, id));
2676
+ }
2677
+ return delta;
2678
+ }
2679
+ sync() {
2680
+ if (this.flushPromise) return this.flushPromise;
2681
+ const run = () => this.withLock(() => this.flush());
2682
+ this.flushPromise = (this.scopeHold ? this.scopeKnown().then(run) : run()).finally(() => {
2683
+ this.flushPromise = void 0;
2684
+ });
2685
+ return this.flushPromise;
2686
+ }
2687
+ async flush() {
2688
+ const ticket = this.ticket() ?? await this.nextTicket();
2689
+ await this.ensureQueueLoaded();
2690
+ await this.reloadQueue();
2691
+ if (this.queue.length === 0) return {
2692
+ flushed: 0,
2693
+ remaining: 0
2694
+ };
2695
+ if (this.unverified) return {
2696
+ flushed: 0,
2697
+ remaining: this.queue.length
2698
+ };
2699
+ this.patchStatus({ syncing: true });
2700
+ const touched = /* @__PURE__ */ new Set();
2701
+ const queuedAtStart = this.queue.length;
2702
+ let flushed = 0;
2703
+ const unbatched = /* @__PURE__ */ new Set();
2704
+ try {
2705
+ while (this.queue.length > 0 && !this.disposed && this.isCurrent(ticket)) {
2706
+ const op = this.queue[0];
2707
+ if ((op.schemaVersion ?? 1) > 1) {
2708
+ this.patchStatus({ lastError: `A queued write was saved by a newer version of the SDK (layout ${String(op.schemaVersion)}); it is kept, and replays once this page runs that version.` });
2709
+ break;
2710
+ }
2711
+ touched.add(op.collection);
2712
+ const run = this.foldableRun(unbatched);
2713
+ if (run.length > 1) {
2714
+ const outcome = await this.replayRun(run, ticket);
2715
+ if (outcome === "offline") break;
2716
+ if (outcome === "unbatch") {
2717
+ for (const member of run) unbatched.add(member.mutationId);
2718
+ continue;
2719
+ }
2720
+ flushed += run.length;
2721
+ continue;
2722
+ }
2723
+ this.inFlight.add(op.mutationId);
2724
+ try {
2725
+ try {
2726
+ await this.replay(op, ticket);
2727
+ } catch (error) {
2728
+ if (isNetworkError(error)) {
2729
+ this.connectivity.markFailure();
2730
+ break;
2731
+ }
2732
+ if (op.type === "update" && op.ifMatch && isPreconditionFailed(error)) {
2733
+ this.connectivity.markSuccess();
2734
+ await this.resolveConflict(op, error, ticket);
2735
+ continue;
2736
+ }
2737
+ op.attempts = (op.attempts ?? 0) + 1;
2738
+ op.lastError = error?.message ?? String(error);
2739
+ const limit = isIdempotencyInProgressError(error) ? Math.max(this.maxRetries, IN_PROGRESS_MIN_RETRIES) : this.maxRetries;
2740
+ if (isRetryableError(error) && op.attempts < limit) {
2741
+ await this.persistMutation(`${ticket.scope}|${op.mutationId}`, op).catch(() => void 0);
2742
+ this.connectivity.deferRetry();
2743
+ this.patchStatus({ lastError: op.lastError });
2744
+ break;
2745
+ }
2746
+ if (op.type === "delete" && isNotFoundError(error)) {
2747
+ this.connectivity.markSuccess();
2748
+ this.removeLocalRow(op.collection, op.id, ticket, true);
2749
+ await this.drop(op, ticket);
2750
+ flushed++;
2751
+ continue;
2752
+ }
2753
+ if (isFieldRefusal(error) && (op.parts?.length ?? 0) > 1) {
2754
+ await this.splitCoalesced(op, ticket);
2755
+ continue;
2756
+ }
2757
+ await this.rejectMutation(op, error, ticket);
2758
+ continue;
2759
+ }
2760
+ this.connectivity.markSuccess();
2761
+ await this.drop(op, ticket);
2762
+ flushed++;
2763
+ } finally {
2764
+ this.inFlight.delete(op.mutationId);
2765
+ }
2766
+ }
2767
+ } finally {
2768
+ this.patchStatus({ syncing: false });
2769
+ }
2770
+ if (!this.isCurrent(ticket)) return {
2771
+ flushed,
2772
+ remaining: this.queue.length
2773
+ };
2774
+ if (this.queue.length !== queuedAtStart) {
2775
+ for (const slug of touched) {
2776
+ this.notifyCollection(slug);
2777
+ this.scheduleRefresh(slug);
2778
+ }
2779
+ this.broadcast({ type: "queue" });
2780
+ }
2781
+ if (this.queue.length === 0) this.patchStatus({ lastSyncedAt: Date.now() });
2782
+ return {
2783
+ flushed,
2784
+ remaining: this.queue.length
2785
+ };
2786
+ }
2787
+ /**
2788
+ * The creates at the head of the queue that may replay as one request —
2789
+ * see {@link OfflineConfig.replayBatchSize}. Fewer than two means the head
2790
+ * replays on its own.
2791
+ *
2792
+ * Consecutive, so the queue's order holds; one collection, since a bulk
2793
+ * write is one collection's. Each is a create never sent (so its request
2794
+ * is still its own to choose) with an id the SDK minted as a string — a
2795
+ * lost answer then cannot duplicate it, because the server refuses that
2796
+ * id the second time and the single replay recognises the row as its
2797
+ * own. A create whose values name another member's minted id waits: the
2798
+ * server may give that row a different id, which only a replay one at a
2799
+ * time can carry over.
2800
+ */
2801
+ foldableRun(unbatched) {
2802
+ if (this.replayBatchSize < 2) return [];
2803
+ const foldable = (op, collection) => op !== void 0 && op.type === "create" && op.generatedId === true && typeof op.id === "string" && op.sent === void 0 && !op.upsert && (op.schemaVersion ?? 1) <= 1 && !unbatched.has(op.mutationId) && (collection === void 0 || op.collection === collection);
2804
+ const head = this.queue[0];
2805
+ if (!foldable(head)) return [];
2806
+ const run = [head];
2807
+ for (let i = 1; i < this.queue.length && run.length < this.replayBatchSize; i++) {
2808
+ const next = this.queue[i];
2809
+ if (!foldable(next, head.collection)) break;
2810
+ if (run.some((member) => mentionsMintedId(next, member.id))) break;
2811
+ run.push(next);
2812
+ }
2813
+ return run;
2814
+ }
2815
+ /**
2816
+ * Replay a {@link foldableRun} as one `createMany`.
2817
+ *
2818
+ * - `"done"`: every member is acknowledged, adopted and dropped.
2819
+ * - `"offline"`: the connection failed; the members stay queued, as one
2820
+ * write would.
2821
+ * - `"unbatch"`: anything else — a refusal, a busy server. Nothing was
2822
+ * written (a bulk write is one transaction), and the caller replays the
2823
+ * members one at a time, where each refusal, retry budget and recovery
2824
+ * applies to its own write.
2825
+ *
2826
+ * The key names the run by its first and last member and its length,
2827
+ * which fixes its composition: a run re-sent after a lost answer is the
2828
+ * same request under the same key, and is answered from the server's
2829
+ * record of it.
2830
+ */
2831
+ async replayRun(run, ticket) {
2832
+ const inner = this.innerFor(run[0].collection);
2833
+ const idempotencyKey = `${run[0].mutationId}..${run[run.length - 1].mutationId}:${run.length}`;
2834
+ for (const member of run) this.inFlight.add(member.mutationId);
2835
+ try {
2836
+ let rows;
2837
+ try {
2838
+ rows = await inner.createMany(run.map((member) => member.data), { idempotencyKey });
2839
+ } catch (error) {
2840
+ if (!isNetworkError(error)) return "unbatch";
2841
+ this.connectivity.markFailure();
2842
+ return "offline";
2843
+ }
2844
+ this.connectivity.markSuccess();
2845
+ for (let i = 0; i < run.length; i++) {
2846
+ await this.adoptServerRow(run[i], run[i].id, rows[i], ticket);
2847
+ await this.drop(run[i], ticket);
2848
+ }
2849
+ return "done";
2850
+ } finally {
2851
+ for (const member of run) this.inFlight.delete(member.mutationId);
2852
+ }
2853
+ }
2854
+ async replay(op, ticket) {
2855
+ const inner = this.innerFor(op.collection);
2856
+ if (op.type === "create") {
2857
+ let row;
2858
+ try {
2859
+ row = await this.sendQueuedWrite(op, (idempotencyKey) => inner.create(op.data, void 0, { idempotencyKey }), (sent) => inner.create(sent.data, sent.id, { idempotencyKey: sent.idempotencyKey }));
2860
+ } catch (error) {
2861
+ if (!(op.generatedId === true && isDuplicateKeyError(error))) throw error;
2862
+ row = await inner.findById(op.id);
2863
+ if (!row) throw error;
2864
+ }
2865
+ await this.adoptServerRow(op, op.id, row, ticket);
2866
+ } else if (op.type === "createMany") {
2867
+ const queued = op.data ?? [];
2868
+ const asSent = (sent) => inner.createMany(sent.data, {
2869
+ ...op.upsert ? { upsert: true } : {},
2870
+ ...sent.onConflict?.length ? { onConflict: sent.onConflict } : {},
2871
+ idempotencyKey: sent.idempotencyKey
2872
+ });
2873
+ const rows = op.sent?.onConflict?.length ? await asSent(op.sent) : await this.sendQueuedWrite(op, (idempotencyKey) => inner.createMany(queued, {
2874
+ ...op.upsert ? { upsert: true } : {},
2875
+ idempotencyKey
2876
+ }), asSent);
2877
+ for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i], ticket);
2878
+ } else if (op.type === "updateMany") {
2879
+ const queued = op.updates ?? [];
2880
+ const rows = await inner.updateMany(queued.map((u) => ({
2881
+ id: u.id,
2882
+ data: u.data
2883
+ })), { idempotencyKey: op.mutationId });
2884
+ for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i], ticket);
2885
+ } else if (op.type === "update") {
2886
+ if (!op.sent) {
2887
+ op.sent = { idempotencyKey: op.mutationId };
2888
+ await this.persistMutation(`${ticket.scope}|${op.mutationId}`, op).catch(() => void 0);
2889
+ }
2890
+ const row = await inner.update(op.id, op.data, {
2891
+ idempotencyKey: op.sent.idempotencyKey,
2892
+ ...op.ifMatch ? { ifMatch: op.ifMatch } : {}
2893
+ });
2894
+ await this.ingestReplaced(op, op.id, row, ticket);
2895
+ } else if (op.type === "deleteMany") {
2896
+ const ids = op.ids ?? [];
2897
+ try {
2898
+ await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
2899
+ } catch (error) {
2900
+ if (!isNotFoundError(error)) throw error;
2901
+ for (const id of ids) try {
2902
+ await inner.delete(id, { idempotencyKey: `${op.mutationId}:${String(id)}` });
2903
+ } catch (rowError) {
2904
+ if (!isNotFoundError(rowError)) throw rowError;
2905
+ }
2906
+ }
2907
+ for (const id of ids) this.removeLocalRow(op.collection, id, ticket, true);
2908
+ } else if (op.type === "delete") {
2909
+ await inner.delete(op.id, { idempotencyKey: op.sent?.idempotencyKey ?? op.mutationId });
2910
+ this.removeLocalRow(op.collection, op.id, ticket, true);
2911
+ }
2912
+ }
2913
+ /**
2914
+ * Send a queued write under its key.
2915
+ *
2916
+ * A write the queue sent once already, before it was queued, carries that
2917
+ * attempt's key and is sent under it again, as queued. If the server holds
2918
+ * the key for a different request, the difference is the ids minted here
2919
+ * — so the first attempt committed and only its answer was lost, and
2920
+ * sending that request again returns the stored answer instead of a
2921
+ * second copy of the rows.
2922
+ */
2923
+ async sendQueuedWrite(op, asQueued, asSent) {
2924
+ try {
2925
+ return await asQueued(op.sent?.idempotencyKey ?? op.mutationId);
2926
+ } catch (error) {
2927
+ if (!op.sent || !isIdempotencyKeyReusedError(error)) throw error;
2928
+ return asSent(op.sent);
2929
+ }
2930
+ }
2931
+ /**
2932
+ * Take the server's version of a row the client created offline.
2933
+ *
2934
+ * The server may have assigned a different id — a serial column ignores
2935
+ * the id we invented — in which case every local trace of the temporary id
2936
+ * has to move with it, including queued writes that were made against it
2937
+ * before it was ever sent.
2938
+ */
2939
+ async adoptServerRow(op, localId, row, ticket) {
2940
+ if (!row) return;
2941
+ const slug = op.collection;
2942
+ const serverId = row.id;
2943
+ if (localId !== void 0 && serverId !== void 0 && String(serverId) !== String(localId)) {
2944
+ const oldKey = String(localId);
2945
+ this.removeLocalRow(slug, localId, ticket);
2946
+ const queue = this.isCurrent(ticket) ? this.queue : await this.readQueue(`${ticket.scope}|`).catch(() => []);
2947
+ const minted = op.type === "create" && op.generatedId === true;
2948
+ for (const queued of queue) {
2949
+ let dirty = false;
2950
+ if (minted && queued !== op) {
2951
+ const data = replaceMintedId(queued.data, localId, serverId);
2952
+ if (data.changed) {
2953
+ queued.data = data.value;
2954
+ dirty = true;
2955
+ }
2956
+ if (queued.updates) {
2957
+ const updates = replaceMintedId(queued.updates.map((u) => u.data), localId, serverId);
2958
+ if (updates.changed) {
2959
+ const values = updates.value;
2960
+ queued.updates = queued.updates.map((u, i) => ({
2961
+ id: u.id,
2962
+ data: values[i]
2963
+ }));
2964
+ dirty = true;
2965
+ }
2966
+ }
2967
+ }
2968
+ if (queued.collection !== slug) {
2969
+ if (dirty) await this.persistMutation(`${ticket.scope}|${queued.mutationId}`, queued).catch(() => void 0);
2970
+ continue;
2971
+ }
2972
+ if (queued.id !== void 0 && String(queued.id) === oldKey) {
2973
+ queued.id = serverId;
2974
+ this.queueChangedInPlace();
2975
+ if (queued.data && !Array.isArray(queued.data)) queued.data.id = serverId;
2976
+ dirty = true;
2977
+ }
2978
+ const rollbackRows = queued.rollback?.rows;
2979
+ if (rollbackRows && oldKey in rollbackRows) {
2980
+ rollbackRows[String(serverId)] = rollbackRows[oldKey];
2981
+ delete rollbackRows[oldKey];
2982
+ dirty = true;
2983
+ }
2984
+ if (dirty) await this.persistMutation(`${ticket.scope}|${queued.mutationId}`, queued).catch(() => void 0);
2985
+ }
2986
+ }
2987
+ await this.ingestReplaced(op, serverId ?? localId, row, ticket);
2988
+ }
2989
+ /**
2990
+ * Write a server row over the local one, ignoring the mutation that just
2991
+ * produced it — re-applying that would put the pre-server values back on
2992
+ * top of the server's answer — but keeping every write queued *after* it.
2993
+ * Those are still unsent, and dropping them here would make the row snap
2994
+ * back to the server's version in front of the user, only to change again
2995
+ * when they replay a moment later.
2996
+ */
2997
+ async ingestReplaced(op, id, row, ticket) {
2998
+ if (!this.isCurrent(ticket)) return;
2999
+ const slug = op.collection;
3000
+ const state = await this.ensureCollection(slug);
3001
+ if (!this.isCurrent(ticket)) return;
3002
+ const key = String(id);
3003
+ this.noteETag(state, key, row);
3004
+ const merged = this.applyPendingToRow(slug, key, { ...row }, op.mutationId);
3005
+ if (merged === void 0) {
3006
+ this.removeLocalRow(slug, key, ticket);
3007
+ return;
3008
+ }
3009
+ const cachedAt = Date.now();
3010
+ state.rows.set(key, {
3011
+ row: merged,
3012
+ cachedAt,
3013
+ rev: ++this.revCounter
3014
+ });
3015
+ if (this.applyPendingToRow(slug, key, void 0, op.mutationId) === void 0) state.freshRows.add(key);
3016
+ this.writeCache(this.rowKey(slug, key), dehydrateRow(merged), cachedAt);
3017
+ }
3018
+ /**
3019
+ * The server refused a mutation. Put back what it changed, and discard the
3020
+ * queued writes that were built on top of it: an edit to a row whose
3021
+ * creation was rejected can only fail the same way, and applying it would
3022
+ * leave the local database claiming a row the server does not have.
3023
+ *
3024
+ * The cascade stops the moment a later write stops *depending* on the
3025
+ * rejected one. An `update` reads the row it edits, so it is doomed with
3026
+ * it; a `create` overwrites the row outright and a `delete` needs nothing
3027
+ * of it, so both stand on their own and are kept — dropping them would
3028
+ * silently lose writes the server would have accepted.
3029
+ */
3030
+ async rejectMutation(op, error, ticket, report = true) {
3031
+ if (!this.isCurrent(ticket)) return;
3032
+ const ids = new Set(Object.keys(op.rollback?.rows ?? {}));
3033
+ if (op.id !== void 0) ids.add(String(op.id));
3034
+ const doomed = [op];
3035
+ const orphaned = new Set(ids);
3036
+ const neverCreated = op.type === "create" && op.generatedId === true && op.id !== void 0 ? [op.id] : [];
3037
+ const position = this.queue.indexOf(op);
3038
+ for (const later of this.queue.slice(position + 1)) {
3039
+ if (neverCreated.some((id) => mentionsMintedId(later, id))) {
3040
+ doomed.push(later);
3041
+ if (later.type === "create" && later.generatedId === true && later.id !== void 0) neverCreated.push(later.id);
3042
+ continue;
3043
+ }
3044
+ if (later.collection !== op.collection) continue;
3045
+ const hit = this.idsOf(later).filter((id) => orphaned.has(id));
3046
+ if (hit.length === 0) continue;
3047
+ if (later.type === "update") doomed.push(later);
3048
+ else for (const id of hit) orphaned.delete(id);
3049
+ }
3050
+ for (const dropped of doomed) await this.drop(dropped, ticket);
3051
+ if (!this.isCurrent(ticket)) return;
3052
+ const touched = /* @__PURE__ */ new Set([op.collection]);
3053
+ for (const dropped of doomed) {
3054
+ if (dropped === op || !neverCreated.length || dropped.collection === op.collection && dropped.type === "update") continue;
3055
+ touched.add(dropped.collection);
3056
+ for (const [idKey, previous] of Object.entries(dropped.rollback?.rows ?? {})) {
3057
+ const restored = this.applyPendingToRow(dropped.collection, idKey, previous ?? void 0);
3058
+ if (restored === void 0) this.removeLocalRow(dropped.collection, idKey, ticket);
3059
+ else this.setLocalRow(dropped.collection, idKey, restored, ticket);
3060
+ }
3061
+ }
3062
+ const goneKey = op.type === "update" && isNotFoundError(error) ? String(op.id) : void 0;
3063
+ for (const [idKey, previous] of Object.entries(op.rollback?.rows ?? {})) {
3064
+ const gone = idKey === goneKey;
3065
+ const restored = this.applyPendingToRow(op.collection, idKey, gone ? void 0 : previous ?? void 0);
3066
+ if (restored === void 0) this.removeLocalRow(op.collection, idKey, ticket, gone);
3067
+ else this.setLocalRow(op.collection, idKey, restored, ticket);
3068
+ }
3069
+ this.patchStatus({ lastError: error.message });
3070
+ for (const slug of touched) {
3071
+ this.notifyCollection(slug);
3072
+ this.scheduleRefresh(slug);
3073
+ }
3074
+ for (const dropped of doomed) {
3075
+ if (dropped === op && !report) continue;
3076
+ callSafely(() => this.onSyncError?.(dropped === op ? error : dependencyRejected(op, error), dropped));
3077
+ }
3078
+ }
3079
+ /**
3080
+ * An edit replayed with `If-Match` and refused with a 412: ask
3081
+ * {@link OfflineConfig.onConflict}, and do what it says. A callback that
3082
+ * throws, or none, is "overwrite" — what replay did before it knew of
3083
+ * conflicts.
3084
+ */
3085
+ async resolveConflict(op, error, ticket) {
3086
+ let resolution = "overwrite";
3087
+ if (this.onConflict) try {
3088
+ resolution = await this.onConflict(error, op) === "discard" ? "discard" : "overwrite";
3089
+ } catch (callbackError) {
3090
+ console.error("Error in offline onConflict:", callbackError);
3091
+ }
3092
+ if (resolution === "overwrite") {
3093
+ delete op.ifMatch;
3094
+ await this.persistMutation(`${ticket.scope}|${op.mutationId}`, op).catch(() => void 0);
3095
+ return resolution;
3096
+ }
3097
+ await this.rejectMutation(op, error, ticket, false);
3098
+ const slug = op.collection;
3099
+ const id = op.id;
3100
+ try {
3101
+ const row = await this.innerFor(slug).findById(id);
3102
+ if (this.isCurrent(ticket)) {
3103
+ if (row) await this.ingest(slug, [row], ticket);
3104
+ else if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
3105
+ this.notifyCollection(slug);
3106
+ }
3107
+ } catch {}
3108
+ return resolution;
3109
+ }
3110
+ /**
3111
+ * Put the separate edits a coalesced `update` was made of back in the
3112
+ * queue, in its place and in their order, each with the row as it stood
3113
+ * before it as its rollback — so a refusal undoes that edit alone.
3114
+ *
3115
+ * Their ids extend the merged op's, which sorts them after it and before
3116
+ * whatever was queued next, so the queue's order survives a reload.
3117
+ */
3118
+ async splitCoalesced(op, ticket) {
3119
+ const parts = op.parts ?? [];
3120
+ const idKey = String(op.id);
3121
+ let before = op.rollback?.rows?.[idKey] ?? null;
3122
+ const pieces = parts.map((part, index) => {
3123
+ const piece = {
3124
+ mutationId: `${op.mutationId}:${index}`,
3125
+ collection: op.collection,
3126
+ type: "update",
3127
+ id: op.id,
3128
+ data: { ...part },
3129
+ queuedAt: op.queuedAt,
3130
+ ...index === 0 && op.ifMatch ? { ifMatch: op.ifMatch } : {},
3131
+ rollback: { rows: { [idKey]: before === null ? null : { ...before } } }
3132
+ };
3133
+ if (before !== null) before = {
3134
+ ...before,
3135
+ ...part
3136
+ };
3137
+ return piece;
3138
+ });
3139
+ for (const piece of pieces) await this.persistMutation(`${ticket.scope}|${piece.mutationId}`, piece).catch(() => void 0);
3140
+ await this.store.dequeue(`${ticket.scope}|${op.mutationId}`).catch(() => void 0);
3141
+ if (!this.isCurrent(ticket)) return;
3142
+ const position = this.queue.indexOf(op);
3143
+ this.queue.splice(position < 0 ? 0 : position, position < 0 ? 0 : 1, ...pieces);
3144
+ this.queueChangedInPlace();
3145
+ this.afterQueueChange(false);
3146
+ }
3147
+ /** Every row id a mutation writes to. */
3148
+ idsOf(op) {
3149
+ if (op.type === "createMany") return (op.data ?? []).map((r) => String(r.id));
3150
+ if (op.type === "updateMany") return (op.updates ?? []).map((u) => String(u.id));
3151
+ if (op.type === "deleteMany") return (op.ids ?? []).map((id) => String(id));
3152
+ return op.id === void 0 ? [] : [String(op.id)];
3153
+ }
3154
+ async drop(op, ticket) {
3155
+ await this.store.dequeue(`${ticket.scope}|${op.mutationId}`).catch(() => void 0);
3156
+ if (!this.isCurrent(ticket)) return;
3157
+ this.queue = this.queue.filter((m) => m.mutationId !== op.mutationId);
3158
+ this.afterQueueChange(false);
3159
+ }
3160
+ /** Replay uses unwrapped clients: a failure must never re-enqueue itself. */
3161
+ innerFor(slug) {
3162
+ let inner = this.inners.get(slug);
3163
+ if (!inner) {
3164
+ inner = this.createInner(slug);
3165
+ this.inners.set(slug, inner);
3166
+ }
3167
+ return inner;
3168
+ }
3169
+ async withLock(fn) {
3170
+ const locks = globalThis.navigator?.locks;
3171
+ if (!locks?.request) return fn();
3172
+ let started = false;
3173
+ try {
3174
+ return await locks.request(`rebase-offline-sync:${this.scope}`, () => {
3175
+ started = true;
3176
+ return fn();
3177
+ });
3178
+ } catch (error) {
3179
+ if (started) throw error;
3180
+ return fn();
3181
+ }
3182
+ }
3183
+ broadcast(message, scope = this.scope) {
3184
+ if (!this.channel) return;
3185
+ try {
3186
+ this.channel.postMessage({
3187
+ ...message,
3188
+ scope,
3189
+ sender: this.tabId
3190
+ });
3191
+ } catch {}
3192
+ }
3193
+ onBroadcast(message) {
3194
+ if (this.disposed || !message || typeof message !== "object") return;
3195
+ const msg = message;
3196
+ if (this.scopeHold || msg.sender === this.tabId || msg.scope !== this.scope) return;
3197
+ if (msg.type === "rows") for (const slug of msg.slugs ?? []) this.reloadCollection(slug);
3198
+ else if (msg.type === "queue") this.reloadQueue();
3199
+ }
3200
+ /** Re-read one collection from the store, replacing what is in memory. */
3201
+ async reloadCollection(slug) {
3202
+ const state = this.collections.get(slug);
3203
+ if (!state?.loaded || this.scopeHold) return;
3204
+ await this.reloadQueue();
3205
+ const scope = this.scope;
3206
+ const [rows, snapshots, absent] = await Promise.all([
3207
+ this.store.listCacheEntries(`${scope}|row|${slug}|`).catch(() => []),
3208
+ this.store.listCacheEntries(`${scope}|q|${slug}|`).catch(() => []),
3209
+ this.store.listCache(`${scope}|abs|${slug}|`).catch(() => [])
3210
+ ]);
3211
+ if (this.scope !== scope || this.collections.get(slug) !== state) return;
3212
+ const next = /* @__PURE__ */ new Map();
3213
+ for (const entry of rows) {
3214
+ const row = entry.value;
3215
+ if (!row || row.id === void 0 || row.id === null) continue;
3216
+ const key = String(row.id);
3217
+ const existing = state.rows.get(key);
3218
+ const hydrated = hydrateRow(row);
3219
+ const unchanged = existing && JSON.stringify(existing.row) === JSON.stringify(hydrated);
3220
+ next.set(key, {
3221
+ row: hydrated,
3222
+ cachedAt: entry.cachedAt,
3223
+ rev: unchanged ? existing.rev : ++this.revCounter
3224
+ });
3225
+ }
3226
+ state.rows = next;
3227
+ state.snapshots = /* @__PURE__ */ new Map();
3228
+ for (const entry of snapshots) {
3229
+ const key = entry.key.slice(`${scope}|q|${slug}|`.length);
3230
+ if (entry.value) state.snapshots.set(key, readSnapshot(entry.value));
3231
+ }
3232
+ state.absent = new Set(absent.map((entry) => entry.key.slice(`${scope}|abs|${slug}|`.length)));
3233
+ this.notifyCollection(slug, false);
3234
+ }
3235
+ async reloadQueue() {
3236
+ if (this.scopeHold) return;
3237
+ const scope = this.scope;
3238
+ const queue = await this.readQueue(`${scope}|`).catch(() => void 0);
3239
+ if (!queue || this.scope !== scope) return;
3240
+ this.queue = queue;
3241
+ this.afterQueueChange(false);
3242
+ }
3243
+ afterQueueChange(broadcast = true) {
3244
+ this.patchStatus({ pending: this.queue.length });
3245
+ this.notifyQueue();
3246
+ if (broadcast) this.broadcast({ type: "queue" });
3247
+ }
3248
+ notifyQueue() {
3249
+ for (const listener of this.queueListeners) callSafely(() => listener(this.queue.length));
3250
+ }
3251
+ patchStatus(patch) {
3252
+ let changed = false;
3253
+ for (const [key, value] of Object.entries(patch)) if (this.currentStatus[key] !== value) {
3254
+ this.currentStatus[key] = value;
3255
+ changed = true;
3256
+ }
3257
+ if (!changed) return;
3258
+ const snapshot = { ...this.currentStatus };
3259
+ for (const listener of this.statusListeners) callSafely(() => listener(snapshot));
3260
+ }
3261
+ countKey(slug, params) {
3262
+ return `${this.scope}|count|${slug}|${buildQueryString(params)}`;
3263
+ }
3264
+ rowKey(slug, id) {
3265
+ return `${this.scope}|row|${slug}|${String(id)}`;
3266
+ }
3267
+ absentKey(slug, id) {
3268
+ return `${this.scope}|abs|${slug}|${String(id)}`;
3269
+ }
3270
+ queueKey(mutation) {
3271
+ return `${this.scope}|${mutation.mutationId}`;
3272
+ }
3273
+ /** Queue a write in the store, its rows encoded as the cache's are. */
3274
+ persistMutation(key, op) {
3275
+ return this.store.enqueue(key, dehydrateMutation(op));
3276
+ }
3277
+ /** The store's queue under `prefix`, its rows revived. */
3278
+ async readQueue(prefix) {
3279
+ return (await this.store.listQueue(prefix)).map(hydrateMutation);
3280
+ }
3281
+ async readCache(key) {
3282
+ try {
3283
+ return (await this.store.getCache(key))?.value;
3284
+ } catch {
3285
+ return;
3286
+ }
3287
+ }
3288
+ async writeCache(key, value, cachedAt = Date.now()) {
3289
+ try {
3290
+ await this.store.setCache(key, {
3291
+ value,
3292
+ cachedAt
3293
+ });
3294
+ } catch {}
3295
+ }
3296
+ async deleteCache(keys) {
3297
+ try {
3298
+ await this.store.deleteCache(keys);
3299
+ } catch {}
3300
+ }
3301
+ };
3302
+ //#endregion
3303
+ export { OfflineManager };
3304
+
3305
+ //# sourceMappingURL=offline-QNQgsPOq-B2yGeXkn.js.map