@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.
- package/dist/applog/applog-utils.d.ts +25 -1
- package/dist/applog/applog-utils.d.ts.map +1 -1
- package/dist/applog.js +5 -3
- package/dist/{chunk-2OXLPZQI.js → chunk-4MKPGQIM.js} +8 -16
- package/dist/chunk-4MKPGQIM.js.map +1 -0
- package/dist/{chunk-VGIACGWX.js → chunk-6CSJTSQP.js} +3 -3
- package/dist/{chunk-WVW4YXB5.js → chunk-BIYQEX3N.js} +2 -2
- package/dist/{chunk-Q4EMPWA3.js → chunk-H3JNNTVP.js} +9 -3
- package/dist/chunk-H3JNNTVP.js.map +1 -0
- package/dist/{chunk-EHO2BFFY.js → chunk-H4YVJKB7.js} +2 -2
- package/dist/chunk-N5QPZNKD.js +288 -0
- package/dist/chunk-N5QPZNKD.js.map +1 -0
- package/dist/{chunk-2PJFLZRC.js → chunk-N7SEGHU4.js} +2 -2
- package/dist/index.js +20 -10
- package/dist/ipfs/car.d.ts.map +1 -1
- package/dist/ipfs.js +4 -4
- package/dist/ipns/ipns-record.d.ts +19 -1
- package/dist/ipns/ipns-record.d.ts.map +1 -1
- package/dist/ipns/ipns-w3name.d.ts +31 -1
- package/dist/ipns/ipns-w3name.d.ts.map +1 -1
- package/dist/ipns/ipns-watcher.d.ts.map +1 -1
- package/dist/ipns.js +118 -21
- package/dist/ipns.js.map +1 -1
- package/dist/pubsub.js +4 -4
- package/dist/query/epoch-snapshot.d.ts +205 -0
- package/dist/query/epoch-snapshot.d.ts.map +1 -0
- package/dist/query.d.ts +1 -0
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +12 -4
- package/dist/retrieve.js +4 -4
- package/dist/thread.js +1 -1
- package/dist/viewmodel/index.js +4 -4
- package/dist/viewmodel/index.js.map +1 -1
- package/package.json +1 -1
- package/src/applog/applog-utils.test.ts +43 -1
- package/src/applog/applog-utils.ts +38 -21
- package/src/applog/object-values.test.ts +9 -9
- package/src/ipfs/car.ts +6 -0
- package/src/ipns/ipns-record.ts +95 -17
- package/src/ipns/ipns-w3name.ts +109 -5
- package/src/ipns/ipns-watcher.ts +4 -3
- package/src/query/epoch-snapshot.test.ts +594 -0
- package/src/query/epoch-snapshot.ts +392 -0
- package/src/query.ts +1 -0
- package/src/viewmodel/factory.ts +2 -2
- package/dist/chunk-2OXLPZQI.js.map +0 -1
- package/dist/chunk-OKXRRWNS.js +0 -138
- package/dist/chunk-OKXRRWNS.js.map +0 -1
- package/dist/chunk-Q4EMPWA3.js.map +0 -1
- /package/dist/{chunk-VGIACGWX.js.map → chunk-6CSJTSQP.js.map} +0 -0
- /package/dist/{chunk-WVW4YXB5.js.map → chunk-BIYQEX3N.js.map} +0 -0
- /package/dist/{chunk-EHO2BFFY.js.map → chunk-H4YVJKB7.js.map} +0 -0
- /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
|
+
})
|