@opendatalabs/personal-server-ts-core 1.32.0 → 1.33.1

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 (41) hide show
  1. package/dist/additions/first-added.d.ts +220 -0
  2. package/dist/additions/first-added.d.ts.map +1 -0
  3. package/dist/additions/first-added.js +653 -0
  4. package/dist/additions/first-added.js.map +1 -0
  5. package/dist/additions/index.d.ts +6 -0
  6. package/dist/additions/index.d.ts.map +1 -0
  7. package/dist/additions/index.js +6 -0
  8. package/dist/additions/index.js.map +1 -0
  9. package/dist/additions/ledger-store.d.ts +69 -0
  10. package/dist/additions/ledger-store.d.ts.map +1 -0
  11. package/dist/additions/ledger-store.js +407 -0
  12. package/dist/additions/ledger-store.js.map +1 -0
  13. package/dist/additions/record-rules.d.ts +54 -0
  14. package/dist/additions/record-rules.d.ts.map +1 -0
  15. package/dist/additions/record-rules.js +191 -0
  16. package/dist/additions/record-rules.js.map +1 -0
  17. package/dist/additions/scope-lock.d.ts +14 -0
  18. package/dist/additions/scope-lock.d.ts.map +1 -0
  19. package/dist/additions/scope-lock.js +32 -0
  20. package/dist/additions/scope-lock.js.map +1 -0
  21. package/dist/additions/summary.d.ts +56 -0
  22. package/dist/additions/summary.d.ts.map +1 -0
  23. package/dist/additions/summary.js +114 -0
  24. package/dist/additions/summary.js.map +1 -0
  25. package/dist/api/index.d.ts.map +1 -1
  26. package/dist/api/index.js +26 -0
  27. package/dist/api/index.js.map +1 -1
  28. package/dist/contracts/data.d.ts +19 -1
  29. package/dist/contracts/data.d.ts.map +1 -1
  30. package/dist/contracts/data.js +95 -23
  31. package/dist/contracts/data.js.map +1 -1
  32. package/dist/ports/index.d.ts +11 -0
  33. package/dist/ports/index.d.ts.map +1 -1
  34. package/dist/ports/index.js.map +1 -1
  35. package/dist/sync/workers/download.d.ts.map +1 -1
  36. package/dist/sync/workers/download.js +19 -0
  37. package/dist/sync/workers/download.js.map +1 -1
  38. package/dist/test-utils/memory-storage.d.ts.map +1 -1
  39. package/dist/test-utils/memory-storage.js +47 -9
  40. package/dist/test-utils/memory-storage.js.map +1 -1
  41. package/package.json +5 -1
@@ -0,0 +1,653 @@
1
+ /**
2
+ * First-seen ledger: when each record of a scope was first seen.
3
+ *
4
+ * The Personal Server stores each data import as one envelope per scope,
5
+ * `{ scope, collectedAt, data }`, and a re-import replaces the whole scope
6
+ * with a fresh full snapshot. Nothing in that shape records when an
7
+ * individual record first appeared, so this module derives a per-record
8
+ * first-seen ledger by folding the scope's stored versions.
9
+ *
10
+ * The ledger is a SIDECAR kept beside the data (see `ledger-store.ts`). It
11
+ * is never stamped into a stored envelope, never synced as data and never
12
+ * served to grantees: it is a cache that can always be rebuilt from the
13
+ * retained versions.
14
+ *
15
+ * Everything here is pure: it never reads or writes a store and never
16
+ * mutates the objects it is handed. Folding is order independent and
17
+ * idempotent, so two devices that hold the same versions compute the same
18
+ * ledger, and a version folded twice changes nothing.
19
+ */
20
+ import { memoryRecordRulesFor } from "./record-rules.js";
21
+ /**
22
+ * At most this many tracked keys per scope snapshot AND in the ledger (the
23
+ * union over time). A snapshot over it is untrackable; when the union passes
24
+ * it, keys absent from the newest tracked version are dropped, oldest first.
25
+ */
26
+ export const MAX_TRACKED_KEYS = 200_000;
27
+ /** Ids longer than this are replaced by a hash so keys stay bounded. */
28
+ export const MAX_ID_LENGTH = 64;
29
+ /** Collection names longer than this are replaced by a hash (they can be caller-controlled). */
30
+ export const MAX_COLLECTION_LENGTH = 64;
31
+ /** A record absent from the newest tracked version is dropped after this long. */
32
+ export const PRUNE_AFTER_MS = 90 * 24 * 60 * 60 * 1000;
33
+ /** An unreadable version is read again at most this often. */
34
+ export const RETRY_UNREADABLE_MS = 60 * 60 * 1000;
35
+ /** At most this many unreadable versions are remembered for retry. */
36
+ export const MAX_RETRY_VERSIONS = 20;
37
+ /** Items processed between two yields to the event loop. */
38
+ export const YIELD_EVERY = 2_000;
39
+ /** Run the rest of an async loop in a later macrotask so other requests are served. */
40
+ export function yieldToEventLoop() {
41
+ return new Promise((resolve) => setTimeout(resolve, 0));
42
+ }
43
+ /** Property order an object's stable id is searched in. */
44
+ const ID_PROPERTIES = ["id", "uuid", "key", "uri", "url"];
45
+ /** Keys the server stamps into `data`; mirrors `storage/legacy-projection.ts`. */
46
+ const SERVER_STAMP_KEYS = new Set([
47
+ "$writtenBy",
48
+ "$lineage",
49
+ ]);
50
+ function isRecord(value) {
51
+ return value !== null && typeof value === "object" && !Array.isArray(value);
52
+ }
53
+ function hasOwn(data, key) {
54
+ return Object.prototype.hasOwnProperty.call(data, key);
55
+ }
56
+ /**
57
+ * The stored PDPP rows form: the only key that is not server-stamped is
58
+ * `records`, and it is an array. Mirrors `isRecordsBody` in
59
+ * `storage/legacy-projection.ts` (which is not importable here: this module
60
+ * must stay browser-safe).
61
+ */
62
+ function isRowsBody(data) {
63
+ const keys = Object.keys(data).filter((key) => !SERVER_STAMP_KEYS.has(key));
64
+ return (keys.length === 1 && keys[0] === "records" && Array.isArray(data.records));
65
+ }
66
+ async function sha256Hex(input) {
67
+ const bytes = new TextEncoder().encode(input);
68
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
69
+ let hex = "";
70
+ for (const byte of new Uint8Array(digest)) {
71
+ hex += byte.toString(16).padStart(2, "0");
72
+ }
73
+ return hex;
74
+ }
75
+ /**
76
+ * The value at a dotted path of plain objects, or undefined when any segment
77
+ * is missing or not a plain object.
78
+ */
79
+ function valueAtPath(obj, path) {
80
+ let current = obj;
81
+ for (const segment of path.split(".")) {
82
+ if (!isRecord(current) || !hasOwn(current, segment))
83
+ return undefined;
84
+ current = current[segment];
85
+ }
86
+ return current;
87
+ }
88
+ /**
89
+ * The id part of a key, unambiguous by its tag: `i:<id>` for a string,
90
+ * `n:<number>` for a number (so 1 and "1" differ) and `h:<32 hex of SHA-256>`
91
+ * for a string longer than `MAX_ID_LENGTH`. It is the last part of the key, so
92
+ * it needs no escaping.
93
+ */
94
+ async function idToken(id) {
95
+ if (typeof id === "number")
96
+ return `n:${String(id)}`;
97
+ return id.length > MAX_ID_LENGTH
98
+ ? `h:${(await sha256Hex(id)).slice(0, 32)}`
99
+ : `i:${id}`;
100
+ }
101
+ /**
102
+ * The collection part of a key: the name with `\`, `:` and `#` escaped, or
103
+ * `#<32 hex of SHA-256>` when it is longer than `MAX_COLLECTION_LENGTH` (it
104
+ * can come from the caller's data). The escaping keeps the first unescaped
105
+ * `:` the only separator, and a literal name can never equal a hashed one.
106
+ */
107
+ async function collectionToken(name) {
108
+ if (name.length > MAX_COLLECTION_LENGTH) {
109
+ return `#${(await sha256Hex(name)).slice(0, 32)}`;
110
+ }
111
+ return name.replace(/[\\:#]/g, (character) => `\\${character}`);
112
+ }
113
+ /**
114
+ * The identity token of a record: `i:<id>` (or `h:<hash>` for a long id) for
115
+ * the first `fields` entry that is a non-empty string or a finite number;
116
+ * null when the record has no usable id.
117
+ */
118
+ async function idPart(obj, fields) {
119
+ for (const field of fields) {
120
+ const value = valueAtPath(obj, field);
121
+ if (typeof value === "string" && value.length > 0)
122
+ return idToken(value);
123
+ if (typeof value === "number" && Number.isFinite(value)) {
124
+ return idToken(value);
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+ class KeyCollector {
130
+ keys = new Set();
131
+ total = 0;
132
+ tooMany = false;
133
+ /**
134
+ * Count `items`, and track those with an id. Once the tracked keys pass the
135
+ * cap it stops collecting (and frees them) but still counts, so a hostile
136
+ * body costs a bounded amount of work and memory.
137
+ */
138
+ async addItems(collection, items, idFields) {
139
+ this.total += items.length;
140
+ if (this.tooMany || idFields.length === 0)
141
+ return;
142
+ const prefix = await collectionToken(collection);
143
+ for (let index = 0; index < items.length; index += 1) {
144
+ if (index % YIELD_EVERY === YIELD_EVERY - 1)
145
+ await yieldToEventLoop();
146
+ const item = items[index];
147
+ if (!isRecord(item))
148
+ continue;
149
+ const id = await idPart(item, idFields);
150
+ if (id === null)
151
+ continue;
152
+ this.keys.add(`${prefix}:${id}`);
153
+ if (this.keys.size > MAX_TRACKED_KEYS) {
154
+ this.tooMany = true;
155
+ this.keys.clear();
156
+ return;
157
+ }
158
+ }
159
+ }
160
+ result() {
161
+ return {
162
+ keys: Array.from(this.keys),
163
+ total: this.total,
164
+ ...(this.tooMany ? { tooMany: true } : {}),
165
+ };
166
+ }
167
+ }
168
+ /** The dataset name of a scope: everything after its first dot. */
169
+ function datasetOf(scope) {
170
+ const dot = scope.indexOf(".");
171
+ return dot < 0 ? scope : scope.slice(dot + 1);
172
+ }
173
+ function passesStreamFilter(row, rule) {
174
+ return (!rule.streamFilter ||
175
+ row[rule.streamFilter.field] === rule.streamFilter.equals);
176
+ }
177
+ /** The items of a rule from a legacy body; the first matching array wins. */
178
+ function legacyItems(data, rule) {
179
+ for (const name of [rule.collection, ...(rule.aliases ?? [])]) {
180
+ if (!hasOwn(data, name))
181
+ continue;
182
+ const value = data[name];
183
+ if (!Array.isArray(value))
184
+ continue;
185
+ return value.filter(isRecord);
186
+ }
187
+ return [];
188
+ }
189
+ /**
190
+ * Stable keys for the records in an envelope `data` object, plus the number of
191
+ * records counted. Returns null when the data is not trackable (binary).
192
+ *
193
+ * Both stored forms of a scope give the same keys: the PDPP rows form
194
+ * `{ records: [row, ...] }` and the legacy keyed form `{ <collection>: [...] }`
195
+ * (a ruled scope names the id field per form, see `record-rules.ts`). A record
196
+ * is tracked only when it has a usable id; records without one count toward
197
+ * `total` but produce no key. Ids and collection names over 64 characters are
198
+ * hashed, so every key is at most about 200 characters.
199
+ */
200
+ export async function extractRecordKeys(scope, data) {
201
+ if (!isRecord(data))
202
+ return null;
203
+ const rules = memoryRecordRulesFor(scope);
204
+ // A scope whose rules hold nothing counts 0, whatever it stores (a rebuild
205
+ // reads nothing there, so a write must count the same).
206
+ if (rules !== null && rules.length === 0)
207
+ return { keys: [], total: 0 };
208
+ // A binary payload has no addressable records and is never tracked.
209
+ if (hasOwn(data, "$binary"))
210
+ return null;
211
+ const collector = new KeyCollector();
212
+ const rows = isRowsBody(data) ? data.records : null;
213
+ if (rules !== null) {
214
+ if (rows !== null) {
215
+ // The rows ARE the items of the rule named by the dataset, or of the
216
+ // scope's only rule.
217
+ const dataset = datasetOf(scope);
218
+ const named = rules.filter((rule) => rule.collection === dataset || (rule.aliases ?? []).includes(dataset));
219
+ const chosen = named.length > 0 ? named : rules.length === 1 ? rules : [];
220
+ if (chosen.length === 0 && rules.length > 0) {
221
+ // Several collections and no way to tell which the rows belong to:
222
+ // count them, track none.
223
+ collector.total += rows.length;
224
+ }
225
+ for (const rule of chosen) {
226
+ if (rule.singleton) {
227
+ collector.total += rows.filter(isRecord).length;
228
+ continue;
229
+ }
230
+ const items = rows
231
+ .filter(isRecord)
232
+ .filter((row) => passesStreamFilter(row, rule));
233
+ await collector.addItems(rule.collection, items, rule.rowIdFields);
234
+ }
235
+ }
236
+ else {
237
+ for (const rule of rules) {
238
+ if (rule.singleton) {
239
+ // The body is the record: counted once when it has any content.
240
+ if (Object.keys(data).some((key) => !key.startsWith("$"))) {
241
+ collector.total += 1;
242
+ }
243
+ continue;
244
+ }
245
+ await collector.addItems(rule.collection, legacyItems(data, rule), rule.idFields);
246
+ }
247
+ }
248
+ return collector.result();
249
+ }
250
+ // Generic scope.
251
+ if (rows !== null) {
252
+ await collector.addItems(datasetOf(scope), rows, ID_PROPERTIES);
253
+ return collector.result();
254
+ }
255
+ let sawArray = false;
256
+ for (const key of Object.keys(data)) {
257
+ if (key.startsWith("$"))
258
+ continue;
259
+ const value = data[key];
260
+ if (!Array.isArray(value))
261
+ continue;
262
+ sawArray = true;
263
+ await collector.addItems(key, value, ID_PROPERTIES);
264
+ }
265
+ // No arrays at all: the scope itself is the single record (a profile).
266
+ if (!sawArray)
267
+ collector.total = 1;
268
+ return collector.result();
269
+ }
270
+ function ms(value) {
271
+ return value === null ? Number.NaN : Date.parse(value);
272
+ }
273
+ /**
274
+ * Whether `a` is the newer of two collectedAt strings. Instants decide
275
+ * ("…00Z" and "…00.000Z" are the same instant); a tie is broken by the
276
+ * greater string, so the outcome never depends on the order of folding. Equal
277
+ * strings (a rewrite of the same slot) count as newer, so the last one wins.
278
+ */
279
+ function isNewer(a, b) {
280
+ if (b === null)
281
+ return true;
282
+ const difference = Date.parse(a) - Date.parse(b);
283
+ return difference > 0 || (difference === 0 && a >= b);
284
+ }
285
+ /** Whether `a` is strictly earlier than `b` (instant first, then the smaller string). */
286
+ function isEarlier(a, b) {
287
+ const difference = Date.parse(a) - Date.parse(b);
288
+ return difference < 0 || (difference === 0 && a < b);
289
+ }
290
+ /**
291
+ * A version is trackable when its records could be told apart: it has records
292
+ * with ids, or no records at all. A binary file, a snapshot over the cap and
293
+ * a snapshot whose records have no ids are counted but are not a baseline.
294
+ */
295
+ function isTrackable(extraction) {
296
+ return (extraction !== null &&
297
+ !extraction.tooMany &&
298
+ (extraction.keys.length > 0 || extraction.total === 0));
299
+ }
300
+ /**
301
+ * Folds stored versions into one ledger, one at a time, so a caller can read a
302
+ * version, `add` it and drop it before reading the next: only one parsed
303
+ * envelope is ever alive. Order independent and idempotent for versions within
304
+ * the 90-day pruning window of each other (pruning is relative to the newest
305
+ * tracked version, so folding versions further apart in a different order can
306
+ * re-date a record that was already pruned).
307
+ */
308
+ export class LedgerFold {
309
+ scope;
310
+ baseline = null;
311
+ current = null;
312
+ latest = null;
313
+ through = null;
314
+ retry;
315
+ skipped;
316
+ truncated = false;
317
+ // A Map keeps arbitrary keys (including "__proto__") out of the prototype
318
+ // chain; Object.fromEntries defines each one as an own property again.
319
+ records = new Map();
320
+ constructor(ledger, scope) {
321
+ this.scope = ledger?.scope ?? scope ?? null;
322
+ if (!ledger)
323
+ return;
324
+ this.baseline = ledger.baseline;
325
+ this.current = ledger.current;
326
+ this.latest = { ...ledger.latest };
327
+ this.through = ledger.through;
328
+ this.retry = ledger.retry
329
+ ? {
330
+ versions: [...ledger.retry.versions],
331
+ attemptedAt: ledger.retry.attemptedAt,
332
+ }
333
+ : undefined;
334
+ this.skipped = ledger.skipped;
335
+ this.truncated = ledger.truncated === true;
336
+ // Copied, never shared: the input ledger is not mutated.
337
+ for (const [key, pair] of Object.entries(ledger.records)) {
338
+ this.records.set(key, [pair[0], pair[1]]);
339
+ }
340
+ }
341
+ async add(version) {
342
+ const at = Date.parse(version.collectedAt);
343
+ if (Number.isNaN(at))
344
+ return;
345
+ if (this.scope === null)
346
+ this.scope = version.scope;
347
+ if (this.scope !== version.scope)
348
+ return;
349
+ const extraction = await extractRecordKeys(version.scope, version.data);
350
+ // A binary file is one record of the scope (the owner app lists it so).
351
+ const total = extraction?.total ?? 1;
352
+ if (this.latest === null ||
353
+ isNewer(version.collectedAt, this.latest.collectedAt)) {
354
+ this.latest = { collectedAt: version.collectedAt, total };
355
+ }
356
+ if (isNewer(version.collectedAt, this.through)) {
357
+ this.through = version.collectedAt;
358
+ }
359
+ // A negative-outcome marker keeps no keys; a binary file or a snapshot
360
+ // over the key cap is counted above but contributes none.
361
+ if (this.skipped !== undefined)
362
+ return;
363
+ if (!isTrackable(extraction))
364
+ return;
365
+ if (this.baseline === null) {
366
+ this.baseline = version.collectedAt;
367
+ }
368
+ else if (isEarlier(version.collectedAt, this.baseline) &&
369
+ !this.truncated) {
370
+ // A truncated ledger never moves its baseline back over versions it did
371
+ // not fold: their records would then look added.
372
+ this.baseline = version.collectedAt;
373
+ }
374
+ if (isNewer(version.collectedAt, this.current)) {
375
+ this.current = version.collectedAt;
376
+ }
377
+ const keys = extraction.keys;
378
+ const fresh = [];
379
+ // First pass: records the ledger already knows move their first/last seen.
380
+ for (let index = 0; index < keys.length; index += 1) {
381
+ if (index % YIELD_EVERY === YIELD_EVERY - 1)
382
+ await yieldToEventLoop();
383
+ const key = keys[index];
384
+ const existing = this.records.get(key);
385
+ if (!existing) {
386
+ fresh.push(key);
387
+ continue;
388
+ }
389
+ if (isEarlier(version.collectedAt, existing[0])) {
390
+ existing[0] = version.collectedAt;
391
+ }
392
+ if (isNewer(version.collectedAt, existing[1])) {
393
+ existing[1] = version.collectedAt;
394
+ }
395
+ }
396
+ if (fresh.length > 0)
397
+ await this.insertFresh(fresh, keys, version.collectedAt);
398
+ }
399
+ /**
400
+ * Insert the records this version brings that the ledger does not know.
401
+ * Capacity is decided ONCE: when they do not fit, absent records are evicted
402
+ * in a single pass (see `evict`), and whatever still does not fit is left
403
+ * out, smallest keys first, so the result never depends on the order of the
404
+ * ids in the version.
405
+ */
406
+ async insertFresh(fresh, versionKeys, collectedAt) {
407
+ fresh.sort();
408
+ let room = MAX_TRACKED_KEYS - this.records.size;
409
+ if (fresh.length > room) {
410
+ room += await this.evict(fresh.length - room, new Set(versionKeys));
411
+ }
412
+ const take = Math.min(fresh.length, Math.max(0, room));
413
+ for (let index = 0; index < take; index += 1) {
414
+ if (index % YIELD_EVERY === YIELD_EVERY - 1)
415
+ await yieldToEventLoop();
416
+ this.records.set(fresh[index], [collectedAt, collectedAt]);
417
+ }
418
+ }
419
+ /**
420
+ * Make room for `need` records by dropping some that are absent: not in the
421
+ * newest tracked version, not in the version being folded, and not
422
+ * baseline records (first seen at or before the baseline). Baseline records
423
+ * are never dropped. Other absent records CAN be dropped (and are
424
+ * dated as new if they return): that needs a scope near the cap, and
425
+ * whoever can write the scope can provoke it. Among the evictable ones the
426
+ * most recently first seen go first. Returns how many were dropped.
427
+ */
428
+ async evict(need, inVersion) {
429
+ const currentMs = ms(this.current);
430
+ const baselineMs = ms(this.baseline);
431
+ const candidates = [];
432
+ let seen = 0;
433
+ for (const [key, pair] of this.records) {
434
+ seen += 1;
435
+ if (seen % (YIELD_EVERY * 5) === 0)
436
+ await yieldToEventLoop();
437
+ if (inVersion.has(key))
438
+ continue;
439
+ if (Date.parse(pair[1]) === currentMs)
440
+ continue;
441
+ const firstMs = Date.parse(pair[0]);
442
+ if (firstMs <= baselineMs)
443
+ continue;
444
+ candidates.push([firstMs, key]);
445
+ }
446
+ candidates.sort((a, b) => b[0] - a[0] || (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
447
+ const count = Math.min(need, candidates.length);
448
+ for (let index = 0; index < count; index += 1) {
449
+ this.records.delete(candidates[index][1]);
450
+ }
451
+ return count;
452
+ }
453
+ /** Drop records that are not present and were last seen over 90 days before `current`. */
454
+ async prune() {
455
+ if (this.current === null || this.skipped !== undefined)
456
+ return;
457
+ const currentMs = ms(this.current);
458
+ const cutoff = currentMs - PRUNE_AFTER_MS;
459
+ let seen = 0;
460
+ for (const [key, pair] of this.records) {
461
+ seen += 1;
462
+ if (seen % (YIELD_EVERY * 5) === 0)
463
+ await yieldToEventLoop();
464
+ const lastMs = Date.parse(pair[1]);
465
+ if (lastMs !== currentMs && lastMs < cutoff)
466
+ this.records.delete(key);
467
+ }
468
+ }
469
+ /**
470
+ * Prune and return the ledger, or null when nothing was ever folded.
471
+ * `truncated` marks a ledger rebuilt from only the newest retained versions;
472
+ * `through` overrides the completeness marker (see `ScopeFirstSeenLedger`).
473
+ */
474
+ async finish(options = {}) {
475
+ if (this.scope === null || this.latest === null)
476
+ return null;
477
+ await this.prune();
478
+ const truncated = this.truncated || options.truncated === true;
479
+ const retry = options.retry === undefined ? this.retry : options.retry;
480
+ return {
481
+ version: 4,
482
+ scope: this.scope,
483
+ baseline: this.baseline,
484
+ current: this.current,
485
+ latest: this.latest,
486
+ through: options.through ?? this.through ?? this.latest.collectedAt,
487
+ ...(retry ? { retry } : {}),
488
+ ...(this.skipped ? { skipped: this.skipped } : {}),
489
+ ...(truncated ? { truncated: true } : {}),
490
+ records: Object.fromEntries(this.records),
491
+ };
492
+ }
493
+ }
494
+ /**
495
+ * Fold several stored versions into a ledger in one pass. Equivalent to
496
+ * calling `foldVersion` for each, but copies the ledger and prunes once.
497
+ * Returns null only when no version had a parseable `collectedAt` and there
498
+ * was no starting ledger.
499
+ */
500
+ export async function foldVersions(ledger, versions) {
501
+ const fold = new LedgerFold(ledger);
502
+ for (const version of versions)
503
+ await fold.add(version);
504
+ return fold.finish();
505
+ }
506
+ /**
507
+ * Fold one stored version into a ledger (or start one). A version whose
508
+ * `collectedAt` does not parse is ignored.
509
+ */
510
+ export function foldVersion(ledger, version) {
511
+ return foldVersions(ledger, [version]);
512
+ }
513
+ /** True when the record was in the newest tracked version folded in. */
514
+ export function isPresent(ledger, key) {
515
+ if (ledger.current === null || !hasOwn(ledger.records, key))
516
+ return false;
517
+ return Date.parse(ledger.records[key][1]) === Date.parse(ledger.current);
518
+ }
519
+ /**
520
+ * True when the record's first-seen date is unknown: there is no baseline, or
521
+ * the ledger is partial and the record was first seen at or before the
522
+ * baseline. In a complete ledger the baseline's records are dated by it.
523
+ */
524
+ export function isPreTracking(ledger, key) {
525
+ if (ledger.baseline === null)
526
+ return true;
527
+ if (ledger.partial !== true || !hasOwn(ledger.records, key))
528
+ return false;
529
+ return Date.parse(ledger.records[key][0]) <= Date.parse(ledger.baseline);
530
+ }
531
+ /**
532
+ * First-seen timestamps of the records that count as additions: present in
533
+ * the newest tracked version. Records first seen at the baseline count too
534
+ * (they entered the server then) unless the ledger is `partial`, where their
535
+ * date is unknown. One per record. A skipped ledger, one with no baseline yet, and a scope whose newest
536
+ * version is not its newest tracked one (a binary file or an over-cap
537
+ * snapshot, whose `latest.total` does not describe those records) report none,
538
+ * so `added` can never exceed `total`.
539
+ */
540
+ export function listAddedTimestamps(ledger) {
541
+ if (ledger.skipped !== undefined ||
542
+ ledger.baseline === null ||
543
+ ledger.current === null ||
544
+ Date.parse(ledger.current) !== Date.parse(ledger.latest.collectedAt)) {
545
+ return [];
546
+ }
547
+ const baselineMs = Date.parse(ledger.baseline);
548
+ const currentMs = Date.parse(ledger.current);
549
+ const added = [];
550
+ for (const [first, last] of Object.values(ledger.records)) {
551
+ if (Date.parse(last) !== currentMs)
552
+ continue;
553
+ if (ledger.partial === true && Date.parse(first) <= baselineMs)
554
+ continue;
555
+ added.push(first);
556
+ }
557
+ // Two versions at one instant can both look present; never report more
558
+ // additions than the newest version has records.
559
+ if (added.length > ledger.latest.total) {
560
+ added.sort();
561
+ added.length = ledger.latest.total;
562
+ }
563
+ return added;
564
+ }
565
+ // ---------------------------------------------------------------------------
566
+ // Validation
567
+ // ---------------------------------------------------------------------------
568
+ function parseableString(value) {
569
+ return typeof value === "string" && !Number.isNaN(Date.parse(value));
570
+ }
571
+ function nullableParseable(value) {
572
+ return value === null || parseableString(value);
573
+ }
574
+ const SKIP_REASONS = new Set(["too_large", "unreadable"]);
575
+ /**
576
+ * The ledger in `value`, or null unless it is a well-formed version-2
577
+ * document (older versions are rejected and rebuilt). Never throws. The result is a fresh object.
578
+ */
579
+ export function readScopeFirstSeenLedger(value) {
580
+ try {
581
+ if (!isRecord(value) || value.version !== 4)
582
+ return null;
583
+ const { scope, baseline, current, latest, through, records, skipped, truncated, } = value;
584
+ if (typeof scope !== "string" || scope.length === 0)
585
+ return null;
586
+ if (!nullableParseable(baseline) || !nullableParseable(current)) {
587
+ return null;
588
+ }
589
+ if (!isRecord(latest) || !parseableString(latest.collectedAt))
590
+ return null;
591
+ if (!parseableString(through))
592
+ return null;
593
+ let retryValue;
594
+ if (value.retry !== undefined) {
595
+ const retry = value.retry;
596
+ if (!isRecord(retry) ||
597
+ !Array.isArray(retry.versions) ||
598
+ retry.versions.length === 0 ||
599
+ retry.versions.length > MAX_RETRY_VERSIONS ||
600
+ !retry.versions.every(parseableString) ||
601
+ !parseableString(retry.attemptedAt)) {
602
+ return null;
603
+ }
604
+ retryValue = {
605
+ versions: [...retry.versions],
606
+ attemptedAt: retry.attemptedAt,
607
+ };
608
+ }
609
+ // Completeness cannot run ahead of the newest version the ledger knows
610
+ // (folded, or remembered as unreadable): such a marker would suppress the
611
+ // catch-up it exists to trigger.
612
+ const newestKnown = Math.max(Date.parse(latest.collectedAt), ...(retryValue?.versions.map((version) => Date.parse(version)) ?? []));
613
+ if (Date.parse(through) > newestKnown)
614
+ return null;
615
+ const total = latest.total;
616
+ if (typeof total !== "number" || !Number.isInteger(total) || total < 0) {
617
+ return null;
618
+ }
619
+ if (skipped !== undefined && !SKIP_REASONS.has(skipped))
620
+ return null;
621
+ if (truncated !== undefined && truncated !== true)
622
+ return null;
623
+ if (!isRecord(records))
624
+ return null;
625
+ const entries = Object.entries(records);
626
+ if (entries.length > MAX_TRACKED_KEYS)
627
+ return null;
628
+ const copied = new Map();
629
+ for (const [key, pair] of entries) {
630
+ if (!Array.isArray(pair) || pair.length !== 2)
631
+ return null;
632
+ if (!parseableString(pair[0]) || !parseableString(pair[1]))
633
+ return null;
634
+ copied.set(key, [pair[0], pair[1]]);
635
+ }
636
+ return {
637
+ version: 4,
638
+ scope,
639
+ baseline,
640
+ current,
641
+ latest: { collectedAt: latest.collectedAt, total },
642
+ through,
643
+ ...(retryValue ? { retry: retryValue } : {}),
644
+ ...(skipped ? { skipped: skipped } : {}),
645
+ ...(truncated ? { truncated: true } : {}),
646
+ records: Object.fromEntries(copied),
647
+ };
648
+ }
649
+ catch {
650
+ return null;
651
+ }
652
+ }
653
+ //# sourceMappingURL=first-added.js.map