@wovin/core 0.2.2 → 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/datom-types.d.ts.map +1 -1
- package/dist/applog.js +5 -3
- package/dist/blockstore.js +2 -0
- package/dist/blockstore.js.map +1 -1
- package/dist/{chunk-SHUHRHOT.js → chunk-4MKPGQIM.js} +17 -18
- package/dist/chunk-4MKPGQIM.js.map +1 -0
- package/dist/chunk-64EJIJAJ.js +17 -0
- package/dist/chunk-64EJIJAJ.js.map +1 -0
- package/dist/{chunk-HUIQ54TT.js → chunk-6CSJTSQP.js} +3 -3
- package/dist/chunk-7QEGHKR4.js +17 -0
- package/dist/chunk-7QEGHKR4.js.map +1 -0
- package/dist/{chunk-BLF5MAWU.js → chunk-BIYQEX3N.js} +2 -2
- package/dist/{chunk-6ALNRM3J.js → chunk-H3JNNTVP.js} +21 -8
- package/dist/chunk-H3JNNTVP.js.map +1 -0
- package/dist/{chunk-OC6Z6CQW.js → chunk-H4YVJKB7.js} +2 -2
- package/dist/chunk-ICBK7NC4.js +27 -0
- package/dist/chunk-ICBK7NC4.js.map +1 -0
- package/dist/chunk-N5QPZNKD.js +288 -0
- package/dist/chunk-N5QPZNKD.js.map +1 -0
- package/dist/{chunk-3SUFNJEZ.js → chunk-N7SEGHU4.js} +7 -2
- package/dist/{chunk-3SUFNJEZ.js.map → chunk-N7SEGHU4.js.map} +1 -1
- package/dist/chunk-XF4DWOAE.js +25 -0
- package/dist/chunk-XF4DWOAE.js.map +1 -0
- package/dist/index.js +20 -10
- package/dist/ipfs/car.d.ts.map +1 -1
- package/dist/ipfs.js +4 -4
- package/dist/ipns/gateway-resolver.d.ts +21 -0
- package/dist/ipns/gateway-resolver.d.ts.map +1 -0
- package/dist/ipns/ipns-record.d.ts +46 -7
- package/dist/ipns/ipns-record.d.ts.map +1 -1
- package/dist/ipns/ipns-w3name.d.ts +45 -0
- package/dist/ipns/ipns-w3name.d.ts.map +1 -0
- package/dist/ipns/ipns-watcher.d.ts +190 -0
- package/dist/ipns/ipns-watcher.d.ts.map +1 -0
- package/dist/ipns.d.ts +3 -0
- package/dist/ipns.d.ts.map +1 -1
- package/dist/ipns.js +591 -14
- package/dist/ipns.js.map +1 -1
- package/dist/pubsub/snap-push.d.ts +2 -2
- package/dist/pubsub/snap-push.d.ts.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/adapters/arktype.d.ts +33 -0
- package/dist/viewmodel/adapters/arktype.d.ts.map +1 -0
- package/dist/viewmodel/adapters/arktype.js +7 -0
- package/dist/viewmodel/adapters/arktype.js.map +1 -0
- package/dist/viewmodel/adapters/typebox.d.ts +35 -0
- package/dist/viewmodel/adapters/typebox.d.ts.map +1 -0
- package/dist/viewmodel/adapters/typebox.js +7 -0
- package/dist/viewmodel/adapters/typebox.js.map +1 -0
- package/dist/viewmodel/adapters/typia.d.ts +40 -0
- package/dist/viewmodel/adapters/typia.d.ts.map +1 -0
- package/dist/viewmodel/adapters/typia.js +7 -0
- package/dist/viewmodel/adapters/typia.js.map +1 -0
- package/dist/viewmodel/adapters/zod.d.ts +30 -0
- package/dist/viewmodel/adapters/zod.d.ts.map +1 -0
- package/dist/viewmodel/adapters/zod.js +7 -0
- package/dist/viewmodel/adapters/zod.js.map +1 -0
- package/dist/viewmodel/builder.d.ts +40 -0
- package/dist/viewmodel/builder.d.ts.map +1 -0
- package/dist/viewmodel/examples/all-adapters.d.ts +26 -0
- package/dist/viewmodel/examples/all-adapters.d.ts.map +1 -0
- package/dist/viewmodel/factory.d.ts +38 -0
- package/dist/viewmodel/factory.d.ts.map +1 -0
- package/dist/viewmodel/index.d.ts +10 -0
- package/dist/viewmodel/index.d.ts.map +1 -0
- package/dist/viewmodel/index.js +313 -0
- package/dist/viewmodel/index.js.map +1 -0
- package/dist/viewmodel/schema-adapter.d.ts +16 -0
- package/dist/viewmodel/schema-adapter.d.ts.map +1 -0
- package/dist/viewmodel/types.d.ts +97 -0
- package/dist/viewmodel/types.d.ts.map +1 -0
- package/package.json +29 -3
- package/src/applog/applog-utils.test.ts +43 -1
- package/src/applog/applog-utils.ts +38 -21
- package/src/applog/datom-types.ts +2 -2
- package/src/applog/object-values.test.ts +9 -9
- package/src/ipfs/car.ts +14 -2
- package/src/ipns/gateway-resolver.ts +63 -0
- package/src/ipns/ipns-record.ts +158 -29
- package/src/ipns/ipns-w3name.ts +207 -0
- package/src/ipns/ipns-watcher.ts +608 -0
- package/src/ipns.ts +3 -0
- package/src/pubsub/snap-push.ts +6 -5
- 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/adapters/arktype.ts +44 -0
- package/src/viewmodel/adapters/typebox.ts +59 -0
- package/src/viewmodel/adapters/typia.ts +50 -0
- package/src/viewmodel/adapters/zod.ts +55 -0
- package/src/viewmodel/builder.ts +71 -0
- package/src/viewmodel/examples/all-adapters.ts +206 -0
- package/src/viewmodel/factory.ts +330 -0
- package/src/viewmodel/index.ts +22 -0
- package/src/viewmodel/schema-adapter.ts +27 -0
- package/src/viewmodel/types.ts +152 -0
- package/dist/chunk-22WDFLXO.js +0 -138
- package/dist/chunk-22WDFLXO.js.map +0 -1
- package/dist/chunk-6ALNRM3J.js.map +0 -1
- package/dist/chunk-SHUHRHOT.js.map +0 -1
- /package/dist/{chunk-HUIQ54TT.js.map → chunk-6CSJTSQP.js.map} +0 -0
- /package/dist/{chunk-BLF5MAWU.js.map → chunk-BIYQEX3N.js.map} +0 -0
- /package/dist/{chunk-OC6Z6CQW.js.map → chunk-H4YVJKB7.js.map} +0 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { addDays, addHours, addMinutes, addSeconds, subDays, subHours, subMinutes, subSeconds } from 'date-fns'
|
|
2
|
+
import type { Applog, Attribute, CidString, EntityID, Timestamp } from '../applog/datom-types.ts'
|
|
3
|
+
import { isoDateStrCompare } from '../applog/applog-utils.ts'
|
|
4
|
+
import type { Thread } from '../thread/basic.ts'
|
|
5
|
+
import type { WriteableThread } from '../thread/writeable.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An EpochSnapshot maps every {@link Attribute} present in a time window to the
|
|
9
|
+
* array of {@link Applog}s (from any entity) that carry that attribute.
|
|
10
|
+
*
|
|
11
|
+
* This is **not** a flat array — logs are grouped by attribute first so that
|
|
12
|
+
* consumers can answer questions like "which entities had their `movie/title`
|
|
13
|
+
* updated in this window?" without re-filtering.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* const snap = createEpochSnapshot(thread, {
|
|
18
|
+
* start: '2024-01-01T00:00:00.000Z',
|
|
19
|
+
* end: '2024-06-01T00:00:00.000Z',
|
|
20
|
+
* })
|
|
21
|
+
* for (const [attr, logs] of snap) {
|
|
22
|
+
* console.log(`${attr}: ${logs.length} logs`)
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export type EpochSnapshot = ReadonlyMap<Attribute, readonly Applog[]>
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Options for {@link createEpochSnapshot}.
|
|
30
|
+
*/
|
|
31
|
+
export interface EpochSnapshotOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Start of the time window (inclusive). ISO 8601 string matching the
|
|
34
|
+
* {@link Timestamp} format used on every {@link Applog}.
|
|
35
|
+
*/
|
|
36
|
+
readonly start: Timestamp
|
|
37
|
+
/**
|
|
38
|
+
* End of the time window (exclusive). ISO 8601 string.
|
|
39
|
+
* Logs whose `ts` is **equal to** `end` are excluded.
|
|
40
|
+
*/
|
|
41
|
+
readonly end: Timestamp
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates an {@link EpochSnapshot} — a point-in-time grouping of all app logs
|
|
46
|
+
* within `[start, end)` grouped by their attribute name.
|
|
47
|
+
*
|
|
48
|
+
* The function is a **one-off (snapshot) computation**: it reads the current
|
|
49
|
+
* state of the thread and returns a frozen map. If you need reactivity, wrap
|
|
50
|
+
* the result in a reactive derivation or subscribe to thread changes and
|
|
51
|
+
* re-compute.
|
|
52
|
+
*
|
|
53
|
+
* @param thread - The thread to snapshot.
|
|
54
|
+
* @param options - Time range bounds.
|
|
55
|
+
* @returns A `ReadonlyMap` keyed by attribute name. Each value is the
|
|
56
|
+
* (chronologically sorted) array of applogs whose `at` matches that
|
|
57
|
+
* key and whose `ts` falls inside `[start, end)`.
|
|
58
|
+
*
|
|
59
|
+
* @throws {Error} If `start` is lexically `>=` `end` (empty range).
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```typescript
|
|
63
|
+
* const snap = createEpochSnapshot(thread, {
|
|
64
|
+
* start: '2024-01-01T00:00:00.000Z',
|
|
65
|
+
* end: '2025-01-01T00:00:00.000Z',
|
|
66
|
+
* })
|
|
67
|
+
* // Iterate attribute groups:
|
|
68
|
+
* for (const [attr, logs] of snap) {
|
|
69
|
+
* console.log(attr, logs.length)
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function createEpochSnapshot(
|
|
74
|
+
thread: Thread,
|
|
75
|
+
options: EpochSnapshotOptions,
|
|
76
|
+
): EpochSnapshot {
|
|
77
|
+
const { start, end } = options
|
|
78
|
+
|
|
79
|
+
// Validate range — reject empty windows early.
|
|
80
|
+
if (isoDateStrCompare(start, end, 'asc') >= 0) {
|
|
81
|
+
throw new RangeError(
|
|
82
|
+
`[createEpochSnapshot] Empty time range: start (${start}) >= end (${end})`,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Phase 1: filter by time range, group by attribute.
|
|
87
|
+
// Using a plain object for accumulation because:
|
|
88
|
+
// - we know all keys are strings (Attribute) so no mixed-type Map key issues
|
|
89
|
+
// - bulk construction into a ReadonlyMap at the end gives the desired API shape
|
|
90
|
+
const groups: Record<Attribute, Applog[]> = Object.create(null)
|
|
91
|
+
|
|
92
|
+
for (const log of thread.applogs) {
|
|
93
|
+
// Half-open range: [start, end)
|
|
94
|
+
if (log.ts < start || log.ts >= end) continue
|
|
95
|
+
|
|
96
|
+
let bucket = groups[log.at as Attribute]
|
|
97
|
+
if (!bucket) {
|
|
98
|
+
bucket = []
|
|
99
|
+
groups[log.at as Attribute] = bucket
|
|
100
|
+
}
|
|
101
|
+
bucket.push(log)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Phase 2: sort each group by timestamp (ascending) for deterministic output.
|
|
105
|
+
const keys = Object.keys(groups)
|
|
106
|
+
for (let i = 0; i < keys.length; i++) {
|
|
107
|
+
const attr = keys[i] as Attribute
|
|
108
|
+
groups[attr].sort((a, b) => isoDateStrCompare(a.ts, b.ts, 'asc'))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Phase 3: freeze into a ReadonlyMap.
|
|
112
|
+
const map = new Map<Attribute, readonly Applog[]>()
|
|
113
|
+
for (let i = 0; i < keys.length; i++) {
|
|
114
|
+
const attr = keys[i] as Attribute
|
|
115
|
+
map.set(attr, groups[attr])
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return map
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Time Specification ───────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A flexible time specification used by {@link purgeHistory}.
|
|
125
|
+
*
|
|
126
|
+
* - `-1` (number) — the earliest log's timestamp in the thread ("beginning")
|
|
127
|
+
* - `0` (number) — current time ("now")
|
|
128
|
+
* - Positive number — treated as a Unix millisecond timestamp
|
|
129
|
+
* - `'-1d'`, `'-2h'`, `'-30m'`, `'-5s'` — relative offset **before** now
|
|
130
|
+
* - `'+1d'`, `'2h'` — relative offset **after** now (no sign = forward)
|
|
131
|
+
* - ISO 8601 string — absolute timestamp, used as-is
|
|
132
|
+
* - `'now'` — current time
|
|
133
|
+
*/
|
|
134
|
+
export type TimeSpec = number | string
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve a {@link TimeSpec} to an absolute ISO {@link Timestamp}.
|
|
138
|
+
*
|
|
139
|
+
* @param spec - The time specification to resolve.
|
|
140
|
+
* @param thread - The thread (used to find earliest log when spec is `-1`).
|
|
141
|
+
* @param now - The reference "now" for relative specs.
|
|
142
|
+
* @returns An ISO 8601 timestamp string.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveTimeSpec(
|
|
145
|
+
spec: TimeSpec,
|
|
146
|
+
thread: Thread,
|
|
147
|
+
now: Date,
|
|
148
|
+
): Timestamp {
|
|
149
|
+
if (typeof spec === 'number') {
|
|
150
|
+
if (spec === -1) {
|
|
151
|
+
// Beginning of thread — use earliest log's timestamp, or now if empty
|
|
152
|
+
if (thread.applogs.length === 0) return now.toISOString()
|
|
153
|
+
return thread.applogs[0].ts
|
|
154
|
+
}
|
|
155
|
+
if (spec === 0) return now.toISOString()
|
|
156
|
+
// Treat as Unix milliseconds
|
|
157
|
+
return new Date(spec).toISOString()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// String spec
|
|
161
|
+
if (spec === 'now') return now.toISOString()
|
|
162
|
+
|
|
163
|
+
// Relative time: -1d, -2h, -30m, +1d, 2h, 0d, etc.
|
|
164
|
+
const relMatch = spec.match(/^([+-]?\d+)([dhms])$/)
|
|
165
|
+
if (relMatch) {
|
|
166
|
+
const amount = parseInt(relMatch[1], 10)
|
|
167
|
+
const unit = relMatch[2]
|
|
168
|
+
let result: Date
|
|
169
|
+
if (amount < 0) {
|
|
170
|
+
const abs = Math.abs(amount)
|
|
171
|
+
switch (unit) {
|
|
172
|
+
case 'd': result = subDays(now, abs); break
|
|
173
|
+
case 'h': result = subHours(now, abs); break
|
|
174
|
+
case 'm': result = subMinutes(now, abs); break
|
|
175
|
+
case 's': result = subSeconds(now, abs); break
|
|
176
|
+
default: result = now
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
switch (unit) {
|
|
180
|
+
case 'd': result = addDays(now, amount); break
|
|
181
|
+
case 'h': result = addHours(now, amount); break
|
|
182
|
+
case 'm': result = addMinutes(now, amount); break
|
|
183
|
+
case 's': result = addSeconds(now, amount); break
|
|
184
|
+
default: result = now
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return result.toISOString()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Fall through — assume it's an absolute ISO string
|
|
191
|
+
return spec
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── Serialization ────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A JSON-serializable representation of an {@link EpochSnapshot}, safe for
|
|
198
|
+
* writing to disk, sending over the wire, or storing in IPFS.
|
|
199
|
+
*/
|
|
200
|
+
export interface SerializedEpochSnapshot {
|
|
201
|
+
/** Format marker for versioning */
|
|
202
|
+
readonly format: 'wovin-epoch-snapshot-v1'
|
|
203
|
+
/** The attribute whose history was captured / purged */
|
|
204
|
+
readonly attribute: string
|
|
205
|
+
/** The time range this snapshot covers */
|
|
206
|
+
readonly timeRange: { readonly start: Timestamp; readonly end: Timestamp }
|
|
207
|
+
/** When this snapshot was created */
|
|
208
|
+
readonly createdAt: Timestamp
|
|
209
|
+
/** Total number of applogs across all groups */
|
|
210
|
+
readonly logCount: number
|
|
211
|
+
/**
|
|
212
|
+
* The grouped applogs. Each key is an attribute name; the value is the
|
|
213
|
+
* array of applogs that carried that attribute within the time window.
|
|
214
|
+
*/
|
|
215
|
+
readonly groups: Record<string, readonly Applog[]>
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Converts an {@link EpochSnapshot} into a {@link SerializedEpochSnapshot}
|
|
220
|
+
* that can be persisted as JSON.
|
|
221
|
+
*
|
|
222
|
+
* @param snapshot - The live snapshot to serialize.
|
|
223
|
+
* @param metadata - Identifying metadata to attach.
|
|
224
|
+
* @returns A plain, JSON-safe object.
|
|
225
|
+
*/
|
|
226
|
+
export function serializeEpochSnapshot(
|
|
227
|
+
snapshot: EpochSnapshot,
|
|
228
|
+
metadata: {
|
|
229
|
+
/** The attribute this purge targets */
|
|
230
|
+
readonly attribute: string
|
|
231
|
+
/** The time range of the snapshot */
|
|
232
|
+
readonly timeRange: { readonly start: Timestamp; readonly end: Timestamp }
|
|
233
|
+
},
|
|
234
|
+
): SerializedEpochSnapshot {
|
|
235
|
+
const groups: Record<string, readonly Applog[]> = {}
|
|
236
|
+
let logCount = 0
|
|
237
|
+
for (const [attr, logs] of snapshot) {
|
|
238
|
+
groups[attr] = logs
|
|
239
|
+
logCount += logs.length
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
format: 'wovin-epoch-snapshot-v1',
|
|
244
|
+
attribute: metadata.attribute,
|
|
245
|
+
timeRange: metadata.timeRange,
|
|
246
|
+
createdAt: new Date().toISOString(),
|
|
247
|
+
logCount,
|
|
248
|
+
groups,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── History Purge ────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Options for {@link purgeHistory}.
|
|
256
|
+
*/
|
|
257
|
+
export interface PurgeHistoryOptions {
|
|
258
|
+
/**
|
|
259
|
+
* Callback invoked with the serialized snapshot **before** any logs are
|
|
260
|
+
* purged. Use this to persist the snapshot to a file, database, IPFS, or
|
|
261
|
+
* any other storage you choose.
|
|
262
|
+
*
|
|
263
|
+
* If omitted, the snapshot is created and returned in the result but not
|
|
264
|
+
* automatically persisted anywhere.
|
|
265
|
+
*/
|
|
266
|
+
persistSnapshot?: (serialized: SerializedEpochSnapshot) => Promise<void> | void
|
|
267
|
+
/**
|
|
268
|
+
* Override "now" for deterministic tests. Defaults to `new Date()`.
|
|
269
|
+
*/
|
|
270
|
+
now?: Date
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The result of a successful {@link purgeHistory} call.
|
|
275
|
+
*/
|
|
276
|
+
export interface PurgeHistoryResult {
|
|
277
|
+
/**
|
|
278
|
+
* The {@link EpochSnapshot} that was taken **before** the purge.
|
|
279
|
+
* Contains all logs (across all attributes) within the time window, even
|
|
280
|
+
* though only one attribute was purged.
|
|
281
|
+
*/
|
|
282
|
+
readonly snapshot: EpochSnapshot
|
|
283
|
+
/** Number of applogs that were removed from the thread. */
|
|
284
|
+
readonly purgedCount: number
|
|
285
|
+
/** Number of applogs kept as LWW winners for the purged attribute. */
|
|
286
|
+
readonly keptCount: number
|
|
287
|
+
/** The resolved time range that was processed. */
|
|
288
|
+
readonly timeRange: { readonly start: Timestamp; readonly end: Timestamp }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* For a given **attribute** and **time range**:
|
|
293
|
+
*
|
|
294
|
+
* 1. Creates an {@link EpochSnapshot} capturing all app logs within
|
|
295
|
+
* `[start, end)` across **all** attributes.
|
|
296
|
+
* 2. Calls `options.persistSnapshot` with a serialized copy of the snapshot
|
|
297
|
+
* (so you can store the pre-purge state before any data is removed).
|
|
298
|
+
* 3. For the target attribute, groups the captured logs by entity and keeps
|
|
299
|
+
* only the **latest** (last-write-wins) applog per `(en, at)` pair.
|
|
300
|
+
* All older logs for that pair are purged from the thread.
|
|
301
|
+
*
|
|
302
|
+
* Logs for **other** attributes are never touched.
|
|
303
|
+
*
|
|
304
|
+
* @param thread - A writable thread to purge from.
|
|
305
|
+
* @param attribute - Only logs carrying this attribute are candidates for
|
|
306
|
+
* removal. Logs with other attributes are left untouched.
|
|
307
|
+
* @param start - Start of the time window (see {@link TimeSpec}).
|
|
308
|
+
* @param end - End of the time window, exclusive (see {@link TimeSpec}).
|
|
309
|
+
* @param options - Optional persistence callback and time override.
|
|
310
|
+
* @returns A {@link PurgeHistoryResult} with the snapshot and counts.
|
|
311
|
+
*
|
|
312
|
+
* @throws {RangeError} If the resolved `start` is not before `end`.
|
|
313
|
+
*
|
|
314
|
+
* @example
|
|
315
|
+
* ```typescript
|
|
316
|
+
* // Purge all but the latest block/content per entity from the beginning
|
|
317
|
+
* // up to yesterday, saving a snapshot first.
|
|
318
|
+
* const result = await purgeHistory(thread, 'block/content', -1, '-1d', {
|
|
319
|
+
* persistSnapshot: async (snap) => {
|
|
320
|
+
* await writeFile('purge-archive.json', JSON.stringify(snap, null, 2))
|
|
321
|
+
* },
|
|
322
|
+
* })
|
|
323
|
+
* console.log(`Purged ${result.purgedCount}, kept ${result.keptCount}`)
|
|
324
|
+
* ```
|
|
325
|
+
*/
|
|
326
|
+
export async function purgeHistory(
|
|
327
|
+
thread: WriteableThread,
|
|
328
|
+
attribute: Attribute,
|
|
329
|
+
start: TimeSpec,
|
|
330
|
+
end: TimeSpec,
|
|
331
|
+
options?: PurgeHistoryOptions,
|
|
332
|
+
): Promise<PurgeHistoryResult> {
|
|
333
|
+
const now = options?.now ?? new Date()
|
|
334
|
+
|
|
335
|
+
// 1. Resolve time specs
|
|
336
|
+
const resolvedStart = resolveTimeSpec(start, thread, now)
|
|
337
|
+
const resolvedEnd = resolveTimeSpec(end, thread, now)
|
|
338
|
+
const timeRange = { start: resolvedStart, end: resolvedEnd }
|
|
339
|
+
|
|
340
|
+
// 2. Create full EpochSnapshot for the window
|
|
341
|
+
const snapshot = createEpochSnapshot(thread, timeRange)
|
|
342
|
+
|
|
343
|
+
// 3. Persist snapshot (user-supplied callback — e.g. write to file, IPFS, …)
|
|
344
|
+
if (options?.persistSnapshot) {
|
|
345
|
+
const serialized = serializeEpochSnapshot(snapshot, {
|
|
346
|
+
attribute,
|
|
347
|
+
timeRange,
|
|
348
|
+
})
|
|
349
|
+
await options.persistSnapshot(serialized)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 4. Collect CIDs to purge for the target attribute
|
|
353
|
+
const targetLogs = snapshot.get(attribute)
|
|
354
|
+
if (!targetLogs || targetLogs.length === 0) {
|
|
355
|
+
return { snapshot, purgedCount: 0, keptCount: 0, timeRange }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Group by entity ID (at is the target attribute, so en is the discriminator)
|
|
359
|
+
const byEntity = new Map<EntityID, Applog[]>()
|
|
360
|
+
for (const log of targetLogs) {
|
|
361
|
+
let bucket = byEntity.get(log.en)
|
|
362
|
+
if (!bucket) {
|
|
363
|
+
bucket = []
|
|
364
|
+
byEntity.set(log.en, bucket)
|
|
365
|
+
}
|
|
366
|
+
bucket.push(log)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const cidsToPurge: CidString[] = []
|
|
370
|
+
let keptCount = 0
|
|
371
|
+
|
|
372
|
+
for (const [, logs] of byEntity) {
|
|
373
|
+
// Logs are sorted by ts ascending (createEpochSnapshot guarantees).
|
|
374
|
+
// The LWW winner is the LAST log. All earlier logs are stale.
|
|
375
|
+
if (logs.length <= 1) {
|
|
376
|
+
keptCount += logs.length
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Keep the latest (last in the sorted array), drop the rest
|
|
381
|
+
const winner = logs[logs.length - 1]
|
|
382
|
+
keptCount += 1
|
|
383
|
+
for (let i = 0; i < logs.length - 1; i++) {
|
|
384
|
+
cidsToPurge.push(logs[i].cid)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 5. Execute purge on the thread
|
|
389
|
+
const purgedCount = thread.purge(cidsToPurge)
|
|
390
|
+
|
|
391
|
+
return { snapshot, purgedCount, keptCount, timeRange }
|
|
392
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ApplogValue } from '../../applog/datom-types.ts'
|
|
2
|
+
import type { ISchemaAdapter, VMAttributeDef } from '../types.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pattern for creating a schema adapter from an ArkType schema.
|
|
6
|
+
*
|
|
7
|
+
* ArkType is NOT a runtime dependency of @wovin/core, so this adapter
|
|
8
|
+
* works by accepting the ArkType type object directly.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { type } from 'arktype'
|
|
13
|
+
* import { createArkTypeAdapter } from '@wovin/core/viewmodel/adapters/arktype'
|
|
14
|
+
*
|
|
15
|
+
* const MySchema = type({
|
|
16
|
+
* name: 'string',
|
|
17
|
+
* age: 'number | undefined',
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* const adapter = createArkTypeAdapter(MySchema, 'myEntity')
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function createArkTypeAdapter<T extends Record<string, ApplogValue>>(
|
|
24
|
+
_arkTypeSchema: { infer: T; (input: unknown): T | string },
|
|
25
|
+
entityPrefix: string,
|
|
26
|
+
options?: {
|
|
27
|
+
/** Override at-paths for specific attributes */
|
|
28
|
+
atOverrides?: Record<string, string>
|
|
29
|
+
/** Attribute definitions (must be provided for ArkType since we can't introspect the type) */
|
|
30
|
+
attributes: VMAttributeDef[]
|
|
31
|
+
/** Default values */
|
|
32
|
+
defaults?: Partial<T>
|
|
33
|
+
},
|
|
34
|
+
): ISchemaAdapter<T> {
|
|
35
|
+
return {
|
|
36
|
+
getAttributeDefs: () => options?.attributes ?? [],
|
|
37
|
+
getDefaults: () => (options?.defaults ?? {}) as Partial<T>,
|
|
38
|
+
getEntityPrefix: () => entityPrefix,
|
|
39
|
+
createValidator: () => (value: unknown): value is T => {
|
|
40
|
+
const result = _arkTypeSchema(value as any)
|
|
41
|
+
return typeof result !== 'string'
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ApplogValue } from '../../applog/datom-types.ts'
|
|
2
|
+
import type { ISchemaAdapter, VMAttributeDef } from '../types.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create a schema adapter from a TypeBox object schema.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { Type } from '@sinclair/typebox'
|
|
10
|
+
* import { createTypeBoxAdapter } from '@wovin/core/viewmodel/adapters/typebox'
|
|
11
|
+
*
|
|
12
|
+
* const MySchema = Type.Object({
|
|
13
|
+
* name: Type.String(),
|
|
14
|
+
* age: Type.Optional(Type.Number()),
|
|
15
|
+
* })
|
|
16
|
+
*
|
|
17
|
+
* const adapter = createTypeBoxAdapter(MySchema, 'myEntity')
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* The type parameter `T` is the inferred static type of the schema.
|
|
21
|
+
* Can pass a compiled validator separately if runtime validation is needed.
|
|
22
|
+
*/
|
|
23
|
+
export function createTypeBoxAdapter<T extends Record<string, ApplogValue>>(
|
|
24
|
+
typeBoxSchema: { properties: Record<string, any>; type?: string },
|
|
25
|
+
entityPrefix: string,
|
|
26
|
+
options?: {
|
|
27
|
+
/** Override at-paths for specific attributes */
|
|
28
|
+
atOverrides?: Record<string, string>
|
|
29
|
+
/** Default values */
|
|
30
|
+
defaults?: Partial<T>
|
|
31
|
+
/** Property name to at-path mapping function */
|
|
32
|
+
toAtPath?: (entityPrefix: string, attrName: string) => string
|
|
33
|
+
/** A pre-compiled validator function (e.g. from TypeCompiler.Compile) */
|
|
34
|
+
validator?: (value: unknown) => value is T
|
|
35
|
+
},
|
|
36
|
+
): ISchemaAdapter<T> {
|
|
37
|
+
const properties = typeBoxSchema.properties as Record<string, any>
|
|
38
|
+
const toAtPath = options?.toAtPath ?? ((prefix, name) => `${prefix}/${name}`)
|
|
39
|
+
|
|
40
|
+
const attributeDefs: VMAttributeDef[] = Object.entries(properties).map(([name, propSchema]: [string, any]) => {
|
|
41
|
+
const isOptional = propSchema.optional ?? false
|
|
42
|
+
const wovinAtName = propSchema.wovinAtName
|
|
43
|
+
const atPath = options?.atOverrides?.[name] ?? wovinAtName ?? toAtPath(entityPrefix, name)
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
name,
|
|
47
|
+
atPath,
|
|
48
|
+
optional: isOptional,
|
|
49
|
+
defaultValue: propSchema.default,
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
getAttributeDefs: () => attributeDefs,
|
|
55
|
+
getDefaults: () => (options?.defaults ?? {}) as Partial<T>,
|
|
56
|
+
getEntityPrefix: () => entityPrefix,
|
|
57
|
+
createValidator: options?.validator ? () => options.validator! : undefined,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ApplogValue } from '../../applog/datom-types.ts'
|
|
2
|
+
import type { ISchemaAdapter, VMAttributeDef } from '../types.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pattern for creating a schema adapter from a Typia-generated schema.
|
|
6
|
+
*
|
|
7
|
+
* Typia is NOT a runtime dependency of @wovin/core, so this adapter
|
|
8
|
+
* works by accepting the Typia type object directly along with the
|
|
9
|
+
* attribute definitions (which must be provided separately since
|
|
10
|
+
* Typia doesn't expose runtime property introspection by default).
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* ```ts
|
|
14
|
+
* import typia from 'typia'
|
|
15
|
+
* import { createTypiaAdapter } from '@wovin/core/viewmodel/adapters/typia'
|
|
16
|
+
*
|
|
17
|
+
* interface MyEntity { name: string; age?: number }
|
|
18
|
+
*
|
|
19
|
+
* const adapter = createTypiaAdapter<MyEntity>(
|
|
20
|
+
* typia.createValidate<MyEntity>(),
|
|
21
|
+
* 'myEntity',
|
|
22
|
+
* {
|
|
23
|
+
* attributes: [
|
|
24
|
+
* { name: 'name', atPath: 'myEntity/name', optional: false },
|
|
25
|
+
* { name: 'age', atPath: 'myEntity/age', optional: true },
|
|
26
|
+
* ],
|
|
27
|
+
* },
|
|
28
|
+
* )
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function createTypiaAdapter<T extends Record<string, ApplogValue>>(
|
|
32
|
+
_typiaValidator: (input: unknown) => { success: boolean; data: T | null; errors?: any[] },
|
|
33
|
+
entityPrefix: string,
|
|
34
|
+
options: {
|
|
35
|
+
/** Attribute definitions (required for Typia since property introspection is compile-time) */
|
|
36
|
+
attributes: VMAttributeDef[]
|
|
37
|
+
/** Default values */
|
|
38
|
+
defaults?: Partial<T>
|
|
39
|
+
},
|
|
40
|
+
): ISchemaAdapter<T> {
|
|
41
|
+
return {
|
|
42
|
+
getAttributeDefs: () => options.attributes,
|
|
43
|
+
getDefaults: () => (options?.defaults ?? {}) as Partial<T>,
|
|
44
|
+
getEntityPrefix: () => entityPrefix,
|
|
45
|
+
createValidator: () => (value: unknown): value is T => {
|
|
46
|
+
const result = _typiaValidator(value)
|
|
47
|
+
return result.success
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ApplogValue } from '../../applog/datom-types.ts'
|
|
2
|
+
import type { ISchemaAdapter, VMAttributeDef } from '../types.ts'
|
|
3
|
+
import type { ZodObject, ZodType, ZodTypeAny } from 'zod'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a schema adapter from a Zod object schema.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { z } from 'zod'
|
|
11
|
+
* import { createZodAdapter } from '@wovin/core/viewmodel/adapters/zod'
|
|
12
|
+
*
|
|
13
|
+
* const MySchema = z.object({
|
|
14
|
+
* name: z.string(),
|
|
15
|
+
* age: z.number().optional(),
|
|
16
|
+
* })
|
|
17
|
+
*
|
|
18
|
+
* const adapter = createZodAdapter(MySchema, 'myEntity')
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* The type parameter `T` is the inferred type of the schema.
|
|
22
|
+
*/
|
|
23
|
+
export function createZodAdapter<T extends Record<string, ApplogValue> = Record<string, ApplogValue>>(
|
|
24
|
+
zodSchema: ZodObject<Record<string, ZodTypeAny>>,
|
|
25
|
+
entityPrefix: string,
|
|
26
|
+
options?: {
|
|
27
|
+
/** Override at-paths for specific attributes */
|
|
28
|
+
atOverrides?: Record<string, string>
|
|
29
|
+
/** Default values */
|
|
30
|
+
defaults?: Partial<T>
|
|
31
|
+
/** Property name to at-path mapping function */
|
|
32
|
+
toAtPath?: (entityPrefix: string, attrName: string) => string
|
|
33
|
+
},
|
|
34
|
+
): ISchemaAdapter<T> {
|
|
35
|
+
const shape = zodSchema.shape as Record<string, ZodTypeAny>
|
|
36
|
+
const toAtPath = options?.toAtPath ?? ((prefix, name) => `${prefix}/${name}`)
|
|
37
|
+
|
|
38
|
+
const attributeDefs: VMAttributeDef[] = Object.entries(shape).map(([name, fieldSchema]: [string, ZodTypeAny]) => {
|
|
39
|
+
const isOptional = fieldSchema.isOptional() || fieldSchema.isNullable()
|
|
40
|
+
const atPath = options?.atOverrides?.[name] ?? toAtPath(entityPrefix, name)
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
name,
|
|
44
|
+
atPath,
|
|
45
|
+
optional: isOptional,
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
getAttributeDefs: () => attributeDefs,
|
|
51
|
+
getDefaults: () => (options?.defaults ?? {}) as Partial<T>,
|
|
52
|
+
getEntityPrefix: () => entityPrefix,
|
|
53
|
+
createValidator: () => (value: unknown): value is T => zodSchema.safeParse(value).success,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ApplogForInsertOptionalAgent, ApplogValue, EntityID } from '../applog/datom-types.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generic ObjectBuilder for type-safe entity creation and updates.
|
|
5
|
+
*
|
|
6
|
+
* Provides a fluent API to build a set of applogs for an entity.
|
|
7
|
+
* Modeled after the note3 builder pattern.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* ```ts
|
|
11
|
+
* ObjectBuilder.create({ name: 'foo' })
|
|
12
|
+
* .update({ type: 'bar' })
|
|
13
|
+
* .build(thread)
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export class ObjectBuilder<
|
|
17
|
+
TARGET extends Record<string, ApplogValue> = Record<string, ApplogValue>,
|
|
18
|
+
> {
|
|
19
|
+
constructor(
|
|
20
|
+
private _data: Partial<TARGET>,
|
|
21
|
+
public en: EntityID | null = null,
|
|
22
|
+
private _atPrefix: string | null = null,
|
|
23
|
+
private _atOverrides: Partial<Record<keyof TARGET, string>> = {},
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a new builder with initial data.
|
|
28
|
+
*/
|
|
29
|
+
static create<TARGET extends Record<string, ApplogValue>>(
|
|
30
|
+
init: Partial<TARGET> = {},
|
|
31
|
+
en?: EntityID,
|
|
32
|
+
atPrefix?: string,
|
|
33
|
+
atOverrides?: Partial<Record<keyof TARGET, string>>,
|
|
34
|
+
): ObjectBuilder<TARGET> {
|
|
35
|
+
return new ObjectBuilder<TARGET>(init, en, atPrefix, atOverrides)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Update the data being built.
|
|
40
|
+
*/
|
|
41
|
+
update(updateObj: Partial<TARGET>): this {
|
|
42
|
+
Object.assign(this._data, updateObj)
|
|
43
|
+
return this
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the applogs for this entity, optionally resolving against a thread.
|
|
48
|
+
*/
|
|
49
|
+
build(thread?: {
|
|
50
|
+
insert(applogs: ApplogForInsertOptionalAgent[]): ApplogForInsertOptionalAgent[]
|
|
51
|
+
}): ApplogForInsertOptionalAgent[] {
|
|
52
|
+
const atPrefix = this._atPrefix
|
|
53
|
+
const applogs: ApplogForInsertOptionalAgent[] = []
|
|
54
|
+
|
|
55
|
+
for (const [key, value] of Object.entries(this._data) as [keyof TARGET, ApplogValue][]) {
|
|
56
|
+
if (value === undefined) continue
|
|
57
|
+
const atOverride = this._atOverrides[key]
|
|
58
|
+
const at = atOverride ?? (atPrefix ? `${atPrefix}/${String(key)}` : String(key))
|
|
59
|
+
applogs.push({ en: this.en!, at, vl: value })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return applogs
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get the raw data being built.
|
|
67
|
+
*/
|
|
68
|
+
get data(): Partial<TARGET> {
|
|
69
|
+
return { ...this._data }
|
|
70
|
+
}
|
|
71
|
+
}
|