@honua/app-platform 0.1.11-beta.0 → 0.1.13

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 (236) hide show
  1. package/columnar/apache-arrow.d.ts +17 -0
  2. package/columnar/apache-arrow.d.ts.map +1 -0
  3. package/columnar/apache-arrow.js +1036 -0
  4. package/columnar/batch-cache-conformance.d.ts +66 -0
  5. package/columnar/batch-cache-conformance.d.ts.map +1 -0
  6. package/columnar/batch-cache-conformance.js +317 -0
  7. package/columnar/batch-cache-indexeddb.d.ts +31 -0
  8. package/columnar/batch-cache-indexeddb.d.ts.map +1 -0
  9. package/columnar/batch-cache-indexeddb.js +148 -0
  10. package/columnar/batch-cache.d.ts +298 -0
  11. package/columnar/batch-cache.d.ts.map +1 -0
  12. package/columnar/batch-cache.js +757 -0
  13. package/columnar/geoarrow-aggregate.d.ts +178 -0
  14. package/columnar/geoarrow-aggregate.d.ts.map +1 -0
  15. package/columnar/geoarrow-aggregate.js +976 -0
  16. package/columnar/geoarrow-patch.d.ts +382 -0
  17. package/columnar/geoarrow-patch.d.ts.map +1 -0
  18. package/columnar/geoarrow-patch.js +1656 -0
  19. package/columnar/geoarrow-reproject.d.ts +22 -0
  20. package/columnar/geoarrow-reproject.d.ts.map +1 -0
  21. package/columnar/geoarrow-reproject.js +119 -0
  22. package/columnar/geoarrow-serialization.d.ts +68 -0
  23. package/columnar/geoarrow-serialization.d.ts.map +1 -0
  24. package/columnar/geoarrow-serialization.js +331 -0
  25. package/columnar/geoarrow-types.d.ts +271 -0
  26. package/columnar/geoarrow-types.d.ts.map +1 -0
  27. package/columnar/geoarrow-types.js +15 -0
  28. package/columnar/geoarrow.d.ts +52 -0
  29. package/columnar/geoarrow.d.ts.map +1 -0
  30. package/columnar/geoarrow.js +1469 -0
  31. package/columnar/index.d.ts +43 -0
  32. package/columnar/index.d.ts.map +1 -0
  33. package/columnar/index.js +29 -0
  34. package/columnar/operators.d.ts +70 -0
  35. package/columnar/operators.d.ts.map +1 -0
  36. package/columnar/operators.js +307 -0
  37. package/columnar/result.d.ts +369 -0
  38. package/columnar/result.d.ts.map +1 -0
  39. package/columnar/result.js +815 -0
  40. package/columnar/telemetry.d.ts +152 -0
  41. package/columnar/telemetry.d.ts.map +1 -0
  42. package/columnar/telemetry.js +265 -0
  43. package/columnar/transfer.d.ts +28 -0
  44. package/columnar/transfer.d.ts.map +1 -0
  45. package/columnar/transfer.js +669 -0
  46. package/columnar/types.d.ts +175 -0
  47. package/columnar/types.d.ts.map +1 -0
  48. package/columnar/types.js +22 -0
  49. package/columnar/worker.d.ts +157 -0
  50. package/columnar/worker.d.ts.map +1 -0
  51. package/columnar/worker.js +1059 -0
  52. package/contract/pmtiles.d.ts +6 -5
  53. package/contract/pmtiles.d.ts.map +1 -1
  54. package/contract/pmtiles.js +30 -5
  55. package/control-plane/generated/admin-operations.d.ts +3 -3
  56. package/control-plane/generated/admin-operations.d.ts.map +1 -1
  57. package/control-plane/generated/admin-operations.js +3 -3
  58. package/controls/catalog.d.ts.map +1 -1
  59. package/controls/catalog.js +110 -0
  60. package/controls/index.d.ts +1 -1
  61. package/controls/index.js +1 -1
  62. package/controls/types.d.ts +1 -1
  63. package/controls/types.js +1 -1
  64. package/core/client.d.ts.map +1 -1
  65. package/core/client.js +87 -19
  66. package/core/deprecation-notice.d.ts +28 -0
  67. package/core/deprecation-notice.d.ts.map +1 -0
  68. package/core/deprecation-notice.js +22 -0
  69. package/core/esri-geojson.d.ts.map +1 -1
  70. package/core/esri-geojson.js +10 -6
  71. package/core/optional-grpc-peer.d.ts +15 -0
  72. package/core/optional-grpc-peer.d.ts.map +1 -0
  73. package/core/optional-grpc-peer.js +60 -0
  74. package/esri-compat/basemap-gallery.d.ts +2 -0
  75. package/esri-compat/basemap-gallery.d.ts.map +1 -1
  76. package/esri-compat/basemap-gallery.js +12 -0
  77. package/esri-compat/basemap.d.ts +4 -0
  78. package/esri-compat/basemap.d.ts.map +1 -1
  79. package/esri-compat/basemap.js +70 -0
  80. package/esri-compat/bookmarks.d.ts +2 -0
  81. package/esri-compat/bookmarks.d.ts.map +1 -1
  82. package/esri-compat/bookmarks.js +12 -0
  83. package/esri-compat/controls.d.ts +18 -0
  84. package/esri-compat/controls.d.ts.map +1 -1
  85. package/esri-compat/controls.js +120 -57
  86. package/esri-compat/directions.d.ts +4 -0
  87. package/esri-compat/directions.d.ts.map +1 -1
  88. package/esri-compat/directions.js +30 -20
  89. package/esri-compat/editor.d.ts +4 -0
  90. package/esri-compat/editor.d.ts.map +1 -1
  91. package/esri-compat/editor.js +16 -0
  92. package/esri-compat/esri-config.d.ts +2 -1
  93. package/esri-compat/esri-config.d.ts.map +1 -1
  94. package/esri-compat/feature-effect.d.ts +23 -0
  95. package/esri-compat/feature-effect.d.ts.map +1 -0
  96. package/esri-compat/feature-effect.js +12 -0
  97. package/esri-compat/feature-form.d.ts +6 -0
  98. package/esri-compat/feature-form.d.ts.map +1 -1
  99. package/esri-compat/feature-form.js +63 -0
  100. package/esri-compat/feature-layer.d.ts +21 -2
  101. package/esri-compat/feature-layer.d.ts.map +1 -1
  102. package/esri-compat/feature-layer.js +172 -1
  103. package/esri-compat/feature-table.d.ts +5 -0
  104. package/esri-compat/feature-table.d.ts.map +1 -1
  105. package/esri-compat/feature-table.js +23 -0
  106. package/esri-compat/identity-manager.d.ts +1 -1
  107. package/esri-compat/identity-manager.js +1 -1
  108. package/esri-compat/legend.d.ts +2 -0
  109. package/esri-compat/legend.d.ts.map +1 -1
  110. package/esri-compat/legend.js +98 -0
  111. package/esri-compat/locator.d.ts +1 -1
  112. package/esri-compat/locator.js +1 -1
  113. package/esri-compat/map-view-mount.d.ts +1 -0
  114. package/esri-compat/map-view-mount.d.ts.map +1 -1
  115. package/esri-compat/map-view-mount.js +46 -25
  116. package/esri-compat/map-view.d.ts +13 -0
  117. package/esri-compat/map-view.d.ts.map +1 -1
  118. package/esri-compat/map-view.js +94 -8
  119. package/esri-compat/map.d.ts +5 -1
  120. package/esri-compat/map.d.ts.map +1 -1
  121. package/esri-compat/map.js +10 -0
  122. package/esri-compat/measurement.d.ts +2 -0
  123. package/esri-compat/measurement.d.ts.map +1 -1
  124. package/esri-compat/measurement.js +14 -0
  125. package/esri-compat/point.d.ts +7 -0
  126. package/esri-compat/point.d.ts.map +1 -1
  127. package/esri-compat/point.js +33 -3
  128. package/esri-compat/popup.d.ts +2 -0
  129. package/esri-compat/popup.d.ts.map +1 -1
  130. package/esri-compat/popup.js +12 -0
  131. package/esri-compat/portal.d.ts +1 -1
  132. package/esri-compat/portal.js +1 -1
  133. package/esri-compat/print.d.ts +3 -0
  134. package/esri-compat/print.d.ts.map +1 -1
  135. package/esri-compat/print.js +15 -0
  136. package/esri-compat/search.d.ts +4 -0
  137. package/esri-compat/search.d.ts.map +1 -1
  138. package/esri-compat/search.js +25 -28
  139. package/esri-compat/sketch.d.ts +2 -0
  140. package/esri-compat/sketch.d.ts.map +1 -1
  141. package/esri-compat/sketch.js +12 -0
  142. package/esri-compat/widget-host.d.ts +46 -17
  143. package/esri-compat/widget-host.d.ts.map +1 -1
  144. package/esri-compat/widget-host.js +84 -20
  145. package/esri-compat/widget-shell-hosts.d.ts +61 -0
  146. package/esri-compat/widget-shell-hosts.d.ts.map +1 -0
  147. package/esri-compat/widget-shell-hosts.js +76 -0
  148. package/kernel/renderer.d.ts +82 -0
  149. package/kernel/renderer.js +1 -0
  150. package/offline/conflict-resolution.d.ts +183 -0
  151. package/offline/conflict-resolution.d.ts.map +1 -0
  152. package/offline/conflict-resolution.js +286 -0
  153. package/offline/credential-screen.d.ts +34 -0
  154. package/offline/credential-screen.d.ts.map +1 -0
  155. package/offline/credential-screen.js +78 -0
  156. package/offline/digest.d.ts +15 -0
  157. package/offline/digest.d.ts.map +1 -0
  158. package/offline/digest.js +58 -0
  159. package/offline/edit-queue.d.ts +403 -0
  160. package/offline/edit-queue.d.ts.map +1 -0
  161. package/offline/edit-queue.js +1807 -0
  162. package/offline/edit-replay.d.ts +116 -0
  163. package/offline/edit-replay.d.ts.map +1 -0
  164. package/offline/edit-replay.js +392 -0
  165. package/offline/fetch-handler.d.ts +12 -0
  166. package/offline/fetch-handler.d.ts.map +1 -0
  167. package/offline/fetch-handler.js +65 -0
  168. package/offline/index.d.ts +52 -0
  169. package/offline/index.d.ts.map +1 -0
  170. package/offline/index.js +34 -0
  171. package/offline/indexeddb.d.ts +135 -0
  172. package/offline/indexeddb.d.ts.map +1 -0
  173. package/offline/indexeddb.js +888 -0
  174. package/offline/memory-store.d.ts +17 -0
  175. package/offline/memory-store.d.ts.map +1 -0
  176. package/offline/memory-store.js +183 -0
  177. package/offline/quota.d.ts +161 -0
  178. package/offline/quota.d.ts.map +1 -0
  179. package/offline/quota.js +370 -0
  180. package/offline/read-gate.d.ts +175 -0
  181. package/offline/read-gate.d.ts.map +1 -0
  182. package/offline/read-gate.js +252 -0
  183. package/offline/read-resource.d.ts +99 -0
  184. package/offline/read-resource.d.ts.map +1 -0
  185. package/offline/read-resource.js +156 -0
  186. package/offline/read.d.ts +50 -0
  187. package/offline/read.d.ts.map +1 -0
  188. package/offline/read.js +65 -0
  189. package/offline/region.d.ts +29 -0
  190. package/offline/region.d.ts.map +1 -0
  191. package/offline/region.js +1007 -0
  192. package/offline/replay-conflict.d.ts +183 -0
  193. package/offline/replay-conflict.d.ts.map +1 -0
  194. package/offline/replay-conflict.js +482 -0
  195. package/offline/selection.d.ts +140 -0
  196. package/offline/selection.d.ts.map +1 -0
  197. package/offline/selection.js +391 -0
  198. package/offline/snapshot.d.ts +132 -0
  199. package/offline/snapshot.d.ts.map +1 -0
  200. package/offline/snapshot.js +312 -0
  201. package/offline/status.d.ts +136 -0
  202. package/offline/status.d.ts.map +1 -0
  203. package/offline/status.js +479 -0
  204. package/offline/store-conformance.d.ts +48 -0
  205. package/offline/store-conformance.d.ts.map +1 -0
  206. package/offline/store-conformance.js +233 -0
  207. package/offline/tile-protocol.d.ts +81 -0
  208. package/offline/tile-protocol.d.ts.map +1 -0
  209. package/offline/tile-protocol.js +119 -0
  210. package/offline/types.d.ts +349 -0
  211. package/offline/types.d.ts.map +1 -0
  212. package/offline/types.js +75 -0
  213. package/package.json +14 -2
  214. package/scene-workspace/index.d.ts +2 -2
  215. package/scene-workspace/index.d.ts.map +1 -1
  216. package/scene-workspace/index.js +1 -1
  217. package/scene-workspace/scene-discovery.d.ts +111 -91
  218. package/scene-workspace/scene-discovery.d.ts.map +1 -1
  219. package/scene-workspace/scene-discovery.js +340 -92
  220. package/scene-workspace/scene-view.d.ts +6 -6
  221. package/scene-workspace/scene-view.d.ts.map +1 -1
  222. package/scene-workspace/scene-view.js +32 -6
  223. package/web-components/elements.d.ts +34 -0
  224. package/web-components/elements.d.ts.map +1 -1
  225. package/web-components/elements.js +82 -8
  226. package/web-components/index.d.ts +2 -0
  227. package/web-components/index.d.ts.map +1 -1
  228. package/web-components/index.js +3 -1
  229. package/web-components/map-chrome.d.ts +103 -0
  230. package/web-components/map-chrome.d.ts.map +1 -0
  231. package/web-components/map-chrome.js +325 -0
  232. package/web-components/widget-shell.d.ts +68 -0
  233. package/web-components/widget-shell.d.ts.map +1 -0
  234. package/web-components/widget-shell.js +224 -0
  235. package/widget-capabilities.d.ts +67 -0
  236. package/widget-capabilities.js +152 -0
@@ -0,0 +1,479 @@
1
+ import { normalizeOfflineReplaySyncConflictBinding, projectOfflineReplaySyncConflict, } from "./replay-conflict.js";
2
+ import { HONUA_OFFLINE_REGION_DIAGNOSTIC_KIND, HONUA_OFFLINE_REGION_DIAGNOSTIC_VERSION, HonuaOfflineRegionError, } from "./types.js";
3
+ /** Stable discriminator and version for composed local-first status snapshots. */
4
+ export const HONUA_LOCAL_FIRST_STATUS_KIND = "honua.local-first-status";
5
+ export const HONUA_LOCAL_FIRST_STATUS_VERSION = "1.0";
6
+ /** Conservative ceilings applied before any input array is copied or sorted. */
7
+ export const DEFAULT_LOCAL_FIRST_MAX_REGIONS = 1_000;
8
+ export const DEFAULT_LOCAL_FIRST_MAX_EDITS = 10_000;
9
+ export const DEFAULT_LOCAL_FIRST_MAX_LISTED_EDIT_IDS = 100;
10
+ const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
11
+ const OPTION_KEYS = new Set(["connectivity", "now", "regions", "edits", "editCounts", "replica", "limits"]);
12
+ const LIMIT_KEYS = new Set(["maxRegions", "maxEdits", "maxListedEditIds"]);
13
+ const DIAGNOSTIC_KEYS = new Set([
14
+ "kind",
15
+ "version",
16
+ "generatedAt",
17
+ "regionId",
18
+ "inventoryRevision",
19
+ "cache",
20
+ "provenance",
21
+ "contents",
22
+ "admission",
23
+ "storage",
24
+ ]);
25
+ const DIAGNOSTIC_CACHE_KEYS = new Set([
26
+ "state",
27
+ "freshness",
28
+ "completeness",
29
+ "reason",
30
+ "readable",
31
+ "observedAt",
32
+ "ageMs",
33
+ "staleAfterMs",
34
+ "expectedLogicalBytes",
35
+ "storedLogicalBytes",
36
+ "lastAccessedAt",
37
+ "expiresAt",
38
+ "pinned",
39
+ ]);
40
+ const QUEUED_EDIT_KEYS = new Set([
41
+ "version",
42
+ "id",
43
+ "requestFingerprint",
44
+ "authorizationScopeDigest",
45
+ "sourceId",
46
+ "idempotencyKey",
47
+ "edit",
48
+ "dependencyIds",
49
+ "state",
50
+ "createdAt",
51
+ "updatedAt",
52
+ "attemptCount",
53
+ "lease",
54
+ "retry",
55
+ "applied",
56
+ "conflict",
57
+ "conflictResolution",
58
+ "cancellation",
59
+ "audit",
60
+ ]);
61
+ const RETRY_KEYS = new Set(["retryAt", "reasonCode"]);
62
+ const CACHE_STATES = new Set(["offline", "missing"]);
63
+ const FRESHNESS_VALUES = new Set(["fresh", "stale", "expired"]);
64
+ const COMPLETENESS_VALUES = new Set(["complete", "partial", "missing"]);
65
+ const CACHE_REASONS = new Set(["offline-entry", "stale-entry", "expired-entry", "partial-entry", "cache-miss"]);
66
+ const EDIT_STATES = [
67
+ "pending",
68
+ "leased",
69
+ "retryable",
70
+ "applied",
71
+ "conflicted",
72
+ "cancelled",
73
+ ];
74
+ const UNDELIVERED_EDIT_STATES = new Set(["pending", "leased", "retryable"]);
75
+ const FRESHNESS_RANK = { fresh: 0, stale: 1, expired: 2 };
76
+ const COMPLETENESS_RANK = { complete: 0, partial: 1, missing: 2 };
77
+ /**
78
+ * Compose one local-first status from region diagnostics, durable queue state,
79
+ * and a host-supplied connectivity signal.
80
+ *
81
+ * The headline `state` is decided by a total precedence over cache and queue
82
+ * facets, with connectivity supplying only the base case:
83
+ *
84
+ * `conflicted` > `expired` > `partial` > `pending` > `stale` > `offline` > `online`
85
+ *
86
+ * Data problems therefore outrank undelivered work, which outranks mere
87
+ * staleness, which outranks being disconnected. Reads no payload bytes,
88
+ * performs no network or storage I/O, and mutates nothing.
89
+ */
90
+ export function createLocalFirstStatus(options) {
91
+ const record = plainRecord(options, "options");
92
+ allowedKeys(record, OPTION_KEYS, "options");
93
+ const limits = normalizeLimits(record.limits);
94
+ const connectivity = record.connectivity;
95
+ if (connectivity !== "online" && connectivity !== "offline") {
96
+ invalid("options.connectivity must be online or offline.", "options.connectivity");
97
+ }
98
+ const nowMs = record.now instanceof Date ? record.now.getTime() : Number.NaN;
99
+ if (!Number.isFinite(nowMs))
100
+ invalid("options.now must be a valid Date.", "options.now");
101
+ const replica = record.replica === undefined
102
+ ? undefined
103
+ : normalizeOfflineReplaySyncConflictBinding(record.replica, "options.replica");
104
+ // Only an absent key defaults; an explicit null must not read as "none".
105
+ const reads = summarizeReads(record.regions === undefined ? [] : record.regions, connectivity, limits);
106
+ const writes = summarizeWrites(record.edits === undefined ? [] : record.edits, record.editCounts, limits, replica);
107
+ const { state, reason } = resolveHeadline(reads, writes, connectivity);
108
+ return deepFreeze({
109
+ kind: HONUA_LOCAL_FIRST_STATUS_KIND,
110
+ version: HONUA_LOCAL_FIRST_STATUS_VERSION,
111
+ generatedAt: new Date(nowMs).toISOString(),
112
+ state,
113
+ reason,
114
+ connectivity,
115
+ reads,
116
+ writes,
117
+ });
118
+ }
119
+ function resolveHeadline(reads, writes, connectivity) {
120
+ if (writes.conflictedCount > 0)
121
+ return { state: "conflicted", reason: "conflicted-edits" };
122
+ if (reads.freshness === "expired")
123
+ return { state: "expired", reason: "expired-regions" };
124
+ if (reads.regionCount > 0 && reads.completeness !== "complete") {
125
+ return { state: "partial", reason: reads.completeness === "missing" ? "missing-regions" : "partial-regions" };
126
+ }
127
+ if (writes.undeliveredCount > 0)
128
+ return { state: "pending", reason: "undelivered-edits" };
129
+ if (reads.freshness === "stale")
130
+ return { state: "stale", reason: "stale-regions" };
131
+ if (connectivity === "offline")
132
+ return { state: "offline", reason: "disconnected" };
133
+ return { state: "online", reason: "connected" };
134
+ }
135
+ function summarizeReads(value, connectivity, limits) {
136
+ const input = denseArray(value, "options.regions", limits.maxRegions);
137
+ const seen = new Set();
138
+ const regions = [];
139
+ let storedRegionCount = 0;
140
+ let readableRegionCount = 0;
141
+ let freshness = "fresh";
142
+ let completeness;
143
+ let oldestObservedAt;
144
+ let earliestExpiresAt;
145
+ for (let index = 0; index < input.length; index += 1) {
146
+ const summary = captureRegionSummary(input[index], `options.regions[${index}]`);
147
+ if (seen.has(summary.regionId)) {
148
+ invalid(`Duplicate region diagnostic "${summary.regionId}".`, `options.regions[${index}].regionId`);
149
+ }
150
+ seen.add(summary.regionId);
151
+ regions.push(summary);
152
+ if (summary.state === "offline") {
153
+ storedRegionCount += 1;
154
+ // Freshness describes data that is actually held; a cache miss has no
155
+ // stored observation to age, so it must not colour the aggregate.
156
+ if (FRESHNESS_RANK[summary.freshness] > FRESHNESS_RANK[freshness])
157
+ freshness = summary.freshness;
158
+ }
159
+ if (summary.readable)
160
+ readableRegionCount += 1;
161
+ if (completeness === undefined || COMPLETENESS_RANK[summary.completeness] > COMPLETENESS_RANK[completeness]) {
162
+ completeness = summary.completeness;
163
+ }
164
+ if (oldestObservedAt === undefined || isEarlier(summary.observedAt, oldestObservedAt)) {
165
+ oldestObservedAt = summary.observedAt;
166
+ }
167
+ if (summary.expiresAt !== undefined &&
168
+ (earliestExpiresAt === undefined || isEarlier(summary.expiresAt, earliestExpiresAt))) {
169
+ earliestExpiresAt = summary.expiresAt;
170
+ }
171
+ }
172
+ regions.sort((left, right) => compareCodeUnits(left.regionId, right.regionId));
173
+ const availability = connectivity === "online" ? "live" : readableRegionCount > 0 ? "cached" : "unavailable";
174
+ return {
175
+ availability,
176
+ freshness,
177
+ completeness: completeness ?? "missing",
178
+ regionCount: regions.length,
179
+ storedRegionCount,
180
+ readableRegionCount,
181
+ ...(oldestObservedAt === undefined ? {} : { oldestObservedAt }),
182
+ ...(earliestExpiresAt === undefined ? {} : { earliestExpiresAt }),
183
+ regions,
184
+ };
185
+ }
186
+ function captureRegionSummary(value, path) {
187
+ const record = plainRecord(value, path);
188
+ allowedKeys(record, DIAGNOSTIC_KEYS, path);
189
+ if (record.kind !== HONUA_OFFLINE_REGION_DIAGNOSTIC_KIND)
190
+ invalid(`${path}.kind is unsupported.`, `${path}.kind`);
191
+ if (record.version !== HONUA_OFFLINE_REGION_DIAGNOSTIC_VERSION) {
192
+ invalid(`${path}.version is unsupported.`, `${path}.version`);
193
+ }
194
+ // Present but never descended into: this projection reads no payload,
195
+ // admission arithmetic, storage estimate, or attribution text.
196
+ plainRecord(record.contents, `${path}.contents`);
197
+ plainRecord(record.admission, `${path}.admission`);
198
+ if (record.storage !== undefined)
199
+ plainRecord(record.storage, `${path}.storage`);
200
+ const cache = plainRecord(record.cache, `${path}.cache`);
201
+ allowedKeys(cache, DIAGNOSTIC_CACHE_KEYS, `${path}.cache`);
202
+ const provenance = plainRecord(record.provenance, `${path}.provenance`);
203
+ const state = memberOf(cache.state, CACHE_STATES, `${path}.cache.state`);
204
+ const freshness = memberOf(cache.freshness, FRESHNESS_VALUES, `${path}.cache.freshness`);
205
+ const completeness = memberOf(cache.completeness, COMPLETENESS_VALUES, `${path}.cache.completeness`);
206
+ const reason = memberOf(cache.reason, CACHE_REASONS, `${path}.cache.reason`);
207
+ const readable = requiredBoolean(cache.readable, `${path}.cache.readable`);
208
+ assertCacheFacetsAgree({ state, freshness, completeness, reason, readable }, `${path}.cache`);
209
+ return {
210
+ regionId: requiredString(record.regionId, `${path}.regionId`),
211
+ sourceId: requiredString(provenance.sourceId, `${path}.provenance.sourceId`),
212
+ authorizationScopeDigest: requiredDigest(provenance.authorizationScopeDigest, `${path}.provenance.authorizationScopeDigest`),
213
+ state,
214
+ freshness,
215
+ completeness,
216
+ reason,
217
+ readable,
218
+ observedAt: normalizedTimestamp(cache.observedAt, `${path}.cache.observedAt`),
219
+ ageMs: nonNegativeInteger(cache.ageMs, `${path}.cache.ageMs`),
220
+ ...(cache.expiresAt === undefined
221
+ ? {}
222
+ : { expiresAt: normalizedTimestamp(cache.expiresAt, `${path}.cache.expiresAt`) }),
223
+ pinned: requiredBoolean(cache.pinned, `${path}.cache.pinned`),
224
+ };
225
+ }
226
+ /**
227
+ * `createOfflineRegionDiagnostic` derives every cache facet from one stored
228
+ * entry, so the facets are not independent. Checking each literal in isolation
229
+ * would accept impossible combinations - a `missing` entry claiming to be
230
+ * `complete` and `readable` - and an impossible input would then resolve to a
231
+ * confidently wrong headline. Reject the combination instead.
232
+ */
233
+ function assertCacheFacetsAgree(facets, path) {
234
+ const stored = facets.state === "offline";
235
+ if (stored !== (facets.completeness !== "missing")) {
236
+ invalid(`${path}.completeness does not agree with ${path}.state.`, `${path}.completeness`);
237
+ }
238
+ const readable = stored && facets.completeness === "complete" && facets.freshness !== "expired";
239
+ if (facets.readable !== readable) {
240
+ invalid(`${path}.readable does not agree with the reported cache facets.`, `${path}.readable`);
241
+ }
242
+ const reason = !stored
243
+ ? "cache-miss"
244
+ : facets.freshness === "expired"
245
+ ? "expired-entry"
246
+ : facets.completeness === "partial"
247
+ ? "partial-entry"
248
+ : facets.freshness === "stale"
249
+ ? "stale-entry"
250
+ : "offline-entry";
251
+ if (facets.reason !== reason) {
252
+ invalid(`${path}.reason does not agree with the reported cache facets.`, `${path}.reason`);
253
+ }
254
+ }
255
+ function summarizeWrites(value, countsValue, limits, replica) {
256
+ const input = denseArray(value, "options.edits", limits.maxEdits);
257
+ const counts = {
258
+ pending: 0,
259
+ leased: 0,
260
+ retryable: 0,
261
+ applied: 0,
262
+ conflicted: 0,
263
+ cancelled: 0,
264
+ };
265
+ const seen = new Set();
266
+ const conflictedEditIds = [];
267
+ const projections = new Map();
268
+ let nextRetryAt;
269
+ let oldestUndeliveredAt;
270
+ for (let index = 0; index < input.length; index += 1) {
271
+ const path = `options.edits[${index}]`;
272
+ const record = plainRecord(input[index], path);
273
+ allowedKeys(record, QUEUED_EDIT_KEYS, path);
274
+ const id = requiredDigest(record.id, `${path}.id`);
275
+ if (seen.has(id))
276
+ invalid(`Duplicate queued edit "${id}".`, `${path}.id`);
277
+ seen.add(id);
278
+ const state = memberOfList(record.state, EDIT_STATES, `${path}.state`);
279
+ counts[state] += 1;
280
+ const createdAt = normalizedTimestamp(record.createdAt, `${path}.createdAt`);
281
+ if (state === "conflicted") {
282
+ conflictedEditIds.push(id);
283
+ // Projected from the same record this summary already validated, so the
284
+ // conflict vocabulary and the conflicted-edit count can never disagree.
285
+ if (replica !== undefined) {
286
+ projections.set(id, projectOfflineReplaySyncConflict({ edit: record, replica }));
287
+ }
288
+ }
289
+ if (UNDELIVERED_EDIT_STATES.has(state)) {
290
+ if (oldestUndeliveredAt === undefined || isEarlier(createdAt, oldestUndeliveredAt)) {
291
+ oldestUndeliveredAt = createdAt;
292
+ }
293
+ }
294
+ if (record.retry !== undefined) {
295
+ const retry = plainRecord(record.retry, `${path}.retry`);
296
+ allowedKeys(retry, RETRY_KEYS, `${path}.retry`);
297
+ const retryAt = normalizedTimestamp(retry.retryAt, `${path}.retry.retryAt`);
298
+ if (state === "retryable" && (nextRetryAt === undefined || isEarlier(retryAt, nextRetryAt))) {
299
+ nextRetryAt = retryAt;
300
+ }
301
+ }
302
+ }
303
+ conflictedEditIds.sort(compareCodeUnits);
304
+ // Authoritative totals win. A bounded sample can only ever be a lower bound,
305
+ // and reporting it as the total would let later undelivered work read as idle.
306
+ const authoritative = captureEditCounts(countsValue, counts);
307
+ const totals = authoritative ?? counts;
308
+ const conflictedCount = totals.conflicted;
309
+ const listedConflictedEditIds = conflictedEditIds.slice(0, limits.maxListedEditIds);
310
+ return {
311
+ state: conflictedCount > 0 ? "conflicted" : undeliveredTotal(totals) > 0 ? "pending" : "idle",
312
+ coverage: authoritative ? "complete" : "sampled",
313
+ counts: { ...totals },
314
+ undeliveredCount: undeliveredTotal(totals),
315
+ conflictedCount,
316
+ conflictedEditIds: listedConflictedEditIds,
317
+ conflictedEditIdsTruncated: conflictedCount > Math.min(conflictedEditIds.length, limits.maxListedEditIds),
318
+ syncConflicts: listedConflictedEditIds.map((id) => projections.get(id)).filter(isProjection),
319
+ ...(nextRetryAt === undefined ? {} : { nextRetryAt }),
320
+ ...(oldestUndeliveredAt === undefined ? {} : { oldestUndeliveredAt }),
321
+ };
322
+ }
323
+ function isProjection(value) {
324
+ return value !== undefined;
325
+ }
326
+ function undeliveredTotal(counts) {
327
+ let total = 0;
328
+ for (const state of EDIT_STATES)
329
+ if (UNDELIVERED_EDIT_STATES.has(state))
330
+ total += counts[state];
331
+ return total;
332
+ }
333
+ function captureEditCounts(value, sampled) {
334
+ if (value === undefined)
335
+ return undefined;
336
+ const record = plainRecord(value, "options.editCounts");
337
+ allowedKeys(record, new Set(EDIT_STATES), "options.editCounts");
338
+ const totals = { ...sampled };
339
+ for (const state of EDIT_STATES) {
340
+ const total = nonNegativeInteger(record[state], `options.editCounts.${state}`);
341
+ // A sample that exceeds its own total means the two inputs disagree; refuse
342
+ // rather than publish a status that cannot be true.
343
+ if (total < sampled[state]) {
344
+ invalid(`options.editCounts.${state} is smaller than the supplied sample.`, `options.editCounts.${state}`);
345
+ }
346
+ totals[state] = total;
347
+ }
348
+ return totals;
349
+ }
350
+ function normalizeLimits(value) {
351
+ if (value === undefined) {
352
+ return {
353
+ maxRegions: DEFAULT_LOCAL_FIRST_MAX_REGIONS,
354
+ maxEdits: DEFAULT_LOCAL_FIRST_MAX_EDITS,
355
+ maxListedEditIds: DEFAULT_LOCAL_FIRST_MAX_LISTED_EDIT_IDS,
356
+ };
357
+ }
358
+ const record = plainRecord(value, "options.limits");
359
+ allowedKeys(record, LIMIT_KEYS, "options.limits");
360
+ return {
361
+ maxRegions: tightened(record.maxRegions, DEFAULT_LOCAL_FIRST_MAX_REGIONS, "options.limits.maxRegions"),
362
+ maxEdits: tightened(record.maxEdits, DEFAULT_LOCAL_FIRST_MAX_EDITS, "options.limits.maxEdits"),
363
+ maxListedEditIds: tightened(record.maxListedEditIds, DEFAULT_LOCAL_FIRST_MAX_LISTED_EDIT_IDS, "options.limits.maxListedEditIds"),
364
+ };
365
+ }
366
+ function tightened(value, ceiling, path) {
367
+ return Math.min(value === undefined ? ceiling : nonNegativeInteger(value, path), ceiling);
368
+ }
369
+ function plainRecord(value, path) {
370
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
371
+ invalid(`${path} must be a plain object.`, path);
372
+ }
373
+ const prototype = Object.getPrototypeOf(value);
374
+ if (prototype !== Object.prototype && prototype !== null)
375
+ invalid(`${path} must be a plain object.`, path);
376
+ if (Object.getOwnPropertySymbols(value).length > 0)
377
+ invalid(`${path} must not contain symbol properties.`, path);
378
+ for (const key in value) {
379
+ if (!Object.hasOwn(value, key))
380
+ continue;
381
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
382
+ if (!descriptor || !("value" in descriptor))
383
+ invalid(`${path}.${key} must be a data property.`, `${path}.${key}`);
384
+ }
385
+ return value;
386
+ }
387
+ function allowedKeys(record, allowed, path) {
388
+ for (const key in record) {
389
+ if (Object.hasOwn(record, key) && !allowed.has(key))
390
+ invalid(`${path}.${key} is not supported.`, `${path}.${key}`);
391
+ }
392
+ }
393
+ function denseArray(value, path, maximum) {
394
+ if (!Array.isArray(value))
395
+ invalid(`${path} must be an array.`, path);
396
+ if (Object.getPrototypeOf(value) !== Array.prototype || Object.getOwnPropertySymbols(value).length > 0) {
397
+ invalid(`${path} must be a plain array.`, path);
398
+ }
399
+ if (value.length > maximum) {
400
+ throw new HonuaOfflineRegionError("resource-limit-exceeded", `${path} contains ${value.length} entries; maximum is ${maximum}.`, { path });
401
+ }
402
+ for (const key in value) {
403
+ if (!Object.hasOwn(value, key))
404
+ continue;
405
+ const index = Number(key);
406
+ if (!Number.isSafeInteger(index) || index < 0 || index >= value.length || String(index) !== key) {
407
+ invalid(`${path} must not contain named properties.`, `${path}.${key}`);
408
+ }
409
+ }
410
+ for (let index = 0; index < value.length; index += 1) {
411
+ if (!Object.hasOwn(value, index))
412
+ invalid(`${path} must not contain sparse entries.`, `${path}[${index}]`);
413
+ const descriptor = Object.getOwnPropertyDescriptor(value, index);
414
+ if (!descriptor || !("value" in descriptor)) {
415
+ invalid(`${path}[${index}] must be a data property.`, `${path}[${index}]`);
416
+ }
417
+ }
418
+ return value;
419
+ }
420
+ function memberOf(value, allowed, path) {
421
+ if (typeof value !== "string" || !allowed.has(value))
422
+ invalid(`${path} is invalid.`, path);
423
+ return value;
424
+ }
425
+ function memberOfList(value, allowed, path) {
426
+ if (typeof value !== "string" || !allowed.includes(value)) {
427
+ invalid(`${path} is invalid.`, path);
428
+ }
429
+ return value;
430
+ }
431
+ function requiredString(value, path) {
432
+ if (typeof value !== "string" || value.length === 0 || value.trim() !== value) {
433
+ invalid(`${path} must be a normalized non-empty string.`, path);
434
+ }
435
+ return value;
436
+ }
437
+ function requiredDigest(value, path) {
438
+ const result = requiredString(value, path);
439
+ if (!DIGEST_PATTERN.test(result))
440
+ invalid(`${path} must be a lowercase SHA-256 digest.`, path);
441
+ return result;
442
+ }
443
+ function requiredBoolean(value, path) {
444
+ if (typeof value !== "boolean")
445
+ invalid(`${path} must be a boolean.`, path);
446
+ return value;
447
+ }
448
+ function nonNegativeInteger(value, path) {
449
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
450
+ invalid(`${path} must be a non-negative safe integer.`, path);
451
+ }
452
+ return value;
453
+ }
454
+ function normalizedTimestamp(value, path) {
455
+ const raw = requiredString(value, path);
456
+ const parsed = Date.parse(raw);
457
+ if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== raw) {
458
+ invalid(`${path} must be a normalized ISO-8601 timestamp.`, path);
459
+ }
460
+ return raw;
461
+ }
462
+ function compareCodeUnits(left, right) {
463
+ return left < right ? -1 : left > right ? 1 : 0;
464
+ }
465
+ /** Chronological comparison; normalized ISO strings still differ lexically for expanded years. */
466
+ function isEarlier(candidate, current) {
467
+ return Date.parse(candidate) < Date.parse(current);
468
+ }
469
+ function invalid(message, path) {
470
+ throw new HonuaOfflineRegionError("invalid-manifest", message, { path });
471
+ }
472
+ function deepFreeze(value) {
473
+ if (typeof value !== "object" || value === null || Object.isFrozen(value))
474
+ return value;
475
+ Object.freeze(value);
476
+ for (const child of Object.values(value))
477
+ deepFreeze(child);
478
+ return value;
479
+ }
@@ -0,0 +1,48 @@
1
+ import { type OfflineRegionStore } from "./types.js";
2
+ /**
3
+ * One conformance suite every offline region store must pass.
4
+ *
5
+ * The IndexedDB adapter can only run in a browser and the in-memory adapter can
6
+ * run anywhere, so the suite is written as plain async functions rather than
7
+ * against a test framework: vitest runs it over the memory store, and the browser
8
+ * evidence runs the identical cases over real IndexedDB. A store that passes here
9
+ * is substitutable in the download coordinator and the read path.
10
+ *
11
+ * @experimental
12
+ */
13
+ export declare const HONUA_OFFLINE_REGION_STORE_CONFORMANCE_KIND: "honua.offline-region-store-conformance";
14
+ export declare const HONUA_OFFLINE_REGION_STORE_CONFORMANCE_VERSION: "1.0";
15
+ export interface OfflineRegionStoreConformanceOptions {
16
+ /** Must return a store with no committed regions. Called once per case. */
17
+ readonly createStore: () => OfflineRegionStore | Promise<OfflineRegionStore>;
18
+ /** Optional teardown for the store a case finished with. */
19
+ readonly disposeStore?: (store: OfflineRegionStore) => void | Promise<void>;
20
+ /** Human label recorded on the report (for example `indexeddb`). */
21
+ readonly label?: string;
22
+ /** Run a subset by name. Unknown names fail the report rather than passing silently. */
23
+ readonly only?: readonly string[];
24
+ }
25
+ export interface OfflineRegionStoreConformanceCaseResultV1 {
26
+ readonly name: string;
27
+ readonly status: "passed" | "failed";
28
+ readonly detail?: string;
29
+ }
30
+ export interface OfflineRegionStoreConformanceReportV1 {
31
+ readonly kind: typeof HONUA_OFFLINE_REGION_STORE_CONFORMANCE_KIND;
32
+ readonly version: typeof HONUA_OFFLINE_REGION_STORE_CONFORMANCE_VERSION;
33
+ readonly label?: string;
34
+ readonly total: number;
35
+ readonly passed: number;
36
+ readonly failed: number;
37
+ readonly cases: readonly OfflineRegionStoreConformanceCaseResultV1[];
38
+ }
39
+ /** Names of every case in the suite, in execution order. */
40
+ export declare const OFFLINE_REGION_STORE_CONFORMANCE_CASES: readonly string[];
41
+ /**
42
+ * Run the suite and report per-case outcomes.
43
+ *
44
+ * Cases never share a store: each one is handed a fresh store from
45
+ * `createStore`, so a failure cannot cascade into a false failure downstream.
46
+ */
47
+ export declare function runOfflineRegionStoreConformance(options: OfflineRegionStoreConformanceOptions): Promise<OfflineRegionStoreConformanceReportV1>;
48
+ //# sourceMappingURL=store-conformance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-conformance.d.ts","sourceRoot":"","sources":["../../../src/offline/store-conformance.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,kBAAkB,EACxB,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;;GAUG;AAEH,eAAO,MAAM,2CAA2C,EAAG,wCAAiD,CAAC;AAC7G,eAAO,MAAM,8CAA8C,EAAG,KAAc,CAAC;AAE7E,MAAM,WAAW,oCAAoC;IACnD,2EAA2E;IAC3E,QAAQ,CAAC,WAAW,EAAE,MAAM,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAC7E,4DAA4D;IAC5D,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5E,oEAAoE;IACpE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,wFAAwF;IACxF,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC;AAED,MAAM,WAAW,yCAAyC;IACxD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,QAAQ,GAAG,QAAQ,CAAC;IACrC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,qCAAqC;IACpD,QAAQ,CAAC,IAAI,EAAE,OAAO,2CAA2C,CAAC;IAClE,QAAQ,CAAC,OAAO,EAAE,OAAO,8CAA8C,CAAC;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,yCAAyC,EAAE,CAAC;CACtE;AAOD,4DAA4D;AAC5D,eAAO,MAAM,sCAAsC,EAAE,SAAS,MAAM,EAWnE,CAAC;AAgIF;;;;;GAKG;AACH,wBAAsB,gCAAgC,CACpD,OAAO,EAAE,oCAAoC,GAC5C,OAAO,CAAC,qCAAqC,CAAC,CAoChD"}