@wovin/core 0.3.0 → 0.3.2

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 (53) hide show
  1. package/dist/applog/applog-utils.d.ts +25 -1
  2. package/dist/applog/applog-utils.d.ts.map +1 -1
  3. package/dist/applog.js +5 -3
  4. package/dist/{chunk-2OXLPZQI.js → chunk-4MKPGQIM.js} +8 -16
  5. package/dist/chunk-4MKPGQIM.js.map +1 -0
  6. package/dist/{chunk-VGIACGWX.js → chunk-6CSJTSQP.js} +3 -3
  7. package/dist/{chunk-WVW4YXB5.js → chunk-BIYQEX3N.js} +2 -2
  8. package/dist/{chunk-Q4EMPWA3.js → chunk-H3JNNTVP.js} +9 -3
  9. package/dist/chunk-H3JNNTVP.js.map +1 -0
  10. package/dist/{chunk-EHO2BFFY.js → chunk-H4YVJKB7.js} +2 -2
  11. package/dist/chunk-N5QPZNKD.js +288 -0
  12. package/dist/chunk-N5QPZNKD.js.map +1 -0
  13. package/dist/{chunk-2PJFLZRC.js → chunk-N7SEGHU4.js} +2 -2
  14. package/dist/index.js +20 -10
  15. package/dist/ipfs/car.d.ts.map +1 -1
  16. package/dist/ipfs.js +4 -4
  17. package/dist/ipns/ipns-record.d.ts +19 -1
  18. package/dist/ipns/ipns-record.d.ts.map +1 -1
  19. package/dist/ipns/ipns-w3name.d.ts +31 -1
  20. package/dist/ipns/ipns-w3name.d.ts.map +1 -1
  21. package/dist/ipns/ipns-watcher.d.ts.map +1 -1
  22. package/dist/ipns.js +118 -21
  23. package/dist/ipns.js.map +1 -1
  24. package/dist/pubsub.js +4 -4
  25. package/dist/query/epoch-snapshot.d.ts +205 -0
  26. package/dist/query/epoch-snapshot.d.ts.map +1 -0
  27. package/dist/query.d.ts +1 -0
  28. package/dist/query.d.ts.map +1 -1
  29. package/dist/query.js +12 -4
  30. package/dist/retrieve.js +4 -4
  31. package/dist/thread.js +1 -1
  32. package/dist/viewmodel/index.js +4 -4
  33. package/dist/viewmodel/index.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/applog/applog-utils.test.ts +43 -1
  36. package/src/applog/applog-utils.ts +38 -21
  37. package/src/applog/object-values.test.ts +9 -9
  38. package/src/ipfs/car.ts +6 -0
  39. package/src/ipns/ipns-record.ts +95 -17
  40. package/src/ipns/ipns-w3name.ts +109 -5
  41. package/src/ipns/ipns-watcher.ts +4 -3
  42. package/src/query/epoch-snapshot.test.ts +594 -0
  43. package/src/query/epoch-snapshot.ts +392 -0
  44. package/src/query.ts +1 -0
  45. package/src/viewmodel/factory.ts +2 -2
  46. package/dist/chunk-2OXLPZQI.js.map +0 -1
  47. package/dist/chunk-OKXRRWNS.js +0 -138
  48. package/dist/chunk-OKXRRWNS.js.map +0 -1
  49. package/dist/chunk-Q4EMPWA3.js.map +0 -1
  50. /package/dist/{chunk-VGIACGWX.js.map → chunk-6CSJTSQP.js.map} +0 -0
  51. /package/dist/{chunk-WVW4YXB5.js.map → chunk-BIYQEX3N.js.map} +0 -0
  52. /package/dist/{chunk-EHO2BFFY.js.map → chunk-H4YVJKB7.js.map} +0 -0
  53. /package/dist/{chunk-2PJFLZRC.js.map → chunk-N7SEGHU4.js.map} +0 -0
@@ -0,0 +1,594 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
+ import { finalizeApplogForInsert } from '../applog/applog-helpers.ts'
3
+ import { sortApplogsByTs } from '../applog/applog-utils.ts'
4
+ import type { Applog, ApplogForInsert } from '../applog/datom-types.ts'
5
+ import { ThreadInMemory } from '../thread/writeable.ts'
6
+ import { createEpochSnapshot, resolveTimeSpec, serializeEpochSnapshot, purgeHistory } from './epoch-snapshot.ts'
7
+
8
+ // ─── Test Data ───────────────────────────────────────────────────
9
+ // A small movie database — same schema as note3's test-applogs but
10
+ // with explicit CIDs and a smaller dataset for focused testing.
11
+
12
+ let tsCounter = 0
13
+ function makeApplogs(inputs: ApplogForInsert[]): Applog[] {
14
+ const logs = inputs.map(input =>
15
+ finalizeApplogForInsert({
16
+ ts: new Date(1700000000000 + ++tsCounter * 1000).toISOString(),
17
+ pv: null,
18
+ ag: 'testAgent',
19
+ ...input,
20
+ } as ApplogForInsert, {}),
21
+ )
22
+ sortApplogsByTs(logs)
23
+ return logs
24
+ }
25
+
26
+ // Base dataset — spanning several "epochs" so we can test time-range slicing.
27
+ // Timestamps are sequential (1s apart) starting at 1700000001000.
28
+ const baseData: ApplogForInsert[] = [
29
+ // People – created first (earliest timestamps)
30
+ { en: 'p1', at: 'person/name', vl: 'James Cameron', ag: 'testAgent' },
31
+ { en: 'p2', at: 'person/name', vl: 'Arnold Schwarzenegger', ag: 'testAgent' },
32
+ { en: 'p3', at: 'person/name', vl: 'Linda Hamilton', ag: 'testAgent' },
33
+ { en: 'p4', at: 'person/name', vl: 'John McTiernan', ag: 'testAgent' },
34
+ { en: 'p5', at: 'person/name', vl: 'Bruce Willis', ag: 'testAgent' },
35
+
36
+ // Movies – created second
37
+ { en: 'm1', at: 'movie/title', vl: 'The Terminator', ag: 'testAgent' },
38
+ { en: 'm1', at: 'movie/year', vl: 1984, ag: 'testAgent' },
39
+ { en: 'm1', at: 'movie/director', vl: 'p1', ag: 'testAgent' },
40
+ { en: 'm1', at: 'movie/cast', vl: 'p2', ag: 'testAgent' },
41
+ { en: 'm1', at: 'movie/cast', vl: 'p3', ag: 'testAgent' },
42
+
43
+ { en: 'm2', at: 'movie/title', vl: 'Predator', ag: 'testAgent' },
44
+ { en: 'm2', at: 'movie/year', vl: 1987, ag: 'testAgent' },
45
+ { en: 'm2', at: 'movie/director', vl: 'p4', ag: 'testAgent' },
46
+ { en: 'm2', at: 'movie/cast', vl: 'p2', ag: 'testAgent' },
47
+
48
+ { en: 'm3', at: 'movie/title', vl: 'Die Hard', ag: 'testAgent' },
49
+ { en: 'm3', at: 'movie/year', vl: 1988, ag: 'testAgent' },
50
+ { en: 'm3', at: 'movie/director', vl: 'p4', ag: 'testAgent' },
51
+ { en: 'm3', at: 'movie/cast', vl: 'p5', ag: 'testAgent' },
52
+
53
+ { en: 'm4', at: 'movie/title', vl: 'T2: Judgment Day', ag: 'testAgent' },
54
+ { en: 'm4', at: 'movie/year', vl: 1991, ag: 'testAgent' },
55
+ { en: 'm4', at: 'movie/director', vl: 'p1', ag: 'testAgent' },
56
+ { en: 'm4', at: 'movie/cast', vl: 'p2', ag: 'testAgent' },
57
+ { en: 'm4', at: 'movie/cast', vl: 'p3', ag: 'testAgent' },
58
+ ]
59
+
60
+ // ─── Helpers ─────────────────────────────────────────────────────
61
+ let db: ReturnType<typeof ThreadInMemory.fromArray>
62
+ let allLogs: Applog[]
63
+
64
+ beforeEach(() => {
65
+ tsCounter = 0
66
+ allLogs = makeApplogs(baseData)
67
+ db = ThreadInMemory.fromArray(allLogs, 'test-movies')
68
+ })
69
+
70
+ afterEach(() => {
71
+ // Fresh db is created in beforeEach — no global state to reset
72
+ })
73
+
74
+ // ═════════════════════════════════════════════════════════════════
75
+ // Tests
76
+ // ═════════════════════════════════════════════════════════════════
77
+
78
+ describe('createEpochSnapshot', () => {
79
+ it('returns empty map for an empty thread', () => {
80
+ const emptyDb = ThreadInMemory.fromArray([], 'empty')
81
+ const snap = createEpochSnapshot(emptyDb, {
82
+ start: '2024-01-01T00:00:00.000Z',
83
+ end: '2025-01-01T00:00:00.000Z',
84
+ })
85
+ expect(snap.size).toBe(0)
86
+ })
87
+
88
+ it('groups all logs by attribute when range covers everything', () => {
89
+ // Find min and max timestamps from the generated data
90
+ const firstTs = allLogs[0].ts
91
+ const lastTs = allLogs[allLogs.length - 1].ts
92
+
93
+ const snap = createEpochSnapshot(db, {
94
+ start: firstTs,
95
+ end: lastTs, // exclusive, so the last log's exact ts won't be included
96
+ })
97
+
98
+ // last log excluded, so count should be allLogs.length - 1
99
+ expect(snap.size).toBeGreaterThan(0)
100
+
101
+ // Count total logs across all groups
102
+ let total = 0
103
+ for (const logs of snap.values()) total += logs.length
104
+ expect(total).toBe(allLogs.length - 1) // last excluded by exclusive end
105
+ })
106
+
107
+ it('includes all logs with a generous end bound', () => {
108
+ const firstTs = allLogs[0].ts
109
+
110
+ const snap = createEpochSnapshot(db, {
111
+ start: firstTs,
112
+ end: '3000-01-01T00:00:00.000Z',
113
+ })
114
+
115
+ let total = 0
116
+ for (const logs of snap.values()) total += logs.length
117
+ expect(total).toBe(allLogs.length)
118
+ })
119
+
120
+ it('has the correct attribute keys', () => {
121
+ const snap = createEpochSnapshot(db, {
122
+ start: '2023-01-01T00:00:00.000Z',
123
+ end: '3000-01-01T00:00:00.000Z',
124
+ })
125
+
126
+ const attrs = new Set(snap.keys())
127
+ expect(attrs.has('person/name')).toBe(true)
128
+ expect(attrs.has('movie/title')).toBe(true)
129
+ expect(attrs.has('movie/year')).toBe(true)
130
+ expect(attrs.has('movie/director')).toBe(true)
131
+ expect(attrs.has('movie/cast')).toBe(true)
132
+ expect(attrs.size).toBe(5)
133
+ })
134
+
135
+ it('groups logs correctly — person/name has 5 entries', () => {
136
+ const snap = createEpochSnapshot(db, {
137
+ start: '2023-01-01T00:00:00.000Z',
138
+ end: '3000-01-01T00:00:00.000Z',
139
+ })
140
+
141
+ const nameLogs = snap.get('person/name')
142
+ expect(nameLogs).toBeDefined()
143
+ expect(nameLogs!.length).toBe(5)
144
+ })
145
+
146
+ it('groups logs correctly — movie/title has 4 entries', () => {
147
+ const snap = createEpochSnapshot(db, {
148
+ start: '2023-01-01T00:00:00.000Z',
149
+ end: '3000-01-01T00:00:00.000Z',
150
+ })
151
+
152
+ const titleLogs = snap.get('movie/title')
153
+ expect(titleLogs).toBeDefined()
154
+ expect(titleLogs!.length).toBe(4)
155
+ // Verify specific titles
156
+ const titles = titleLogs!.map(l => l.vl)
157
+ expect(titles).toContain('The Terminator')
158
+ expect(titles).toContain('Predator')
159
+ expect(titles).toContain('Die Hard')
160
+ expect(titles).toContain('T2: Judgment Day')
161
+ })
162
+
163
+ it('groups logs correctly — movie/cast has 6 entries across movies', () => {
164
+ const snap = createEpochSnapshot(db, {
165
+ start: '2023-01-01T00:00:00.000Z',
166
+ end: '3000-01-01T00:00:00.000Z',
167
+ })
168
+
169
+ const castLogs = snap.get('movie/cast')
170
+ expect(castLogs).toBeDefined()
171
+ expect(castLogs!.length).toBe(6)
172
+ })
173
+
174
+ it('returns empty map when no logs fall in the time range', () => {
175
+ const snap = createEpochSnapshot(db, {
176
+ start: '1990-01-01T00:00:00.000Z',
177
+ end: '1990-06-01T00:00:00.000Z',
178
+ })
179
+ expect(snap.size).toBe(0)
180
+ })
181
+
182
+ it('slices a partial range — only picks logs with timestamps in window', () => {
183
+ // Our logs have sequential timestamps. Pick a window that covers
184
+ // roughly the first half of logs.
185
+ const midPoint = allLogs[Math.floor(allLogs.length / 2)].ts
186
+
187
+ const snap = createEpochSnapshot(db, {
188
+ start: allLogs[0].ts,
189
+ end: midPoint,
190
+ })
191
+
192
+ let total = 0
193
+ for (const logs of snap.values()) total += logs.length
194
+
195
+ // Should contain roughly half the logs (excluding the one at midPoint)
196
+ expect(total).toBeGreaterThan(0)
197
+ expect(total).toBeLessThan(allLogs.length)
198
+ })
199
+
200
+ it('sorts each group by timestamp ascending', () => {
201
+ const snap = createEpochSnapshot(db, {
202
+ start: '2023-01-01T00:00:00.000Z',
203
+ end: '3000-01-01T00:00:00.000Z',
204
+ })
205
+
206
+ for (const [attr, logs] of snap) {
207
+ for (let i = 1; i < logs.length; i++) {
208
+ expect(logs[i].ts.localeCompare(logs[i - 1].ts, 'en-US')).toBeGreaterThanOrEqual(0)
209
+ }
210
+ }
211
+ })
212
+
213
+ it('throws RangeError when start >= end', () => {
214
+ expect(() =>
215
+ createEpochSnapshot(db, {
216
+ start: '2025-01-01T00:00:00.000Z',
217
+ end: '2024-01-01T00:00:00.000Z',
218
+ })
219
+ ).toThrow(RangeError)
220
+ })
221
+
222
+ it('throws RangeError when start equals end', () => {
223
+ expect(() =>
224
+ createEpochSnapshot(db, {
225
+ start: '2024-06-01T00:00:00.000Z',
226
+ end: '2024-06-01T00:00:00.000Z',
227
+ })
228
+ ).toThrow(RangeError)
229
+ })
230
+
231
+ it('attribute groups contain applogs from different entities', () => {
232
+ // The `movie/director` attribute should link movies (m1-m4) to
233
+ // director entities (p1, p4) — from different entities.
234
+ const snap = createEpochSnapshot(db, {
235
+ start: '2023-01-01T00:00:00.000Z',
236
+ end: '3000-01-01T00:00:00.000Z',
237
+ })
238
+
239
+ const dirLogs = snap.get('movie/director')
240
+ expect(dirLogs).toBeDefined()
241
+ expect(dirLogs!.length).toBe(4) // 4 movies × 1 director each
242
+
243
+ const entities = new Set(dirLogs!.map(l => l.en))
244
+ expect(entities.has('m1')).toBe(true)
245
+ expect(entities.has('m2')).toBe(true)
246
+ expect(entities.has('m3')).toBe(true)
247
+ expect(entities.has('m4')).toBe(true)
248
+ })
249
+
250
+ it('preserves all Applog fields in each entry', () => {
251
+ const snap = createEpochSnapshot(db, {
252
+ start: '2023-01-01T00:00:00.000Z',
253
+ end: '3000-01-01T00:00:00.000Z',
254
+ })
255
+
256
+ // Spot-check a single log
257
+ const titleLogs = snap.get('movie/title')
258
+ const terminator = titleLogs!.find(l => l.vl === 'The Terminator')
259
+ expect(terminator).toBeDefined()
260
+ expect(terminator!.en).toBe('m1')
261
+ expect(terminator!.at).toBe('movie/title')
262
+ expect(terminator!.vl).toBe('The Terminator')
263
+ expect(terminator!.ag).toBe('testAgent')
264
+ expect(terminator!.cid).toBeDefined()
265
+ expect(typeof terminator!.cid).toBe('string')
266
+ })
267
+
268
+ it('does not mutate when the underlying thread changes', () => {
269
+ const snap = createEpochSnapshot(db, {
270
+ start: '2023-01-01T00:00:00.000Z',
271
+ end: '3000-01-01T00:00:00.000Z',
272
+ })
273
+
274
+ // Insert a new log into the thread
275
+ const newLog = finalizeApplogForInsert({
276
+ en: 'm5',
277
+ at: 'movie/title',
278
+ vl: 'New Movie',
279
+ ag: 'testAgent',
280
+ ts: new Date(1800000000000).toISOString(),
281
+ pv: null,
282
+ } as ApplogForInsert, {})
283
+ db.insertRaw([newLog])
284
+
285
+ // The snapshot should still have the original 4 movies
286
+ const titleLogs = snap.get('movie/title')
287
+ expect(titleLogs).toBeDefined()
288
+ expect(titleLogs!.length).toBe(4)
289
+ })
290
+ })
291
+
292
+ // ═════════════════════════════════════════════════════════════════
293
+ // resolveTimeSpec tests
294
+ // ═════════════════════════════════════════════════════════════════
295
+
296
+ describe('resolveTimeSpec', () => {
297
+ // Use getter functions so thread/db is resolved during test execution,
298
+ // not at describe-time (before beforeEach populates db).
299
+ const getThread = () => db
300
+
301
+ it('resolves -1 to the earliest log timestamp', () => {
302
+ const result = resolveTimeSpec(-1, getThread(), new Date('2025-01-01'))
303
+ expect(result).toBe(allLogs[0].ts)
304
+ })
305
+
306
+ it('resolves 0 to now', () => {
307
+ const now = new Date('2025-06-15T12:00:00.000Z')
308
+ const result = resolveTimeSpec(0, getThread(), now)
309
+ expect(result).toBe(now.toISOString())
310
+ })
311
+
312
+ it('resolves -1 to now for an empty thread', () => {
313
+ const empty = ThreadInMemory.fromArray([], 'empty')
314
+ const now = new Date('2025-06-15T12:00:00.000Z')
315
+ const result = resolveTimeSpec(-1, empty, now)
316
+ expect(result).toBe(now.toISOString())
317
+ })
318
+
319
+ it('resolves "now" to the given now date', () => {
320
+ const now = new Date('2025-06-15T12:00:00.000Z')
321
+ const result = resolveTimeSpec('now', getThread(), now)
322
+ expect(result).toBe(now.toISOString())
323
+ })
324
+
325
+ it('resolves "-1d" to 1 day before now', () => {
326
+ const now = new Date('2025-06-15T12:00:00.000Z')
327
+ const result = resolveTimeSpec('-1d', getThread(), now)
328
+ const expected = new Date('2025-06-14T12:00:00.000Z').toISOString()
329
+ expect(result).toBe(expected)
330
+ })
331
+
332
+ it('resolves "-2h" to 2 hours before now', () => {
333
+ const now = new Date('2025-06-15T12:00:00.000Z')
334
+ const result = resolveTimeSpec('-2h', getThread(), now)
335
+ const expected = new Date('2025-06-15T10:00:00.000Z').toISOString()
336
+ expect(result).toBe(expected)
337
+ })
338
+
339
+ it('resolves "-30m" to 30 minutes before now', () => {
340
+ const now = new Date('2025-06-15T12:00:00.000Z')
341
+ const result = resolveTimeSpec('-30m', getThread(), now)
342
+ const expected = new Date('2025-06-15T11:30:00.000Z').toISOString()
343
+ expect(result).toBe(expected)
344
+ })
345
+
346
+ it('resolves "-5s" to 5 seconds before now', () => {
347
+ const now = new Date('2025-06-15T12:00:00.000Z')
348
+ const result = resolveTimeSpec('-5s', getThread(), now)
349
+ const expected = new Date('2025-06-15T11:59:55.000Z').toISOString()
350
+ expect(result).toBe(expected)
351
+ })
352
+
353
+ it('resolves "+1d" to 1 day after now', () => {
354
+ const now = new Date('2025-06-15T12:00:00.000Z')
355
+ const result = resolveTimeSpec('+1d', getThread(), now)
356
+ const expected = new Date('2025-06-16T12:00:00.000Z').toISOString()
357
+ expect(result).toBe(expected)
358
+ })
359
+
360
+ it('resolves ISO string as-is', () => {
361
+ const result = resolveTimeSpec('2024-12-25T00:00:00.000Z', getThread(), new Date())
362
+ expect(result).toBe('2024-12-25T00:00:00.000Z')
363
+ })
364
+
365
+ it('resolves positive number as Unix timestamp', () => {
366
+ const ts = 1700000000000
367
+ const result = resolveTimeSpec(ts, getThread(), new Date())
368
+ expect(result).toBe(new Date(ts).toISOString())
369
+ })
370
+ })
371
+
372
+ // ═════════════════════════════════════════════════════════════════
373
+ // serializeEpochSnapshot tests
374
+ // ═════════════════════════════════════════════════════════════════
375
+
376
+ describe('serializeEpochSnapshot', () => {
377
+ it('produces a serializable object with format marker and metadata', () => {
378
+ const snap = createEpochSnapshot(db, {
379
+ start: '2023-01-01T00:00:00.000Z',
380
+ end: '3000-01-01T00:00:00.000Z',
381
+ })
382
+ const serialized = serializeEpochSnapshot(snap, {
383
+ attribute: 'movie/title',
384
+ timeRange: { start: '2023-01-01T00:00:00.000Z', end: '3000-01-01T00:00:00.000Z' },
385
+ })
386
+
387
+ expect(serialized.format).toBe('wovin-epoch-snapshot-v1')
388
+ expect(serialized.attribute).toBe('movie/title')
389
+ expect(serialized.logCount).toBe(allLogs.length)
390
+ expect(typeof serialized.createdAt).toBe('string')
391
+ expect(serialized.groups['movie/title']).toBeDefined()
392
+ expect(serialized.groups['movie/title'].length).toBe(4)
393
+ })
394
+
395
+ it('includes all attribute groups in the serialized output', () => {
396
+ const snap = createEpochSnapshot(db, {
397
+ start: '2023-01-01T00:00:00.000Z',
398
+ end: '3000-01-01T00:00:00.000Z',
399
+ })
400
+ const serialized = serializeEpochSnapshot(snap, {
401
+ attribute: 'movie/cast',
402
+ timeRange: { start: '2023-01-01T00:00:00.000Z', end: '3000-01-01T00:00:00.000Z' },
403
+ })
404
+
405
+ expect(Object.keys(serialized.groups).sort()).toEqual([
406
+ 'movie/cast',
407
+ 'movie/director',
408
+ 'movie/title',
409
+ 'movie/year',
410
+ 'person/name',
411
+ ])
412
+ })
413
+ })
414
+
415
+ // ═════════════════════════════════════════════════════════════════
416
+ // purgeHistory tests
417
+ // ═════════════════════════════════════════════════════════════════
418
+
419
+ describe('purgeHistory', () => {
420
+ it('purges all but the latest log per entity for the target attribute', async () => {
421
+ // Create a thread with multiple block/content entries per entity
422
+ const logs = makeApplogs([
423
+ { en: 'b1', at: 'block/content', vl: 'v1', ag: 'testAgent' },
424
+ { en: 'b1', at: 'block/content', vl: 'v2', ag: 'testAgent' },
425
+ { en: 'b1', at: 'block/content', vl: 'v3', ag: 'testAgent' },
426
+ { en: 'b2', at: 'block/content', vl: 'w1', ag: 'testAgent' },
427
+ { en: 'b2', at: 'block/content', vl: 'w2', ag: 'testAgent' },
428
+ { en: 'b3', at: 'person/name', vl: 'Alice', ag: 'testAgent' }, // different attr — untouched
429
+ ])
430
+ const thread = ThreadInMemory.fromArray(logs, 'purge-test')
431
+
432
+ const result = await purgeHistory(thread, 'block/content', -1, '3000-01-01T00:00:00.000Z', {
433
+ now: new Date('2025-01-01'),
434
+ })
435
+
436
+ // b1 had 3 logs → kept 1, purged 2
437
+ // b2 had 2 logs → kept 1, purged 1
438
+ // b3 had 1 log with different attr → untouched (0, not in target attr)
439
+ expect(result.purgedCount).toBe(3)
440
+ expect(result.keptCount).toBe(2) // b1 winner + b2 winner
441
+
442
+ // Thread should now have: b1/v3, b2/w2, b3/Alice
443
+ expect(thread.applogs.length).toBe(3)
444
+
445
+ const b1Logs = thread.applogs.filter(l => l.en === 'b1' && l.at === 'block/content')
446
+ expect(b1Logs).toHaveLength(1)
447
+ expect(b1Logs[0].vl).toBe('v3')
448
+
449
+ const b2Logs = thread.applogs.filter(l => l.en === 'b2' && l.at === 'block/content')
450
+ expect(b2Logs).toHaveLength(1)
451
+ expect(b2Logs[0].vl).toBe('w2')
452
+ })
453
+
454
+ it('does not touch attributes other than the target', async () => {
455
+ const logs = makeApplogs([
456
+ { en: 'b1', at: 'block/content', vl: 'v1', ag: 'testAgent' },
457
+ { en: 'b1', at: 'block/content', vl: 'v2', ag: 'testAgent' },
458
+ { en: 'b1', at: 'person/name', vl: 'Alice', ag: 'testAgent' },
459
+ ])
460
+ const thread = ThreadInMemory.fromArray(logs, 'purge-other-attrs')
461
+
462
+ await purgeHistory(thread, 'block/content', -1, '3000-01-01T00:00:00.000Z', {
463
+ now: new Date('2025-01-01'),
464
+ })
465
+
466
+ // person/name should still be there
467
+ const nameLogs = thread.applogs.filter(l => l.at === 'person/name')
468
+ expect(nameLogs).toHaveLength(1)
469
+ expect(nameLogs[0].vl).toBe('Alice')
470
+ })
471
+
472
+ it('preserves snapshot via callback before purging', async () => {
473
+ const logs = makeApplogs([
474
+ { en: 'b1', at: 'block/content', vl: 'v1', ag: 'testAgent' },
475
+ { en: 'b1', at: 'block/content', vl: 'v2', ag: 'testAgent' },
476
+ { en: 'b1', at: 'block/content', vl: 'v3', ag: 'testAgent' },
477
+ ])
478
+ const thread = ThreadInMemory.fromArray(logs, 'purge-persist')
479
+
480
+ let captured: unknown = null
481
+ const result = await purgeHistory(thread, 'block/content', -1, '3000-01-01T00:00:00.000Z', {
482
+ now: new Date('2025-01-01'),
483
+ persistSnapshot: async (snap) => {
484
+ captured = snap
485
+ },
486
+ })
487
+
488
+ expect(captured).not.toBeNull()
489
+ const serialized = captured as any
490
+ expect(serialized.format).toBe('wovin-epoch-snapshot-v1')
491
+ expect(serialized.attribute).toBe('block/content')
492
+ // Snapshot includes all attributes, but snapshot has block/content logs
493
+ expect(serialized.groups['block/content'].length).toBe(3)
494
+
495
+ // After purge, only 1 remains
496
+ expect(result.purgedCount).toBe(2)
497
+ expect(result.keptCount).toBe(1)
498
+ })
499
+
500
+ it('handles single log per entity — no purge needed', async () => {
501
+ const logs = makeApplogs([
502
+ { en: 'b1', at: 'block/content', vl: 'v1', ag: 'testAgent' },
503
+ { en: 'b2', at: 'block/content', vl: 'w1', ag: 'testAgent' },
504
+ ])
505
+ const thread = ThreadInMemory.fromArray(logs, 'purge-single')
506
+
507
+ const result = await purgeHistory(thread, 'block/content', -1, '3000-01-01T00:00:00.000Z', {
508
+ now: new Date('2025-01-01'),
509
+ })
510
+
511
+ expect(result.purgedCount).toBe(0)
512
+ expect(result.keptCount).toBe(2)
513
+ expect(thread.applogs.length).toBe(2)
514
+ })
515
+
516
+ it('handles empty thread gracefully', async () => {
517
+ const thread = ThreadInMemory.fromArray([], 'purge-empty')
518
+
519
+ const result = await purgeHistory(thread, 'block/content', -1, '3000-01-01T00:00:00.000Z', {
520
+ now: new Date('2025-01-01'),
521
+ })
522
+
523
+ expect(result.purgedCount).toBe(0)
524
+ expect(result.keptCount).toBe(0)
525
+ expect(result.snapshot.size).toBe(0)
526
+ })
527
+
528
+ it('handles no logs of the target attribute in range', async () => {
529
+ const logs = makeApplogs([
530
+ { en: 'b1', at: 'person/name', vl: 'Alice', ag: 'testAgent' },
531
+ ])
532
+ const thread = ThreadInMemory.fromArray(logs, 'purge-no-target')
533
+
534
+ const result = await purgeHistory(thread, 'block/content', -1, '3000-01-01T00:00:00.000Z', {
535
+ now: new Date('2025-01-01'),
536
+ })
537
+
538
+ expect(result.purgedCount).toBe(0)
539
+ expect(result.keptCount).toBe(0)
540
+ })
541
+
542
+ it('scopes purge to the given time range', async () => {
543
+ const logs = makeApplogs([
544
+ { en: 'b1', at: 'block/content', vl: 'old', ag: 'testAgent' },
545
+ { en: 'b1', at: 'block/content', vl: 'mid', ag: 'testAgent' },
546
+ { en: 'b1', at: 'block/content', vl: 'recent', ag: 'testAgent' },
547
+ ])
548
+ const thread = ThreadInMemory.fromArray(logs, 'purge-range')
549
+
550
+ // Find timestamps to create a slice that includes only 'old' and 'mid'
551
+ const oldLog = thread.applogs[0]
552
+ const midLog = thread.applogs[1]
553
+ const recentLog = thread.applogs[2]
554
+
555
+ // Range: [old.ts, recent.ts) — includes old and mid, excludes recent
556
+ const result = await purgeHistory(thread, 'block/content', oldLog.ts, recentLog.ts, {
557
+ now: new Date('2025-01-01'),
558
+ })
559
+
560
+ // Within [old.ts, recent.ts): old and mid — old is purged, mid is kept
561
+ // recent is outside the range so untouched
562
+ expect(result.purgedCount).toBe(1) // old purged
563
+ expect(result.keptCount).toBe(1) // mid kept as LWW winner
564
+
565
+ // Thread should still have mid and recent
566
+ expect(thread.applogs).toHaveLength(2)
567
+ expect(thread.applogs[0].vl).toBe('mid')
568
+ expect(thread.applogs[1].vl).toBe('recent')
569
+ })
570
+
571
+ it('returns the snapshot and timeRange in the result', async () => {
572
+ const logs = makeApplogs([
573
+ { en: 'b1', at: 'block/content', vl: 'v1', ag: 'testAgent' },
574
+ ])
575
+ const thread = ThreadInMemory.fromArray(logs, 'purge-result')
576
+
577
+ const result = await purgeHistory(thread, 'block/content', -1, '3000-01-01T00:00:00.000Z', {
578
+ now: new Date('2025-01-01'),
579
+ })
580
+
581
+ expect(result.snapshot).toBeDefined()
582
+ expect(result.snapshot.size).toBeGreaterThan(0)
583
+ // resolveTimeSpec(-1, ...) uses the THREAD's earliest log, not the outer dataset
584
+ expect(result.timeRange.start).toBe(logs[0].ts) // -1 → earliest log in THIS thread
585
+ expect(result.timeRange.end).toBe('3000-01-01T00:00:00.000Z')
586
+ })
587
+
588
+ it('throws RangeError for inverted time range', async () => {
589
+ const thread = ThreadInMemory.fromArray([], 'purge-inverted')
590
+ await expect(
591
+ purgeHistory(thread, 'block/content', '2025-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z'),
592
+ ).rejects.toThrow(RangeError)
593
+ })
594
+ })