foldkit-remote 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +987 -198
  2. package/dist/answerGaps.d.ts +16 -0
  3. package/dist/answerGaps.d.ts.map +1 -0
  4. package/dist/answerGaps.js +60 -0
  5. package/dist/answerGaps.js.map +1 -0
  6. package/dist/client-B055kQuP.d.ts +110 -0
  7. package/dist/client-B055kQuP.d.ts.map +1 -0
  8. package/dist/client.d.ts +2 -0
  9. package/dist/client.js +142 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/coalesce-CLAr92Nk.d.ts +34 -0
  12. package/dist/coalesce-CLAr92Nk.d.ts.map +1 -0
  13. package/dist/coalesce.d.ts +2 -0
  14. package/dist/coalesce.js +131 -0
  15. package/dist/coalesce.js.map +1 -0
  16. package/dist/connection-BRyaJy89.d.ts +45 -0
  17. package/dist/connection-BRyaJy89.d.ts.map +1 -0
  18. package/dist/connection.d.ts +2 -0
  19. package/dist/connection.js +170 -0
  20. package/dist/connection.js.map +1 -0
  21. package/dist/data.d.ts +7 -0
  22. package/dist/data.d.ts.map +1 -0
  23. package/dist/data.js +21 -0
  24. package/dist/data.js.map +1 -0
  25. package/dist/diagnostics-DHhVYflV.d.ts +125 -0
  26. package/dist/diagnostics-DHhVYflV.d.ts.map +1 -0
  27. package/dist/diagnostics.d.ts +2 -0
  28. package/dist/diagnostics.js +60 -0
  29. package/dist/diagnostics.js.map +1 -0
  30. package/dist/entity-CgsG5Uel.d.ts +196 -0
  31. package/dist/entity-CgsG5Uel.d.ts.map +1 -0
  32. package/dist/entity-DuDxaa3n.js +279 -0
  33. package/dist/entity-DuDxaa3n.js.map +1 -0
  34. package/dist/entity.d.ts +2 -0
  35. package/dist/entity.js +1 -0
  36. package/dist/held.d.ts +19 -0
  37. package/dist/held.d.ts.map +1 -0
  38. package/dist/held.js +70 -0
  39. package/dist/held.js.map +1 -0
  40. package/dist/impact.d.ts +79 -0
  41. package/dist/impact.d.ts.map +1 -0
  42. package/dist/impact.js +166 -0
  43. package/dist/impact.js.map +1 -0
  44. package/dist/index.d.ts +1542 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +2791 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/inspect-DtRQGCRP.d.ts +134 -0
  49. package/dist/inspect-DtRQGCRP.d.ts.map +1 -0
  50. package/dist/inspect.d.ts +2 -0
  51. package/dist/inspect.js +33 -0
  52. package/dist/inspect.js.map +1 -0
  53. package/dist/json-DowLl1eB.d.ts +52 -0
  54. package/dist/json-DowLl1eB.d.ts.map +1 -0
  55. package/dist/json.d.ts +2 -0
  56. package/dist/json.js +123 -0
  57. package/dist/json.js.map +1 -0
  58. package/dist/law.d.ts +56 -0
  59. package/dist/law.d.ts.map +1 -0
  60. package/dist/law.js +201 -0
  61. package/dist/law.js.map +1 -0
  62. package/dist/live-BpTzlCnY.d.ts +79 -0
  63. package/dist/live-BpTzlCnY.d.ts.map +1 -0
  64. package/dist/live.d.ts +2 -0
  65. package/dist/live.js +126 -0
  66. package/dist/live.js.map +1 -0
  67. package/dist/matching-DF5F9Us8.d.ts +100 -0
  68. package/dist/matching-DF5F9Us8.d.ts.map +1 -0
  69. package/dist/matching.d.ts +2 -0
  70. package/dist/matching.js +172 -0
  71. package/dist/matching.js.map +1 -0
  72. package/dist/model-B6zk1NMB.d.ts +710 -0
  73. package/dist/model-B6zk1NMB.d.ts.map +1 -0
  74. package/dist/model.d.ts +2 -0
  75. package/dist/model.js +1308 -0
  76. package/dist/model.js.map +1 -0
  77. package/dist/mutation.d.ts +2 -0
  78. package/dist/mutation.js +612 -0
  79. package/dist/mutation.js.map +1 -0
  80. package/dist/optimistic-CECsfNGY.d.ts +523 -0
  81. package/dist/optimistic-CECsfNGY.d.ts.map +1 -0
  82. package/dist/optimistic.d.ts +2 -0
  83. package/dist/optimistic.js +336 -0
  84. package/dist/optimistic.js.map +1 -0
  85. package/dist/outbox-DNuc4kZp.d.ts +259 -0
  86. package/dist/outbox-DNuc4kZp.d.ts.map +1 -0
  87. package/dist/outbox.d.ts +2 -0
  88. package/dist/outbox.js +326 -0
  89. package/dist/outbox.js.map +1 -0
  90. package/dist/patchValues.d.ts +10 -0
  91. package/dist/patchValues.d.ts.map +1 -0
  92. package/dist/patchValues.js +57 -0
  93. package/dist/patchValues.js.map +1 -0
  94. package/dist/persistence-CCMnvg7-.d.ts +114 -0
  95. package/dist/persistence-CCMnvg7-.d.ts.map +1 -0
  96. package/dist/persistence.d.ts +2 -0
  97. package/dist/persistence.js +205 -0
  98. package/dist/persistence.js.map +1 -0
  99. package/dist/placement-jZdsIpmj.d.ts +50 -0
  100. package/dist/placement-jZdsIpmj.d.ts.map +1 -0
  101. package/dist/placement.d.ts +2 -0
  102. package/dist/placement.js +116 -0
  103. package/dist/placement.js.map +1 -0
  104. package/dist/plan-D9cftxC_.d.ts +35 -0
  105. package/dist/plan-D9cftxC_.d.ts.map +1 -0
  106. package/dist/plan.d.ts +2 -0
  107. package/dist/plan.js +100 -0
  108. package/dist/plan.js.map +1 -0
  109. package/dist/policy-DsWHmIAq.d.ts +42 -0
  110. package/dist/policy-DsWHmIAq.d.ts.map +1 -0
  111. package/dist/policy.d.ts +2 -0
  112. package/dist/policy.js +32 -0
  113. package/dist/policy.js.map +1 -0
  114. package/dist/{port.d.mts → port.d.ts} +3 -2
  115. package/dist/port.d.ts.map +1 -0
  116. package/dist/{port.mjs → port.js} +3 -2
  117. package/dist/port.js.map +1 -0
  118. package/dist/query-BuIU93xA.d.ts +141 -0
  119. package/dist/query-BuIU93xA.d.ts.map +1 -0
  120. package/dist/query.d.ts +2 -0
  121. package/dist/query.js +0 -0
  122. package/dist/query.js.map +1 -0
  123. package/dist/record.d.ts +11 -0
  124. package/dist/record.d.ts.map +1 -0
  125. package/dist/record.js +21 -0
  126. package/dist/record.js.map +1 -0
  127. package/dist/relation-BLBGOZom.d.ts +59 -0
  128. package/dist/relation-BLBGOZom.d.ts.map +1 -0
  129. package/dist/relation.d.ts +2 -0
  130. package/dist/relation.js +93 -0
  131. package/dist/relation.js.map +1 -0
  132. package/dist/remoteData-mK1suYi1.d.ts +148 -0
  133. package/dist/remoteData-mK1suYi1.d.ts.map +1 -0
  134. package/dist/remoteData.d.ts +2 -0
  135. package/dist/remoteData.js +179 -0
  136. package/dist/remoteData.js.map +1 -0
  137. package/dist/reporting.d.ts +12 -0
  138. package/dist/reporting.d.ts.map +1 -0
  139. package/dist/reporting.js +45 -0
  140. package/dist/reporting.js.map +1 -0
  141. package/dist/requirement-D57S2LpH.d.ts +64 -0
  142. package/dist/requirement-D57S2LpH.d.ts.map +1 -0
  143. package/dist/requirement.d.ts +2 -0
  144. package/dist/requirement.js +114 -0
  145. package/dist/requirement.js.map +1 -0
  146. package/dist/resume-Ct5R6yli.d.ts +92 -0
  147. package/dist/resume-Ct5R6yli.d.ts.map +1 -0
  148. package/dist/resume.d.ts +2 -0
  149. package/dist/resume.js +222 -0
  150. package/dist/resume.js.map +1 -0
  151. package/dist/retain-BfNTvBRb.d.ts +48 -0
  152. package/dist/retain-BfNTvBRb.d.ts.map +1 -0
  153. package/dist/retain.d.ts +2 -0
  154. package/dist/retain.js +107 -0
  155. package/dist/retain.js.map +1 -0
  156. package/dist/selection.d.ts +2 -0
  157. package/dist/selection.js +1 -0
  158. package/dist/shown.d.ts +20 -0
  159. package/dist/shown.d.ts.map +1 -0
  160. package/dist/shown.js +40 -0
  161. package/dist/shown.js.map +1 -0
  162. package/dist/sse-C7LhdbiQ.d.ts +17 -0
  163. package/dist/sse-C7LhdbiQ.d.ts.map +1 -0
  164. package/dist/sse.d.ts +2 -0
  165. package/dist/sse.js +127 -0
  166. package/dist/sse.js.map +1 -0
  167. package/dist/store-ah2qjcJW.d.ts +100 -0
  168. package/dist/store-ah2qjcJW.d.ts.map +1 -0
  169. package/dist/store.d.ts +2 -0
  170. package/dist/store.js +218 -0
  171. package/dist/store.js.map +1 -0
  172. package/dist/testing.d.ts +133 -0
  173. package/dist/testing.d.ts.map +1 -0
  174. package/dist/testing.js +0 -0
  175. package/dist/testing.js.map +1 -0
  176. package/dist/why-kgp_Wr3z.d.ts +483 -0
  177. package/dist/why-kgp_Wr3z.d.ts.map +1 -0
  178. package/dist/why.d.ts +2 -0
  179. package/dist/why.js +293 -0
  180. package/dist/why.js.map +1 -0
  181. package/dist/whyReasons.d.ts +42 -0
  182. package/dist/whyReasons.d.ts.map +1 -0
  183. package/dist/whyReasons.js +211 -0
  184. package/dist/whyReasons.js.map +1 -0
  185. package/dist/wire-DthPcTM6.d.ts +787 -0
  186. package/dist/wire-DthPcTM6.d.ts.map +1 -0
  187. package/dist/wire.d.ts +2 -0
  188. package/dist/wire.js +329 -0
  189. package/dist/wire.js.map +1 -0
  190. package/package.json +19 -14
  191. package/dist/index-BEvVAL76.d.mts +0 -4097
  192. package/dist/index-BEvVAL76.d.mts.map +0 -1
  193. package/dist/index.d.mts +0 -2
  194. package/dist/index.mjs +0 -1
  195. package/dist/port.d.mts.map +0 -1
  196. package/dist/port.mjs.map +0 -1
  197. package/dist/src-BDqn7Ocr.mjs +0 -6344
  198. package/dist/src-BDqn7Ocr.mjs.map +0 -1
@@ -0,0 +1,205 @@
1
+ import { emptyStore } from "./store.js";
2
+ import { stableStringify } from "./query.js";
3
+ import { connectionIdentity } from "./optimistic.js";
4
+ import { Effect, Option } from "effect";
5
+ import { KeyValueStore } from "effect/persistence";
6
+ // src/persistence.ts
7
+ /**
8
+ * Remote cache persistence: a snapshot of the entity store and nothing else.
9
+ * Runtime state (connections' live cursors, optimistic layers, the mutation
10
+ * ledger, gaps, retention roots) is never in a snapshot; it belongs to the
11
+ * session that produced it.
12
+ *
13
+ * The cache is server-derived and disposable: an incompatible, oversized, or
14
+ * corrupt snapshot is discarded (and removed from a `KeyValueStore`) so the
15
+ * planner refetches. `dehydrate`/`hydrate` are the string forms, for SSR and
16
+ * any other transport; `save`/`restore` put them behind Effect's
17
+ * `KeyValueStore`, so the backend (memory, filesystem, SQL, Web Storage) is the
18
+ * application's choice.
19
+ */
20
+ /** Bump when the serialized shape changes; a mismatch discards the cache. */
21
+ const REMOTE_CACHE_VERSION = 5;
22
+ /**
23
+ * A snapshot of a Model, keeping the entity cache and only the connections
24
+ * named.
25
+ *
26
+ * Naming none — the default — is exactly what Remote did before connections
27
+ * could be kept at all: a disposable, server-derived cache.
28
+ */
29
+ const snapshotOf = (model, options = {}) => ({
30
+ entities: model.entities,
31
+ connections: Object.fromEntries((options.connections ?? []).flatMap((connection) => {
32
+ const identity = connectionIdentity(connection);
33
+ const held = model.connections[identity];
34
+ return held === void 0 ? [] : [[identity, held.segments.map((segment) => segment.edges)]];
35
+ }))
36
+ });
37
+ const sorted = (values) => [...values].sort();
38
+ /**
39
+ * The snapshot's data shape. `stableStringify` sorts object keys, and the
40
+ * field sets are sorted here, so equal stores give byte-equal snapshots
41
+ * whatever order they were built in.
42
+ */
43
+ const serializeStore = (snapshot, scope) => ({
44
+ version: 5,
45
+ scope: scope ?? null,
46
+ connections: snapshot.connections,
47
+ entities: Object.fromEntries(Object.entries(snapshot.entities).map(([key, entry]) => [key, {
48
+ values: entry.values,
49
+ present: sorted(entry.present),
50
+ stale: sorted(entry.stale),
51
+ unavailable: sorted(entry.unavailable),
52
+ tombstone: entry.tombstone,
53
+ updatedAt: entry.updatedAt,
54
+ windows: entry.windows
55
+ }]))
56
+ });
57
+ const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
58
+ const isStringRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
59
+ /** Throws on a malformed entry so the whole snapshot is discarded. */
60
+ const parseEntry = (value) => {
61
+ if (value === null || typeof value !== "object") throw new Error("entry is not an object");
62
+ const entry = value;
63
+ if (entry.values === null || typeof entry.values !== "object" || Array.isArray(entry.values)) throw new Error("entry.values is not a record");
64
+ if (!isStringArray(entry.present)) throw new Error("entry.present is not a string array");
65
+ if (!isStringArray(entry.stale)) throw new Error("entry.stale is not a string array");
66
+ if (!isStringArray(entry.unavailable)) throw new Error("entry.unavailable is not a string array");
67
+ if (typeof entry.tombstone !== "boolean") throw new Error("entry.tombstone is not a boolean");
68
+ if (typeof entry.updatedAt !== "number") throw new Error("entry.updatedAt is not a number");
69
+ if (!isStringRecord(entry.windows)) throw new Error("entry.windows is not a string record");
70
+ return {
71
+ values: entry.values,
72
+ present: new Set(entry.present),
73
+ stale: new Set(entry.stale),
74
+ unavailable: new Set(entry.unavailable),
75
+ tombstone: entry.tombstone,
76
+ updatedAt: entry.updatedAt,
77
+ windows: entry.windows
78
+ };
79
+ };
80
+ /** Throws on a malformed edge so the whole snapshot is discarded. */
81
+ const parseEdges = (value) => {
82
+ if (!Array.isArray(value)) throw new Error("segment is not an array of edges");
83
+ return value.map((edge) => {
84
+ const candidate = edge;
85
+ if (typeof candidate.key !== "string" || typeof candidate.ref?.entity !== "string" || typeof candidate.ref?.id !== "string") throw new Error("edge is not { key, ref: { entity, id } }");
86
+ return {
87
+ key: candidate.key,
88
+ ref: {
89
+ entity: candidate.ref.entity,
90
+ id: candidate.ref.id
91
+ }
92
+ };
93
+ });
94
+ };
95
+ const deserializeStore = (serialized) => ({
96
+ entities: Object.fromEntries(Object.entries(serialized.entities).map(([key, entry]) => [key, parseEntry(entry)])),
97
+ connections: Object.fromEntries(Object.entries(serialized.connections ?? {}).map(([key, segments]) => {
98
+ if (!Array.isArray(segments)) throw new Error("connection is not an array of segments");
99
+ return [key, segments.map(parseEdges)];
100
+ }))
101
+ });
102
+ function dehydrate(snapshot, options = {}) {
103
+ const text = stableStringify(serializeStore(snapshot, options.scope));
104
+ return exceeds(text, options.maxBytes) ? void 0 : text;
105
+ }
106
+ /**
107
+ * Whether the UTF-8 size of `text` exceeds `maxBytes`. A character is one to
108
+ * three bytes, so most texts are decided from their length alone; only the
109
+ * rest are measured.
110
+ */
111
+ const exceeds = (text, maxBytes) => {
112
+ if (maxBytes === void 0) return false;
113
+ if (text.length > maxBytes) return true;
114
+ if (text.length * 3 <= maxBytes) return false;
115
+ return new TextEncoder().encode(text).length > maxBytes;
116
+ };
117
+ /**
118
+ * The store a snapshot text holds, or none when the text is missing,
119
+ * oversized, not this version, for another scope, or malformed. Hydrating the
120
+ * same text twice yields equal stores.
121
+ */
122
+ const hydrate = (raw, options = {}) => {
123
+ if (raw === void 0 || raw === null) return Option.none();
124
+ if (exceeds(raw, options.maxBytes)) return Option.none();
125
+ let parsed;
126
+ try {
127
+ parsed = JSON.parse(raw);
128
+ } catch {
129
+ return Option.none();
130
+ }
131
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return Option.none();
132
+ const candidate = parsed;
133
+ if (candidate.version !== 5) return Option.none();
134
+ if ((candidate.scope ?? null) !== (options.scope ?? null)) return Option.none();
135
+ if (candidate.entities === null || typeof candidate.entities !== "object") return Option.none();
136
+ if (Array.isArray(candidate.entities)) return Option.none();
137
+ try {
138
+ return Option.some(deserializeStore(candidate));
139
+ } catch {
140
+ return Option.none();
141
+ }
142
+ };
143
+ /**
144
+ * Brings a snapshot into a store that may already hold entries. `replace`
145
+ * takes the snapshot's entry for every key it has; `preserve-existing` keeps
146
+ * an entry the current store already has (it is at least as fresh as a
147
+ * snapshot taken earlier). Either way, keys only the current store has stay.
148
+ */
149
+ const mergeStores = (current, snapshot, policy) => Object.keys(snapshot).length === 0 ? current : policy === "replace" ? {
150
+ ...current,
151
+ ...snapshot
152
+ } : {
153
+ ...snapshot,
154
+ ...current
155
+ };
156
+ /** An empty snapshot: the cache as a fresh session has it. */
157
+ const emptySnapshot = {
158
+ entities: emptyStore,
159
+ connections: {}
160
+ };
161
+ const RemotePersistence = {
162
+ /** The snapshot text, or `undefined` when it would exceed `maxBytes`. */
163
+ dehydrate,
164
+ /** What a snapshot text holds, or none when it is refused. */
165
+ hydrate,
166
+ /**
167
+ * What to keep of a Model, naming the connections that should survive a
168
+ * reload. Naming none is the default and is what Remote always did.
169
+ */
170
+ snapshotOf,
171
+ /** A snapshot's entities brought into a store by policy. */
172
+ mergeStores,
173
+ /** The cache as a fresh session has it; what a refused `restore` yields. */
174
+ emptySnapshot,
175
+ /**
176
+ * Writes the snapshot under `key`, or removes the key when the store would
177
+ * exceed `maxBytes`, so a stale smaller snapshot cannot outlive a larger
178
+ * store it no longer describes.
179
+ */
180
+ save: (snapshot, options) => Effect.gen(function* () {
181
+ const kv = yield* KeyValueStore.KeyValueStore;
182
+ const text = dehydrate(snapshot, options);
183
+ if (text === void 0) yield* kv.remove(options.key);
184
+ else yield* kv.set(options.key, text);
185
+ }),
186
+ /**
187
+ * Reads the cache. A missing key yields `emptySnapshot`; anything `hydrate`
188
+ * refuses yields `emptySnapshot` and removes the key, so the next `restore`
189
+ * is clean and the planner refetches.
190
+ */
191
+ restore: (options) => Effect.gen(function* () {
192
+ const kv = yield* KeyValueStore.KeyValueStore;
193
+ const raw = yield* kv.get(options.key);
194
+ if (raw === void 0) return emptySnapshot;
195
+ const restored = hydrate(raw, options);
196
+ if (Option.isNone(restored)) {
197
+ yield* kv.remove(options.key);
198
+ return emptySnapshot;
199
+ }
200
+ return restored.value;
201
+ })
202
+ };
203
+ export { REMOTE_CACHE_VERSION, RemotePersistence };
204
+
205
+ //# sourceMappingURL=persistence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persistence.js","names":[],"sources":["../src/persistence.ts"],"sourcesContent":["/**\n * Remote cache persistence: a snapshot of the entity store and nothing else.\n * Runtime state (connections' live cursors, optimistic layers, the mutation\n * ledger, gaps, retention roots) is never in a snapshot; it belongs to the\n * session that produced it.\n *\n * The cache is server-derived and disposable: an incompatible, oversized, or\n * corrupt snapshot is discarded (and removed from a `KeyValueStore`) so the\n * planner refetches. `dehydrate`/`hydrate` are the string forms, for SSR and\n * any other transport; `save`/`restore` put them behind Effect's\n * `KeyValueStore`, so the backend (memory, filesystem, SQL, Web Storage) is the\n * application's choice.\n */\nimport { Effect, Option } from 'effect'\nimport { KeyValueStore } from 'effect/persistence'\nimport { type Connection, type Edge } from './connection.js'\nimport { connectionIdentity, type ConnectionIdentity } from './optimistic.js'\nimport { stableStringify } from './query.js'\nimport { emptyStore, type EntityEntry, type EntityStore } from './store.js'\n\n/** Bump when the serialized shape changes; a mismatch discards the cache. */\nexport const REMOTE_CACHE_VERSION = 5\n\n/**\n * What survives a reload: the entity cache, and the connections an application\n * **declared** should come back.\n *\n * Connections are session state by default and stay that way — `snapshotOf`\n * takes none unless asked. What a declared one keeps is its **edges and\n * nothing else**, which is not a simplification but the rule: a connection's\n * boundaries are cursors the server minted, and a cursor may name server state\n * that no longer exists. Restoring one would be claiming to know where the\n * page ends. The shape has nowhere to put a cursor, so that cannot be got\n * wrong later.\n */\nexport interface Snapshot {\n readonly entities: EntityStore\n /**\n * By connection identity: its **segments**, each a run of edges. No\n * boundaries.\n *\n * Segments rather than one flat list, because the gaps between them are\n * knowledge too. A connection paged from both ends holds a head and a tail\n * with an unfetched middle; flattening those into one run would claim the\n * tail's first row follows the head's last, which is a thing the client was\n * never told.\n */\n readonly connections: Readonly<Record<string, ReadonlyArray<ReadonlyArray<Edge>>>>\n}\n\n/**\n * A snapshot of a Model, keeping the entity cache and only the connections\n * named.\n *\n * Naming none — the default — is exactly what Remote did before connections\n * could be kept at all: a disposable, server-derived cache.\n */\nconst snapshotOf = (\n model: {\n readonly entities: EntityStore\n readonly connections: Readonly<Record<string, Connection>>\n },\n options: { readonly connections?: ReadonlyArray<ConnectionIdentity> | undefined } = {},\n): Snapshot => ({\n entities: model.entities,\n connections: Object.fromEntries(\n (options.connections ?? []).flatMap(connection => {\n const identity = connectionIdentity(connection)\n const held = model.connections[identity]\n return held === undefined\n ? []\n : [[identity, held.segments.map(segment => segment.edges)] as const]\n }),\n ),\n})\n\nexport interface SnapshotOptions {\n /**\n * Who or what the snapshot is for (a user id, a tenant, a build). A snapshot\n * taken under another scope is discarded rather than shown to the wrong\n * reader. Default: unscoped, which only matches unscoped.\n */\n readonly scope?: string | undefined\n /** Snapshots larger than this many bytes are not written and not read. */\n readonly maxBytes?: number | undefined\n}\n\n/** How a snapshot meets a store that already has entries. */\nexport type MergePolicy = 'replace' | 'preserve-existing'\n\ninterface SerializedEntry {\n readonly values: Readonly<Record<string, unknown>>\n readonly present: ReadonlyArray<string>\n readonly stale: ReadonlyArray<string>\n readonly unavailable: ReadonlyArray<string>\n readonly tombstone: boolean\n readonly updatedAt: number\n readonly windows: Readonly<Record<string, string>>\n}\n\ninterface SerializedStore {\n readonly version: number\n readonly scope: string | null\n readonly entities: Readonly<Record<string, SerializedEntry>>\n readonly connections: Readonly<Record<string, ReadonlyArray<ReadonlyArray<Edge>>>>\n}\n\nconst sorted = <T>(values: Iterable<T>): T[] => [...values].sort()\n\n/**\n * The snapshot's data shape. `stableStringify` sorts object keys, and the\n * field sets are sorted here, so equal stores give byte-equal snapshots\n * whatever order they were built in.\n */\nconst serializeStore = (snapshot: Snapshot, scope?: string): SerializedStore => ({\n version: REMOTE_CACHE_VERSION,\n scope: scope ?? null,\n connections: snapshot.connections,\n entities: Object.fromEntries(\n Object.entries(snapshot.entities).map(([key, entry]) => [\n key,\n {\n values: entry.values,\n present: sorted(entry.present),\n stale: sorted(entry.stale),\n unavailable: sorted(entry.unavailable),\n tombstone: entry.tombstone,\n updatedAt: entry.updatedAt,\n windows: entry.windows,\n },\n ]),\n ),\n})\n\nconst isStringArray = (value: unknown): value is ReadonlyArray<string> =>\n Array.isArray(value) && value.every(item => typeof item === 'string')\n\nconst isStringRecord = (value: unknown): value is Readonly<Record<string, string>> =>\n value !== null &&\n typeof value === 'object' &&\n !Array.isArray(value) &&\n Object.values(value as Record<string, unknown>).every(item => typeof item === 'string')\n\n/** Throws on a malformed entry so the whole snapshot is discarded. */\nconst parseEntry = (value: unknown): EntityEntry => {\n if (value === null || typeof value !== 'object') throw new Error('entry is not an object')\n const entry = value as Record<string, unknown>\n if (entry.values === null || typeof entry.values !== 'object' || Array.isArray(entry.values)) {\n throw new Error('entry.values is not a record')\n }\n if (!isStringArray(entry.present)) throw new Error('entry.present is not a string array')\n if (!isStringArray(entry.stale)) throw new Error('entry.stale is not a string array')\n if (!isStringArray(entry.unavailable)) throw new Error('entry.unavailable is not a string array')\n if (typeof entry.tombstone !== 'boolean') throw new Error('entry.tombstone is not a boolean')\n if (typeof entry.updatedAt !== 'number') throw new Error('entry.updatedAt is not a number')\n if (!isStringRecord(entry.windows)) throw new Error('entry.windows is not a string record')\n return {\n values: entry.values as Readonly<Record<string, unknown>>,\n present: new Set(entry.present),\n stale: new Set(entry.stale),\n unavailable: new Set(entry.unavailable),\n tombstone: entry.tombstone,\n updatedAt: entry.updatedAt,\n windows: entry.windows,\n }\n}\n\n/** Throws on a malformed edge so the whole snapshot is discarded. */\nconst parseEdges = (value: unknown): ReadonlyArray<Edge> => {\n if (!Array.isArray(value)) throw new Error('segment is not an array of edges')\n return value.map(edge => {\n const candidate = edge as { key?: unknown; ref?: { entity?: unknown; id?: unknown } }\n if (\n typeof candidate.key !== 'string' ||\n typeof candidate.ref?.entity !== 'string' ||\n typeof candidate.ref?.id !== 'string'\n ) {\n throw new Error('edge is not { key, ref: { entity, id } }')\n }\n return { key: candidate.key, ref: { entity: candidate.ref.entity, id: candidate.ref.id } }\n })\n}\n\nconst deserializeStore = (serialized: SerializedStore): Snapshot => ({\n entities: Object.fromEntries(\n Object.entries(serialized.entities).map(([key, entry]) => [key, parseEntry(entry)]),\n ),\n connections: Object.fromEntries(\n Object.entries(serialized.connections ?? {}).map(([key, segments]) => {\n if (!Array.isArray(segments)) throw new Error('connection is not an array of segments')\n return [key, segments.map(parseEdges)]\n }),\n ),\n})\n\n/**\n * The snapshot text, or `undefined` when it would exceed `maxBytes`. Build the\n * snapshot with `snapshotOf`, which is where the connections that survive are\n * named; runtime state — optimistic layers, live cursors, gaps, the mutation\n * ledger — is never in one.\n */\nfunction dehydrate(\n snapshot: Snapshot,\n options?: SnapshotOptions & { readonly maxBytes?: undefined },\n): string\nfunction dehydrate(snapshot: Snapshot, options?: SnapshotOptions): string | undefined\nfunction dehydrate(snapshot: Snapshot, options: SnapshotOptions = {}): string | undefined {\n const text = stableStringify(serializeStore(snapshot, options.scope))\n return exceeds(text, options.maxBytes) ? undefined : text\n}\n\n/**\n * Whether the UTF-8 size of `text` exceeds `maxBytes`. A character is one to\n * three bytes, so most texts are decided from their length alone; only the\n * rest are measured.\n */\nconst exceeds = (text: string, maxBytes: number | undefined): boolean => {\n if (maxBytes === undefined) return false\n if (text.length > maxBytes) return true\n if (text.length * 3 <= maxBytes) return false\n return new TextEncoder().encode(text).length > maxBytes\n}\n\n/**\n * The store a snapshot text holds, or none when the text is missing,\n * oversized, not this version, for another scope, or malformed. Hydrating the\n * same text twice yields equal stores.\n */\nconst hydrate = (\n raw: string | undefined | null,\n options: SnapshotOptions = {},\n): Option.Option<Snapshot> => {\n if (raw === undefined || raw === null) return Option.none()\n if (exceeds(raw, options.maxBytes)) return Option.none()\n let parsed: unknown\n try {\n parsed = JSON.parse(raw)\n } catch {\n return Option.none()\n }\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return Option.none()\n const candidate = parsed as {\n readonly version?: unknown\n readonly scope?: unknown\n readonly entities?: unknown\n }\n if (candidate.version !== REMOTE_CACHE_VERSION) return Option.none()\n if ((candidate.scope ?? null) !== (options.scope ?? null)) return Option.none()\n if (candidate.entities === null || typeof candidate.entities !== 'object') return Option.none()\n if (Array.isArray(candidate.entities)) return Option.none()\n try {\n return Option.some(deserializeStore(candidate as SerializedStore))\n } catch {\n return Option.none()\n }\n}\n\n/**\n * Brings a snapshot into a store that may already hold entries. `replace`\n * takes the snapshot's entry for every key it has; `preserve-existing` keeps\n * an entry the current store already has (it is at least as fresh as a\n * snapshot taken earlier). Either way, keys only the current store has stay.\n */\nconst mergeStores = (\n current: EntityStore,\n snapshot: EntityStore,\n policy: MergePolicy,\n): EntityStore =>\n Object.keys(snapshot).length === 0\n ? current\n : policy === 'replace'\n ? { ...current, ...snapshot }\n : { ...snapshot, ...current }\n\n/** An empty snapshot: the cache as a fresh session has it. */\nconst emptySnapshot: Snapshot = { entities: emptyStore, connections: {} }\n\nexport const RemotePersistence = {\n /** The snapshot text, or `undefined` when it would exceed `maxBytes`. */\n dehydrate,\n /** What a snapshot text holds, or none when it is refused. */\n hydrate,\n /**\n * What to keep of a Model, naming the connections that should survive a\n * reload. Naming none is the default and is what Remote always did.\n */\n snapshotOf,\n /** A snapshot's entities brought into a store by policy. */\n mergeStores,\n /** The cache as a fresh session has it; what a refused `restore` yields. */\n emptySnapshot,\n\n /**\n * Writes the snapshot under `key`, or removes the key when the store would\n * exceed `maxBytes`, so a stale smaller snapshot cannot outlive a larger\n * store it no longer describes.\n */\n save: (snapshot: Snapshot, options: { readonly key: string } & SnapshotOptions) =>\n Effect.gen(function* () {\n const kv = yield* KeyValueStore.KeyValueStore\n const text = dehydrate(snapshot, options)\n if (text === undefined) yield* kv.remove(options.key)\n else yield* kv.set(options.key, text)\n }),\n\n /**\n * Reads the cache. A missing key yields `emptySnapshot`; anything `hydrate`\n * refuses yields `emptySnapshot` and removes the key, so the next `restore`\n * is clean and the planner refetches.\n */\n restore: (options: { readonly key: string } & SnapshotOptions) =>\n Effect.gen(function* () {\n const kv = yield* KeyValueStore.KeyValueStore\n const raw = yield* kv.get(options.key)\n if (raw === undefined) return emptySnapshot\n const restored = hydrate(raw, options)\n if (Option.isNone(restored)) {\n yield* kv.remove(options.key)\n return emptySnapshot\n }\n return restored.value\n }),\n}\n"],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ import { i as Edge, n as Connection } from "./connection-BRyaJy89.js";
2
+ import { r as EntityStore } from "./store-ah2qjcJW.js";
3
+ import { Data } from "effect";
4
+ import { AnyQuery, MemoryOperators } from "foldkit-entity";
5
+ /** Where a row falls in a loaded list. */
6
+ type RowPlace = Data.TaggedEnum<{
7
+ /** Just before this edge, in this segment. */
8
+ Before: {
9
+ readonly segment: number;
10
+ readonly edge: string;
11
+ };
12
+ /** After every edge of this segment, which ends the list. */
13
+ End: {
14
+ readonly segment: number;
15
+ };
16
+ /** In rows the list has not loaded: past a `Cursor` boundary, or in a gap. */
17
+ Outside: {};
18
+ /** Not judged exactly, and why. */
19
+ Unknown: {
20
+ readonly reason: string;
21
+ };
22
+ }>;
23
+ declare const RowPlace: Data.TaggedEnum.Constructor<{
24
+ readonly _tag: "Before";
25
+ readonly segment: number;
26
+ readonly edge: string;
27
+ } | {
28
+ readonly _tag: "End";
29
+ readonly segment: number;
30
+ } | {
31
+ readonly _tag: "Outside";
32
+ } | {
33
+ readonly _tag: "Unknown";
34
+ readonly reason: string;
35
+ }>;
36
+ /**
37
+ * Where the row stored under `key` falls in `connection`, by `body`'s order
38
+ * for `input` (encoded, as a connection's identity carries it), a call in it
39
+ * run by `operators`. The row's own edge, if the list holds it, is left out:
40
+ * placing a row that moved asks where it goes now.
41
+ */
42
+ declare const placeIn: (store: EntityStore, connection: Connection, body: AnyQuery, input: Readonly<Record<string, unknown>>, key: string, operators: MemoryOperators) => RowPlace;
43
+ /** The connection without the edge of `key`, or the connection itself when it holds none. */
44
+ declare const withoutEdge: (connection: Connection, key: string) => Connection;
45
+ /** The connection with `edge` where `place` says: before an edge, or at a segment's end. */
46
+ declare const withEdge: (connection: Connection, added: Edge, place: Extract<RowPlace, {
47
+ readonly _tag: 'Before' | 'End';
48
+ }>) => Connection;
49
+ export { withoutEdge as i, placeIn as n, withEdge as r, RowPlace as t };
50
+ //# sourceMappingURL=placement-jZdsIpmj.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"placement-jZdsIpmj.d.ts","names":[],"sources":["../src/placement.ts"],"mappings":";;;;;KA2BY,WAAW,KAAK;;EAE1B;aAAmB;aAA0B;;;EAE7C;aAAgB;;;EAEhB;;EAEA;aAAoB;;;cAET,UAAQ,KAAA,WAAA;;WARS;WAAuB;;;WAE1B;;;;;WAIG;;;;;;;;cAiCjB,UAAO,OACX,aAAW,YACN,YAAU,MAChB,UAAQ,OACP,SAAS,0BAAwB,aAC7B,WACA,oBACV;;cAyEU,cAAW,YAAgB,YAAU,gBAAgB;;cAYrD,WAAQ,YACP,YAAU,OACf,MAAI,OACJ,QAAQ;WAAqB;OACnC"}
@@ -0,0 +1,2 @@
1
+ import { i as withoutEdge, n as placeIn, r as withEdge, t as RowPlace } from "./placement-jZdsIpmj.js";
2
+ export { RowPlace, placeIn, withEdge, withoutEdge };
@@ -0,0 +1,116 @@
1
+ import { entityKey, readField } from "./store.js";
2
+ import { Data, Match, Option, Schema, SchemaAST } from "effect";
3
+ import { Query, compareRows, dependenciesOf } from "foldkit-entity";
4
+ // src/placement.ts
5
+ /**
6
+ * Where one row falls in a loaded list, by the list's own declared order:
7
+ * the decidable half of placing a row instead of fetching the list again.
8
+ *
9
+ * A list is segments of edges between boundaries, and only a `Terminal`
10
+ * boundary says nothing lies beyond it. So a row is placed between two loaded
11
+ * edges, or before a segment's first edge when that segment starts the list,
12
+ * or at its end when it ends it; a row that sorts past a `Cursor` or into a
13
+ * gap belongs to rows the client has not read, and is `Outside`. Anything the
14
+ * client cannot judge exactly (a body it cannot place, a field a row lacks,
15
+ * keys it cannot compare) is `Unknown`, with why, never a guess.
16
+ *
17
+ * Pure: it reads the store and the list it is given.
18
+ */
19
+ const RowPlace = Data.taggedEnum();
20
+ /**
21
+ * The values a row holds of the fields an order reads, a call's arguments
22
+ * included, or none when it lacks one. An `id` no Selection stored is its key's, when the id is stored as
23
+ * text as a key holds it, so the id tie-break every order ends on does not
24
+ * make such a row unplaceable; a number id is never compared as text.
25
+ */
26
+ const keysOf = (store, key, id, idIsText, terms) => {
27
+ const values = Object.create(null);
28
+ for (const { key: field } of dependenciesOf(...terms).fields) {
29
+ const stored = readField(store, key, field);
30
+ const value = field === "id" && Option.isNone(stored) && idIsText ? id : stored;
31
+ if (Option.isNone(value)) return Option.none();
32
+ values[field] = value.value;
33
+ }
34
+ return Option.some(values);
35
+ };
36
+ /**
37
+ * Where the row stored under `key` falls in `connection`, by `body`'s order
38
+ * for `input` (encoded, as a connection's identity carries it), a call in it
39
+ * run by `operators`. The row's own edge, if the list holds it, is left out:
40
+ * placing a row that moved asks where it goes now.
41
+ */
42
+ const placeIn = (store, connection, body, input, key, operators) => {
43
+ const placement = Query.placement(body, input, { operators });
44
+ if (placement._tag === "NotPlaceable") return RowPlace.Unknown({ reason: placement.reason });
45
+ const terms = Query.orderFor(body, input);
46
+ const prefix = `${body.entity.name}:`;
47
+ const id = key.startsWith(prefix) ? Option.some(key.slice(prefix.length)) : Option.none();
48
+ const idField = body.entity.fields.id;
49
+ const idIsText = idField !== void 0 && SchemaAST.isString(Schema.toEncoded(idField.schema).ast);
50
+ const row = keysOf(store, key, id, idIsText, terms);
51
+ if (Option.isNone(row)) return RowPlace.Unknown({ reason: `${key} lacks a field the order reads` });
52
+ /** Negative when the row sorts before the edge; none when the edge cannot be compared. */
53
+ const against = (edge) => Option.flatMap(keysOf(store, entityKey(edge.ref.entity, edge.ref.id), Option.some(edge.ref.id), idIsText, terms), (values) => {
54
+ try {
55
+ return Option.some(compareRows(terms, row.value, values, body.entity.name, {
56
+ input,
57
+ operators
58
+ }));
59
+ } catch {
60
+ return Option.none();
61
+ }
62
+ });
63
+ const boundary = RowPlace.Unknown({ reason: `${key} was where its page met rows not loaded` });
64
+ const { segments } = connection;
65
+ for (let index = 0; index < segments.length; index++) {
66
+ const segment = segments[index];
67
+ const held = segment.edges.findIndex((edge) => edge.key === key);
68
+ const edges = segment.edges.filter((edge) => edge.key !== key);
69
+ for (let position = 0; position < edges.length; position++) {
70
+ const edge = edges[position];
71
+ const sign = against(edge);
72
+ if (Option.isNone(sign)) return RowPlace.Unknown({ reason: `${edge.key} lacks a field the order reads` });
73
+ if (sign.value < 0) {
74
+ const before = RowPlace.Before({
75
+ segment: index,
76
+ edge: edge.key
77
+ });
78
+ if (position > 0) return before;
79
+ return Match.value(segment.start).pipe(Match.tag("Terminal", () => before), Match.orElse(() => held === 0 ? boundary : RowPlace.Outside()));
80
+ }
81
+ }
82
+ if (Match.value(segment.end).pipe(Match.tag("Terminal", () => true), Match.orElse(() => false))) return RowPlace.End({ segment: index });
83
+ if (held >= 0 && held === segment.edges.length - 1) return boundary;
84
+ }
85
+ return RowPlace.Outside();
86
+ };
87
+ /** The connection without the edge of `key`, or the connection itself when it holds none. */
88
+ const withoutEdge = (connection, key) => connection.segments.some((segment) => segment.edges.some((edge) => edge.key === key)) ? {
89
+ ...connection,
90
+ segments: connection.segments.map((segment) => ({
91
+ ...segment,
92
+ edges: segment.edges.filter((edge) => edge.key !== key)
93
+ }))
94
+ } : connection;
95
+ /** The connection with `edge` where `place` says: before an edge, or at a segment's end. */
96
+ const withEdge = (connection, added, place) => ({
97
+ ...connection,
98
+ segments: connection.segments.map((segment, index) => {
99
+ if (index !== place.segment) return segment;
100
+ const at = Match.value(place).pipe(Match.tagsExhaustive({
101
+ Before: ({ edge }) => segment.edges.findIndex((held) => held.key === edge),
102
+ End: () => segment.edges.length
103
+ }));
104
+ return {
105
+ ...segment,
106
+ edges: [
107
+ ...segment.edges.slice(0, at),
108
+ added,
109
+ ...segment.edges.slice(at)
110
+ ]
111
+ };
112
+ })
113
+ });
114
+ export { RowPlace, placeIn, withEdge, withoutEdge };
115
+
116
+ //# sourceMappingURL=placement.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"placement.js","names":["Relational"],"sources":["../src/placement.ts"],"sourcesContent":["/**\n * Where one row falls in a loaded list, by the list's own declared order:\n * the decidable half of placing a row instead of fetching the list again.\n *\n * A list is segments of edges between boundaries, and only a `Terminal`\n * boundary says nothing lies beyond it. So a row is placed between two loaded\n * edges, or before a segment's first edge when that segment starts the list,\n * or at its end when it ends it; a row that sorts past a `Cursor` or into a\n * gap belongs to rows the client has not read, and is `Outside`. Anything the\n * client cannot judge exactly (a body it cannot place, a field a row lacks,\n * keys it cannot compare) is `Unknown`, with why, never a guess.\n *\n * Pure: it reads the store and the list it is given.\n */\nimport { Data, Match, Option, Schema, SchemaAST } from 'effect'\nimport {\n Query as Relational,\n compareRows,\n dependenciesOf,\n type AnyQuery,\n type MemoryOperators,\n type OrderTerm,\n} from 'foldkit-entity'\nimport type { Connection, Edge } from './connection.js'\nimport { entityKey, readField, type EntityStore } from './store.js'\n\n/** Where a row falls in a loaded list. */\nexport type RowPlace = Data.TaggedEnum<{\n /** Just before this edge, in this segment. */\n Before: { readonly segment: number; readonly edge: string }\n /** After every edge of this segment, which ends the list. */\n End: { readonly segment: number }\n /** In rows the list has not loaded: past a `Cursor` boundary, or in a gap. */\n Outside: {}\n /** Not judged exactly, and why. */\n Unknown: { readonly reason: string }\n}>\nexport const RowPlace = Data.taggedEnum<RowPlace>()\n\n/**\n * The values a row holds of the fields an order reads, a call's arguments\n * included, or none when it lacks one. An `id` no Selection stored is its key's, when the id is stored as\n * text as a key holds it, so the id tie-break every order ends on does not\n * make such a row unplaceable; a number id is never compared as text.\n */\nconst keysOf = (\n store: EntityStore,\n key: string,\n id: Option.Option<string>,\n idIsText: boolean,\n terms: ReadonlyArray<OrderTerm>,\n): Option.Option<Readonly<Record<string, unknown>>> => {\n const values: Record<string, unknown> = Object.create(null)\n for (const { key: field } of dependenciesOf(...terms).fields) {\n const stored = readField(store, key, field)\n const value = field === 'id' && Option.isNone(stored) && idIsText ? id : stored\n if (Option.isNone(value)) return Option.none()\n values[field] = value.value\n }\n return Option.some(values)\n}\n\n/**\n * Where the row stored under `key` falls in `connection`, by `body`'s order\n * for `input` (encoded, as a connection's identity carries it), a call in it\n * run by `operators`. The row's own edge, if the list holds it, is left out:\n * placing a row that moved asks where it goes now.\n */\nexport const placeIn = (\n store: EntityStore,\n connection: Connection,\n body: AnyQuery,\n input: Readonly<Record<string, unknown>>,\n key: string,\n operators: MemoryOperators,\n): RowPlace => {\n const placement = Relational.placement(body, input, { operators })\n if (placement._tag === 'NotPlaceable') return RowPlace.Unknown({ reason: placement.reason })\n const terms = Relational.orderFor(body, input)\n // The row is of the body's Entity, so its key is that name, a colon, its id.\n const prefix = `${body.entity.name}:`\n const id = key.startsWith(prefix) ? Option.some(key.slice(prefix.length)) : Option.none()\n const idField = (body.entity.fields as Readonly<Record<string, { readonly schema: Schema.Top }>>)\n .id\n const idIsText = idField !== undefined && SchemaAST.isString(Schema.toEncoded(idField.schema).ast)\n const row = keysOf(store, key, id, idIsText, terms)\n if (Option.isNone(row)) {\n return RowPlace.Unknown({ reason: `${key} lacks a field the order reads` })\n }\n /** Negative when the row sorts before the edge; none when the edge cannot be compared. */\n const against = (edge: Edge): Option.Option<number> =>\n Option.flatMap(\n keysOf(\n store,\n entityKey(edge.ref.entity, edge.ref.id),\n Option.some(edge.ref.id),\n idIsText,\n terms,\n ),\n values => {\n try {\n return Option.some(\n compareRows(terms, row.value, values, body.entity.name, { input, operators }),\n )\n } catch {\n return Option.none()\n }\n },\n )\n\n // A row that was a segment's first or last edge beside a `Cursor` was\n // itself where its page began or ended: its old keys are gone from the\n // store, so whether it moved past that boundary cannot be told.\n const boundary = RowPlace.Unknown({ reason: `${key} was where its page met rows not loaded` })\n const { segments } = connection\n for (let index = 0; index < segments.length; index++) {\n const segment = segments[index]!\n const held = segment.edges.findIndex(edge => edge.key === key)\n const edges = segment.edges.filter(edge => edge.key !== key)\n for (let position = 0; position < edges.length; position++) {\n const edge = edges[position]!\n const sign = against(edge)\n if (Option.isNone(sign)) {\n return RowPlace.Unknown({ reason: `${edge.key} lacks a field the order reads` })\n }\n if (sign.value < 0) {\n const before = RowPlace.Before({ segment: index, edge: edge.key })\n if (position > 0) return before\n // Before this segment's first edge is loaded only when nothing precedes it.\n return Match.value(segment.start).pipe(\n Match.tag('Terminal', () => before),\n Match.orElse(() => (held === 0 ? boundary : RowPlace.Outside())),\n )\n }\n }\n // After every edge of this segment: its end, if nothing follows; else the\n // next segment, or rows not loaded.\n const ends = Match.value(segment.end).pipe(\n Match.tag('Terminal', () => true),\n Match.orElse(() => false),\n )\n if (ends) return RowPlace.End({ segment: index })\n if (held >= 0 && held === segment.edges.length - 1) return boundary\n }\n return RowPlace.Outside()\n}\n\n/** The connection without the edge of `key`, or the connection itself when it holds none. */\nexport const withoutEdge = (connection: Connection, key: string): Connection =>\n connection.segments.some(segment => segment.edges.some(edge => edge.key === key))\n ? {\n ...connection,\n segments: connection.segments.map(segment => ({\n ...segment,\n edges: segment.edges.filter(edge => edge.key !== key),\n })),\n }\n : connection\n\n/** The connection with `edge` where `place` says: before an edge, or at a segment's end. */\nexport const withEdge = (\n connection: Connection,\n added: Edge,\n place: Extract<RowPlace, { readonly _tag: 'Before' | 'End' }>,\n): Connection => ({\n ...connection,\n segments: connection.segments.map((segment, index) => {\n if (index !== place.segment) return segment\n const at = Match.value(place).pipe(\n Match.tagsExhaustive({\n Before: ({ edge }) => segment.edges.findIndex(held => held.key === edge),\n End: () => segment.edges.length,\n }),\n )\n return { ...segment, edges: [...segment.edges.slice(0, at), added, ...segment.edges.slice(at)] }\n }),\n})\n"],"mappings":";;;;;;;;;;;;;;;;;;AAqCA,MAAa,WAAW,KAAK,WAAqB;;;;;;;AAQlD,MAAM,UACJ,OACA,KACA,IACA,UACA,UACqD;CACrD,MAAM,SAAkC,OAAO,OAAO,IAAI;CAC1D,KAAK,MAAM,EAAE,KAAK,WAAW,eAAe,GAAG,KAAK,CAAC,CAAC,QAAQ;EAC5D,MAAM,SAAS,UAAU,OAAO,KAAK,KAAK;EAC1C,MAAM,QAAQ,UAAU,QAAQ,OAAO,OAAO,MAAM,KAAK,WAAW,KAAK;EACzE,IAAI,OAAO,OAAO,KAAK,GAAG,OAAO,OAAO,KAAK;EAC7C,OAAO,SAAS,MAAM;CACxB;CACA,OAAO,OAAO,KAAK,MAAM;AAC3B;;;;;;;AAQA,MAAa,WACX,OACA,YACA,MACA,OACA,KACA,cACa;CACb,MAAM,YAAYA,MAAW,UAAU,MAAM,OAAO,EAAE,UAAU,CAAC;CACjE,IAAI,UAAU,SAAS,gBAAgB,OAAO,SAAS,QAAQ,EAAE,QAAQ,UAAU,OAAO,CAAC;CAC3F,MAAM,QAAQA,MAAW,SAAS,MAAM,KAAK;CAE7C,MAAM,SAAS,GAAG,KAAK,OAAO,KAAK;CACnC,MAAM,KAAK,IAAI,WAAW,MAAM,IAAI,OAAO,KAAK,IAAI,MAAM,OAAO,MAAM,CAAC,IAAI,OAAO,KAAK;CACxF,MAAM,UAAW,KAAK,OAAO,OAC1B;CACH,MAAM,WAAW,YAAY,KAAA,KAAa,UAAU,SAAS,OAAO,UAAU,QAAQ,MAAM,CAAC,CAAC,GAAG;CACjG,MAAM,MAAM,OAAO,OAAO,KAAK,IAAI,UAAU,KAAK;CAClD,IAAI,OAAO,OAAO,GAAG,GACnB,OAAO,SAAS,QAAQ,EAAE,QAAQ,GAAG,IAAI,gCAAgC,CAAC;;CAG5E,MAAM,WAAW,SACf,OAAO,QACL,OACE,OACA,UAAU,KAAK,IAAI,QAAQ,KAAK,IAAI,EAAE,GACtC,OAAO,KAAK,KAAK,IAAI,EAAE,GACvB,UACA,KACF,IACA,WAAU;EACR,IAAI;GACF,OAAO,OAAO,KACZ,YAAY,OAAO,IAAI,OAAO,QAAQ,KAAK,OAAO,MAAM;IAAE;IAAO;GAAU,CAAC,CAC9E;EACF,QAAQ;GACN,OAAO,OAAO,KAAK;EACrB;CACF,CACF;CAKF,MAAM,WAAW,SAAS,QAAQ,EAAE,QAAQ,GAAG,IAAI,yCAAyC,CAAC;CAC7F,MAAM,EAAE,aAAa;CACrB,KAAK,IAAI,QAAQ,GAAG,QAAQ,SAAS,QAAQ,SAAS;EACpD,MAAM,UAAU,SAAS;EACzB,MAAM,OAAO,QAAQ,MAAM,WAAU,SAAQ,KAAK,QAAQ,GAAG;EAC7D,MAAM,QAAQ,QAAQ,MAAM,QAAO,SAAQ,KAAK,QAAQ,GAAG;EAC3D,KAAK,IAAI,WAAW,GAAG,WAAW,MAAM,QAAQ,YAAY;GAC1D,MAAM,OAAO,MAAM;GACnB,MAAM,OAAO,QAAQ,IAAI;GACzB,IAAI,OAAO,OAAO,IAAI,GACpB,OAAO,SAAS,QAAQ,EAAE,QAAQ,GAAG,KAAK,IAAI,gCAAgC,CAAC;GAEjF,IAAI,KAAK,QAAQ,GAAG;IAClB,MAAM,SAAS,SAAS,OAAO;KAAE,SAAS;KAAO,MAAM,KAAK;IAAI,CAAC;IACjE,IAAI,WAAW,GAAG,OAAO;IAEzB,OAAO,MAAM,MAAM,QAAQ,KAAK,CAAC,CAAC,KAChC,MAAM,IAAI,kBAAkB,MAAM,GAClC,MAAM,aAAc,SAAS,IAAI,WAAW,SAAS,QAAQ,CAAE,CACjE;GACF;EACF;EAOA,IAJa,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KACpC,MAAM,IAAI,kBAAkB,IAAI,GAChC,MAAM,aAAa,KAAK,CAEnB,GAAG,OAAO,SAAS,IAAI,EAAE,SAAS,MAAM,CAAC;EAChD,IAAI,QAAQ,KAAK,SAAS,QAAQ,MAAM,SAAS,GAAG,OAAO;CAC7D;CACA,OAAO,SAAS,QAAQ;AAC1B;;AAGA,MAAa,eAAe,YAAwB,QAClD,WAAW,SAAS,MAAK,YAAW,QAAQ,MAAM,MAAK,SAAQ,KAAK,QAAQ,GAAG,CAAC,IAC5E;CACE,GAAG;CACH,UAAU,WAAW,SAAS,KAAI,aAAY;EAC5C,GAAG;EACH,OAAO,QAAQ,MAAM,QAAO,SAAQ,KAAK,QAAQ,GAAG;CACtD,EAAE;AACJ,IACA;;AAGN,MAAa,YACX,YACA,OACA,WACgB;CAChB,GAAG;CACH,UAAU,WAAW,SAAS,KAAK,SAAS,UAAU;EACpD,IAAI,UAAU,MAAM,SAAS,OAAO;EACpC,MAAM,KAAK,MAAM,MAAM,KAAK,CAAC,CAAC,KAC5B,MAAM,eAAe;GACnB,SAAS,EAAE,WAAW,QAAQ,MAAM,WAAU,SAAQ,KAAK,QAAQ,IAAI;GACvE,WAAW,QAAQ,MAAM;EAC3B,CAAC,CACH;EACA,OAAO;GAAE,GAAG;GAAS,OAAO;IAAC,GAAG,QAAQ,MAAM,MAAM,GAAG,EAAE;IAAG;IAAO,GAAG,QAAQ,MAAM,MAAM,EAAE;GAAC;EAAE;CACjG,CAAC;AACH"}
@@ -0,0 +1,35 @@
1
+ import { r as EntityStore } from "./store-ah2qjcJW.js";
2
+ import { a as Requirement } from "./requirement-D57S2LpH.js";
3
+ type Window = NonNullable<Requirement['windows']>[string];
4
+ /** A stable key for a window, so two equal windows compare equal. */
5
+ declare const windowKey: (window: Window) => string;
6
+ interface PlanFreshness {
7
+ readonly now: number;
8
+ /** A present entry older than this many milliseconds is refreshed whole. */
9
+ readonly freshness: number;
10
+ }
11
+ interface PlanOptions {
12
+ readonly freshness?: PlanFreshness | undefined;
13
+ /** Plan every requested field, present or not, tombstoned or not. */
14
+ readonly force?: boolean | undefined;
15
+ }
16
+ /** When present values next go stale, and the requirements of the entities that do. */
17
+ interface Deadline {
18
+ readonly at: number;
19
+ readonly due: ReadonlyArray<Requirement>;
20
+ }
21
+ /**
22
+ * The earliest moment a value `requirements` read, following the relations the
23
+ * store holds as `plan` does, goes stale under `freshness`, with the
24
+ * requirements of the entities that go stale then. A field the server settled
25
+ * without a value ages with its entity too, since `plan` asks for it again once
26
+ * the entity expires. Nothing when no such field is still fresh: what is
27
+ * already expired or stale is planned, not awaited, which is what keeps a
28
+ * timer from firing on what it already fired on. It is the next moment the
29
+ * plan can change with the clock alone, which the read entry relies on to
30
+ * reuse its plan until then.
31
+ */
32
+ declare const deadlineOf: (store: EntityStore, requirements: readonly Requirement[], freshness: PlanFreshness) => Deadline | undefined;
33
+ declare const plan: (store: EntityStore, requirements: readonly Requirement[], options?: PlanOptions) => ReadonlyArray<Requirement>;
34
+ export { plan as a, deadlineOf as i, PlanFreshness as n, windowKey as o, PlanOptions as r, Deadline as t };
35
+ //# sourceMappingURL=plan-D9cftxC_.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plan-D9cftxC_.d.ts","names":[],"sources":["../src/plan.ts"],"mappings":";;KAiBK,SAAS,YAAY;;cAGb,YAAS,QAAY;UAEjB;WACN;;WAEA;;UAGM;WACN,YAAY;;WAEZ;;;UAoBM;WACN;WACA,KAAK,cAAc;;;;;;;;;;;;;cAcjB,aAAU,OACd,aAAW,uBACK,eAAa,WACzB,kBACV;cAgCU,OAAI,OACR,aAAW,uBACK,eAAa,UAC3B,gBACR,cAAc"}
package/dist/plan.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { a as plan, i as deadlineOf, n as PlanFreshness, o as windowKey, r as PlanOptions, t as Deadline } from "./plan-D9cftxC_.js";
2
+ export { Deadline, PlanFreshness, PlanOptions, deadlineOf, plan, windowKey };
package/dist/plan.js ADDED
@@ -0,0 +1,100 @@
1
+ import { Requirement } from "./requirement.js";
2
+ import { targetsOf } from "./relation.js";
3
+ import { entityKey, missingFields, readField } from "./store.js";
4
+ import { stableStringify } from "./query.js";
5
+ // src/plan.ts
6
+ /**
7
+ * The pure requirement planner.
8
+ *
9
+ * A `Requirement` is plain data: which entity fields a Remote projection needs,
10
+ * and through `relations`, which fields of each relation's target. `plan` diffs
11
+ * requirements against the store and returns only the missing fields, grouped
12
+ * and deterministically ordered. A relation whose field is being fetched keeps
13
+ * its nested requirement on the request, so the server resolves the graph in
14
+ * one read; a relation whose refs the store already holds is followed into
15
+ * concrete requirements for the targets. Time enters through `PlanFreshness`,
16
+ * never from ambient state; `force` plans every field.
17
+ */
18
+ /** A stable key for a window, so two equal windows compare equal. */
19
+ const windowKey = (window) => stableStringify(window);
20
+ const pickFields = (record, fields) => {
21
+ if (record === void 0) return void 0;
22
+ const picked = Object.fromEntries(Object.entries(record).filter(([field]) => fields.has(field)));
23
+ return Object.keys(picked).length === 0 ? void 0 : picked;
24
+ };
25
+ /** The concrete requirements a known relation value contributes for its targets. */
26
+ const followRelation = (value, relation) => targetsOf(value, relation).map((ref) => ({
27
+ ...relation,
28
+ id: ref.id
29
+ }));
30
+ /**
31
+ * The earliest moment a value `requirements` read, following the relations the
32
+ * store holds as `plan` does, goes stale under `freshness`, with the
33
+ * requirements of the entities that go stale then. A field the server settled
34
+ * without a value ages with its entity too, since `plan` asks for it again once
35
+ * the entity expires. Nothing when no such field is still fresh: what is
36
+ * already expired or stale is planned, not awaited, which is what keeps a
37
+ * timer from firing on what it already fired on. It is the next moment the
38
+ * plan can change with the clock alone, which the read entry relies on to
39
+ * reuse its plan until then.
40
+ */
41
+ const deadlineOf = (store, requirements, freshness) => {
42
+ let at;
43
+ let due = [];
44
+ const walk = (group) => {
45
+ const key = entityKey(group.entity, group.id);
46
+ const entry = store[key];
47
+ if (entry === void 0 || entry.tombstone) return;
48
+ if (group.fields.some((field) => entry.present.has(field) && !entry.stale.has(field) || entry.unavailable.has(field))) {
49
+ const expires = entry.updatedAt + freshness.freshness;
50
+ if (expires >= freshness.now && Number.isFinite(expires)) {
51
+ if (at === void 0 || expires < at) {
52
+ at = expires;
53
+ due = [group];
54
+ } else if (expires === at) due.push(group);
55
+ }
56
+ }
57
+ for (const [field, relation] of Object.entries(group.relations ?? {})) {
58
+ const value = readField(store, key, field);
59
+ if (value._tag === "Some") for (const next of followRelation(value.value, relation)) walk(next);
60
+ }
61
+ };
62
+ for (const group of Requirement.merge(requirements)) walk(group);
63
+ return at === void 0 ? void 0 : {
64
+ at,
65
+ due: Requirement.merge(due)
66
+ };
67
+ };
68
+ const plan = (store, requirements, options = {}) => {
69
+ const { freshness, force = false } = options;
70
+ const planned = [];
71
+ const followed = [];
72
+ for (const group of Requirement.merge(requirements)) {
73
+ const key = entityKey(group.entity, group.id);
74
+ const entry = store[key];
75
+ const expired = freshness !== void 0 && entry !== void 0 && !entry.tombstone && freshness.now - entry.updatedAt > freshness.freshness;
76
+ const windowKeys = Object.fromEntries(Object.entries(group.windows ?? {}).map(([field, window]) => [field, windowKey(window)]));
77
+ const missing = force || expired ? group.fields : missingFields(store, key, group.fields, windowKeys);
78
+ const missingSet = new Set(missing);
79
+ for (const [field, relation] of Object.entries(group.relations ?? {})) {
80
+ if (missingSet.has(field)) continue;
81
+ const value = readField(store, key, field);
82
+ if (value._tag === "Some") followed.push(...followRelation(value.value, relation));
83
+ }
84
+ if (missing.length === 0) continue;
85
+ const windows = pickFields(group.windows, missingSet);
86
+ const relations = pickFields(group.relations, missingSet);
87
+ planned.push({
88
+ entity: group.entity,
89
+ id: group.id,
90
+ fields: missing,
91
+ ...windows === void 0 ? {} : { windows },
92
+ ...relations === void 0 ? {} : { relations }
93
+ });
94
+ }
95
+ const nested = followed.length === 0 ? [] : plan(store, followed, options);
96
+ return [...Requirement.merge([...planned, ...nested])].sort((a, b) => entityKey(a.entity, a.id) < entityKey(b.entity, b.id) ? -1 : 1);
97
+ };
98
+ export { deadlineOf, plan, windowKey };
99
+
100
+ //# sourceMappingURL=plan.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plan.js","names":[],"sources":["../src/plan.ts"],"sourcesContent":["/**\n * The pure requirement planner.\n *\n * A `Requirement` is plain data: which entity fields a Remote projection needs,\n * and through `relations`, which fields of each relation's target. `plan` diffs\n * requirements against the store and returns only the missing fields, grouped\n * and deterministically ordered. A relation whose field is being fetched keeps\n * its nested requirement on the request, so the server resolves the graph in\n * one read; a relation whose refs the store already holds is followed into\n * concrete requirements for the targets. Time enters through `PlanFreshness`,\n * never from ambient state; `force` plans every field.\n */\nimport { stableStringify } from './query.js'\nimport { targetsOf } from './relation.js'\nimport { Requirement, type RelationRequirement } from './requirement.js'\nimport { entityKey, missingFields, readField, type EntityStore } from './store.js'\n\ntype Window = NonNullable<Requirement['windows']>[string]\n\n/** A stable key for a window, so two equal windows compare equal. */\nexport const windowKey = (window: Window): string => stableStringify(window)\n\nexport interface PlanFreshness {\n readonly now: number\n /** A present entry older than this many milliseconds is refreshed whole. */\n readonly freshness: number\n}\n\nexport interface PlanOptions {\n readonly freshness?: PlanFreshness | undefined\n /** Plan every requested field, present or not, tombstoned or not. */\n readonly force?: boolean | undefined\n}\n\nconst pickFields = <T>(\n record: Readonly<Record<string, T>> | undefined,\n fields: ReadonlySet<string>,\n): Readonly<Record<string, T>> | undefined => {\n if (record === undefined) return undefined\n const picked = Object.fromEntries(Object.entries(record).filter(([field]) => fields.has(field)))\n return Object.keys(picked).length === 0 ? undefined : picked\n}\n\n/** The concrete requirements a known relation value contributes for its targets. */\nconst followRelation = (\n value: unknown,\n relation: RelationRequirement,\n): ReadonlyArray<Requirement> =>\n targetsOf(value, relation).map(ref => ({ ...relation, id: ref.id }))\n\n/** When present values next go stale, and the requirements of the entities that do. */\nexport interface Deadline {\n readonly at: number\n readonly due: ReadonlyArray<Requirement>\n}\n\n/**\n * The earliest moment a value `requirements` read, following the relations the\n * store holds as `plan` does, goes stale under `freshness`, with the\n * requirements of the entities that go stale then. A field the server settled\n * without a value ages with its entity too, since `plan` asks for it again once\n * the entity expires. Nothing when no such field is still fresh: what is\n * already expired or stale is planned, not awaited, which is what keeps a\n * timer from firing on what it already fired on. It is the next moment the\n * plan can change with the clock alone, which the read entry relies on to\n * reuse its plan until then.\n */\nexport const deadlineOf = (\n store: EntityStore,\n requirements: readonly Requirement[],\n freshness: PlanFreshness,\n): Deadline | undefined => {\n let at: number | undefined\n let due: Requirement[] = []\n const walk = (group: Requirement): void => {\n const key = entityKey(group.entity, group.id)\n const entry = store[key]\n if (entry === undefined || entry.tombstone) return\n const fresh = group.fields.some(\n field =>\n (entry.present.has(field) && !entry.stale.has(field)) || entry.unavailable.has(field),\n )\n if (fresh) {\n const expires = entry.updatedAt + freshness.freshness\n // An entity dated `Infinity` never ages (see `visibleStore`): no deadline.\n // At `expires` itself `plan` does not yet fetch it, so it is still awaited.\n if (expires >= freshness.now && Number.isFinite(expires)) {\n if (at === undefined || expires < at) {\n at = expires\n due = [group]\n } else if (expires === at) due.push(group)\n }\n }\n for (const [field, relation] of Object.entries(group.relations ?? {})) {\n const value = readField(store, key, field)\n if (value._tag === 'Some')\n for (const next of followRelation(value.value, relation)) walk(next)\n }\n }\n for (const group of Requirement.merge(requirements)) walk(group)\n return at === undefined ? undefined : { at, due: Requirement.merge(due) }\n}\n\nexport const plan = (\n store: EntityStore,\n requirements: readonly Requirement[],\n options: PlanOptions = {},\n): ReadonlyArray<Requirement> => {\n const { freshness, force = false } = options\n const planned: Requirement[] = []\n const followed: Requirement[] = []\n\n for (const group of Requirement.merge(requirements)) {\n const key = entityKey(group.entity, group.id)\n const entry = store[key]\n const expired =\n freshness !== undefined &&\n entry !== undefined &&\n !entry.tombstone &&\n freshness.now - entry.updatedAt > freshness.freshness\n const windowKeys = Object.fromEntries(\n Object.entries(group.windows ?? {}).map(([field, window]) => [field, windowKey(window)]),\n )\n const missing =\n force || expired ? group.fields : missingFields(store, key, group.fields, windowKeys)\n const missingSet = new Set(missing)\n\n // A relation the store already holds is followed into its targets; one\n // being fetched rides on the request instead.\n for (const [field, relation] of Object.entries(group.relations ?? {})) {\n if (missingSet.has(field)) continue\n const value = readField(store, key, field)\n if (value._tag === 'Some') followed.push(...followRelation(value.value, relation))\n }\n\n if (missing.length === 0) continue\n // Only a field being fetched carries its window and its relation.\n const windows = pickFields(group.windows, missingSet)\n const relations = pickFields(group.relations, missingSet)\n planned.push({\n entity: group.entity,\n id: group.id,\n fields: missing,\n ...(windows === undefined ? {} : { windows }),\n ...(relations === undefined ? {} : { relations }),\n })\n }\n\n const nested = followed.length === 0 ? [] : plan(store, followed, options)\n return [...Requirement.merge([...planned, ...nested])].sort((a, b) =>\n entityKey(a.entity, a.id) < entityKey(b.entity, b.id) ? -1 : 1,\n )\n}\n"],"mappings":"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"}
@@ -0,0 +1,42 @@
1
+ import { r as PlanOptions } from "./plan-D9cftxC_.js";
2
+ type RemotePolicy =
3
+ /** Fetch only fields the store lacks (missing, stale, or re-windowed). */
4
+ {
5
+ readonly _tag: 'CacheFirst';
6
+ } |
7
+ /**
8
+ * Keep present values visible, read them as `Refreshing`, and refetch an
9
+ * entry older than `maxAge` milliseconds.
10
+ */
11
+ {
12
+ readonly _tag: 'StaleWhileRevalidate';
13
+ readonly maxAge: number;
14
+ } |
15
+ /** Fetch every selected field regardless of coverage; cached values stay visible meanwhile. */
16
+ {
17
+ readonly _tag: 'NetworkOnly';
18
+ };
19
+ declare const RemotePolicy: {
20
+ /** Fetch only what the store lacks. */
21
+ cacheFirst: {
22
+ readonly _tag: 'CacheFirst';
23
+ };
24
+ /** Keep present values visible, read as `Refreshing`, and refetch an entry older than `maxAge` ms. */
25
+ staleWhileRevalidate: (options: {
26
+ readonly maxAge: number;
27
+ }) => RemotePolicy;
28
+ /** Fetch every selected field regardless of coverage; cached values stay visible meanwhile. */
29
+ networkOnly: {
30
+ readonly _tag: 'NetworkOnly';
31
+ };
32
+ /** The planner options a policy compiles to at `now`. */
33
+ toPlan: (policy: RemotePolicy, now: number) => PlanOptions;
34
+ /**
35
+ * Whether a read under this policy may refetch fields the store already
36
+ * holds, so an observer marks them stale (read as `Refreshing`) while the
37
+ * request is pending.
38
+ */
39
+ refreshes: (policy: RemotePolicy) => boolean;
40
+ };
41
+ export { RemotePolicy as t };
42
+ //# sourceMappingURL=policy-DsWHmIAq.d.ts.map