digital-objects 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +476 -0
- package/dist/ai-database-adapter.d.ts +49 -0
- package/dist/ai-database-adapter.d.ts.map +1 -0
- package/dist/ai-database-adapter.js +89 -0
- package/dist/ai-database-adapter.js.map +1 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +72 -0
- package/dist/errors.js.map +1 -0
- package/dist/http-schemas.d.ts +165 -0
- package/dist/http-schemas.d.ts.map +1 -0
- package/dist/http-schemas.js +55 -0
- package/dist/http-schemas.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/linguistic.d.ts +54 -0
- package/dist/linguistic.d.ts.map +1 -0
- package/dist/linguistic.js +226 -0
- package/dist/linguistic.js.map +1 -0
- package/dist/memory-provider.d.ts +46 -0
- package/dist/memory-provider.d.ts.map +1 -0
- package/dist/memory-provider.js +279 -0
- package/dist/memory-provider.js.map +1 -0
- package/dist/ns-client.d.ts +88 -0
- package/dist/ns-client.d.ts.map +1 -0
- package/dist/ns-client.js +253 -0
- package/dist/ns-client.js.map +1 -0
- package/dist/ns-exports.d.ts +23 -0
- package/dist/ns-exports.d.ts.map +1 -0
- package/dist/ns-exports.js +21 -0
- package/dist/ns-exports.js.map +1 -0
- package/dist/ns.d.ts +60 -0
- package/dist/ns.d.ts.map +1 -0
- package/dist/ns.js +818 -0
- package/dist/ns.js.map +1 -0
- package/dist/r2-persistence.d.ts +112 -0
- package/dist/r2-persistence.d.ts.map +1 -0
- package/dist/r2-persistence.js +252 -0
- package/dist/r2-persistence.js.map +1 -0
- package/dist/schema-validation.d.ts +80 -0
- package/dist/schema-validation.d.ts.map +1 -0
- package/dist/schema-validation.js +233 -0
- package/dist/schema-validation.js.map +1 -0
- package/dist/types.d.ts +184 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +26 -0
- package/dist/types.js.map +1 -0
- package/package.json +55 -0
- package/src/ai-database-adapter.test.ts +610 -0
- package/src/ai-database-adapter.ts +189 -0
- package/src/benchmark.test.ts +109 -0
- package/src/errors.ts +91 -0
- package/src/http-schemas.ts +67 -0
- package/src/index.ts +87 -0
- package/src/linguistic.test.ts +1107 -0
- package/src/linguistic.ts +253 -0
- package/src/memory-provider.ts +470 -0
- package/src/ns-client.test.ts +1360 -0
- package/src/ns-client.ts +342 -0
- package/src/ns-exports.ts +23 -0
- package/src/ns.test.ts +1381 -0
- package/src/ns.ts +1215 -0
- package/src/provider.test.ts +675 -0
- package/src/r2-persistence.test.ts +263 -0
- package/src/r2-persistence.ts +367 -0
- package/src/schema-validation.test.ts +167 -0
- package/src/schema-validation.ts +330 -0
- package/src/types.ts +252 -0
- package/test/action-status.test.ts +42 -0
- package/test/batch-limits.test.ts +165 -0
- package/test/docs.test.ts +48 -0
- package/test/errors.test.ts +148 -0
- package/test/http-validation.test.ts +401 -0
- package/test/ns-client-errors.test.ts +208 -0
- package/test/ns-namespace.test.ts +307 -0
- package/test/performance.test.ts +168 -0
- package/test/schema-validation-error.test.ts +213 -0
- package/test/schema-validation.test.ts +440 -0
- package/test/search-escaping.test.ts +359 -0
- package/test/security.test.ts +322 -0
- package/tsconfig.json +10 -0
- package/wrangler.jsonc +16 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import type { R2Bucket, R2Object, R2ObjectBody } from '@cloudflare/workers-types'
|
|
3
|
+
import { createMemoryProvider } from './memory-provider.js'
|
|
4
|
+
import type { DigitalObjectsProvider } from './types.js'
|
|
5
|
+
|
|
6
|
+
// Mock R2 bucket for testing
|
|
7
|
+
function createMockR2Bucket(): R2Bucket & { storage: Map<string, string> } {
|
|
8
|
+
const storage = new Map<string, string>()
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
storage,
|
|
12
|
+
async put(key: string, value: string | ArrayBuffer | ReadableStream) {
|
|
13
|
+
const content = typeof value === 'string' ? value : JSON.stringify(value)
|
|
14
|
+
storage.set(key, content)
|
|
15
|
+
return {
|
|
16
|
+
key,
|
|
17
|
+
version: '1',
|
|
18
|
+
size: content.length,
|
|
19
|
+
etag: 'mock-etag',
|
|
20
|
+
httpEtag: '"mock-etag"',
|
|
21
|
+
checksums: {},
|
|
22
|
+
uploaded: new Date(),
|
|
23
|
+
httpMetadata: {},
|
|
24
|
+
customMetadata: {},
|
|
25
|
+
writeHttpMetadata: () => {},
|
|
26
|
+
} as R2Object
|
|
27
|
+
},
|
|
28
|
+
async get(key: string) {
|
|
29
|
+
const content = storage.get(key)
|
|
30
|
+
if (!content) return null
|
|
31
|
+
return {
|
|
32
|
+
key,
|
|
33
|
+
version: '1',
|
|
34
|
+
size: content.length,
|
|
35
|
+
etag: 'mock-etag',
|
|
36
|
+
httpEtag: '"mock-etag"',
|
|
37
|
+
checksums: {},
|
|
38
|
+
uploaded: new Date(),
|
|
39
|
+
httpMetadata: {},
|
|
40
|
+
customMetadata: {},
|
|
41
|
+
body: new ReadableStream(),
|
|
42
|
+
bodyUsed: false,
|
|
43
|
+
arrayBuffer: async () => new TextEncoder().encode(content).buffer,
|
|
44
|
+
text: async () => content,
|
|
45
|
+
json: async () => JSON.parse(content),
|
|
46
|
+
blob: async () => new Blob([content]),
|
|
47
|
+
writeHttpMetadata: () => {},
|
|
48
|
+
} as R2ObjectBody
|
|
49
|
+
},
|
|
50
|
+
async delete(key: string | string[]) {
|
|
51
|
+
const keys = Array.isArray(key) ? key : [key]
|
|
52
|
+
for (const k of keys) {
|
|
53
|
+
storage.delete(k)
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
async list(options?: { prefix?: string }) {
|
|
57
|
+
const keys = Array.from(storage.keys())
|
|
58
|
+
.filter((k) => !options?.prefix || k.startsWith(options.prefix))
|
|
59
|
+
.map((key) => ({
|
|
60
|
+
key,
|
|
61
|
+
version: '1',
|
|
62
|
+
size: storage.get(key)?.length ?? 0,
|
|
63
|
+
etag: 'mock-etag',
|
|
64
|
+
httpEtag: '"mock-etag"',
|
|
65
|
+
checksums: {},
|
|
66
|
+
uploaded: new Date(),
|
|
67
|
+
httpMetadata: {},
|
|
68
|
+
customMetadata: {},
|
|
69
|
+
}))
|
|
70
|
+
return {
|
|
71
|
+
objects: keys,
|
|
72
|
+
truncated: false,
|
|
73
|
+
cursor: undefined,
|
|
74
|
+
delimitedPrefixes: [],
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
async head(key: string) {
|
|
78
|
+
if (!storage.has(key)) return null
|
|
79
|
+
return {
|
|
80
|
+
key,
|
|
81
|
+
version: '1',
|
|
82
|
+
size: storage.get(key)?.length ?? 0,
|
|
83
|
+
etag: 'mock-etag',
|
|
84
|
+
httpEtag: '"mock-etag"',
|
|
85
|
+
checksums: {},
|
|
86
|
+
uploaded: new Date(),
|
|
87
|
+
httpMetadata: {},
|
|
88
|
+
customMetadata: {},
|
|
89
|
+
writeHttpMetadata: () => {},
|
|
90
|
+
} as R2Object
|
|
91
|
+
},
|
|
92
|
+
createMultipartUpload: vi.fn(),
|
|
93
|
+
resumeMultipartUpload: vi.fn(),
|
|
94
|
+
} as unknown as R2Bucket & { storage: Map<string, string> }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Import the persistence functions
|
|
98
|
+
import {
|
|
99
|
+
createSnapshot,
|
|
100
|
+
restoreSnapshot,
|
|
101
|
+
appendWAL,
|
|
102
|
+
replayWAL,
|
|
103
|
+
compactWAL,
|
|
104
|
+
exportJSONL,
|
|
105
|
+
importJSONL,
|
|
106
|
+
exportToR2,
|
|
107
|
+
importFromR2,
|
|
108
|
+
} from './r2-persistence.js'
|
|
109
|
+
|
|
110
|
+
describe('R2 Persistence', () => {
|
|
111
|
+
let provider: DigitalObjectsProvider
|
|
112
|
+
let r2: R2Bucket & { storage: Map<string, string> }
|
|
113
|
+
|
|
114
|
+
beforeEach(async () => {
|
|
115
|
+
provider = createMemoryProvider()
|
|
116
|
+
r2 = createMockR2Bucket()
|
|
117
|
+
|
|
118
|
+
// Set up some test data
|
|
119
|
+
await provider.defineNoun({ name: 'Post' })
|
|
120
|
+
await provider.defineNoun({ name: 'Author' })
|
|
121
|
+
await provider.defineVerb({ name: 'write' })
|
|
122
|
+
await provider.defineVerb({ name: 'publish' })
|
|
123
|
+
|
|
124
|
+
const author = await provider.create('Author', { name: 'Alice' })
|
|
125
|
+
const post1 = await provider.create('Post', { title: 'First Post', status: 'published' })
|
|
126
|
+
const post2 = await provider.create('Post', { title: 'Second Post', status: 'draft' })
|
|
127
|
+
|
|
128
|
+
await provider.perform('write', author.id, post1.id)
|
|
129
|
+
await provider.perform('write', author.id, post2.id)
|
|
130
|
+
await provider.perform('publish', undefined, post1.id)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('Snapshot', () => {
|
|
134
|
+
it('should create a complete snapshot', async () => {
|
|
135
|
+
const snapshot = await createSnapshot(provider, r2, 'test-ns')
|
|
136
|
+
|
|
137
|
+
expect(r2.storage.has('snapshots/test-ns/latest.json')).toBe(true)
|
|
138
|
+
const stored = JSON.parse(r2.storage.get('snapshots/test-ns/latest.json')!)
|
|
139
|
+
expect(stored.nouns).toHaveLength(2)
|
|
140
|
+
expect(stored.verbs).toHaveLength(2)
|
|
141
|
+
expect(stored.things).toHaveLength(3)
|
|
142
|
+
expect(stored.actions).toHaveLength(3)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('should restore from snapshot', async () => {
|
|
146
|
+
await createSnapshot(provider, r2, 'test-ns')
|
|
147
|
+
|
|
148
|
+
// Create fresh provider
|
|
149
|
+
const newProvider = createMemoryProvider()
|
|
150
|
+
await restoreSnapshot(newProvider, r2, 'test-ns')
|
|
151
|
+
|
|
152
|
+
const nouns = await newProvider.listNouns()
|
|
153
|
+
expect(nouns).toHaveLength(2)
|
|
154
|
+
|
|
155
|
+
const posts = await newProvider.list('Post')
|
|
156
|
+
expect(posts).toHaveLength(2)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('should create timestamped snapshots', async () => {
|
|
160
|
+
const result = await createSnapshot(provider, r2, 'test-ns', { timestamp: true })
|
|
161
|
+
|
|
162
|
+
expect(result.key).toMatch(/snapshots\/test-ns\/\d+\.json/)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe('WAL (Write-Ahead Log)', () => {
|
|
167
|
+
it('should append operations to WAL', async () => {
|
|
168
|
+
await appendWAL(r2, 'test-ns', {
|
|
169
|
+
type: 'create',
|
|
170
|
+
noun: 'Post',
|
|
171
|
+
id: 'test-id',
|
|
172
|
+
data: { title: 'New Post' },
|
|
173
|
+
timestamp: Date.now(),
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const walFiles = await r2.list({ prefix: 'wal/test-ns/' })
|
|
177
|
+
expect(walFiles.objects.length).toBeGreaterThan(0)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('should replay WAL on top of snapshot', async () => {
|
|
181
|
+
// Create snapshot
|
|
182
|
+
await createSnapshot(provider, r2, 'test-ns')
|
|
183
|
+
|
|
184
|
+
// Append some operations
|
|
185
|
+
const timestamp = Date.now()
|
|
186
|
+
await appendWAL(r2, 'test-ns', {
|
|
187
|
+
type: 'create',
|
|
188
|
+
noun: 'Post',
|
|
189
|
+
id: 'wal-post-id',
|
|
190
|
+
data: { title: 'WAL Post' },
|
|
191
|
+
timestamp,
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// Restore snapshot first, then replay WAL on fresh provider
|
|
195
|
+
const newProvider = createMemoryProvider()
|
|
196
|
+
await restoreSnapshot(newProvider, r2, 'test-ns')
|
|
197
|
+
await replayWAL(newProvider, r2, 'test-ns')
|
|
198
|
+
|
|
199
|
+
const posts = await newProvider.list('Post')
|
|
200
|
+
expect(posts).toHaveLength(3) // 2 from snapshot + 1 from WAL
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('should compact WAL after snapshot', async () => {
|
|
204
|
+
// Add multiple WAL entries
|
|
205
|
+
for (let i = 0; i < 10; i++) {
|
|
206
|
+
await appendWAL(r2, 'test-ns', {
|
|
207
|
+
type: 'create',
|
|
208
|
+
noun: 'Post',
|
|
209
|
+
id: `post-${i}`,
|
|
210
|
+
data: { title: `Post ${i}` },
|
|
211
|
+
timestamp: Date.now() + i,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Compact (create snapshot + delete old WAL)
|
|
216
|
+
await createSnapshot(provider, r2, 'test-ns')
|
|
217
|
+
await compactWAL(r2, 'test-ns')
|
|
218
|
+
|
|
219
|
+
const walFiles = await r2.list({ prefix: 'wal/test-ns/' })
|
|
220
|
+
expect(walFiles.objects).toHaveLength(0)
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('JSONL Export/Import', () => {
|
|
225
|
+
it('should export all data as JSONL', async () => {
|
|
226
|
+
const jsonl = await exportJSONL(provider)
|
|
227
|
+
|
|
228
|
+
const lines = jsonl.trim().split('\n')
|
|
229
|
+
expect(lines.length).toBe(10) // 2 nouns + 2 verbs + 3 things + 3 actions
|
|
230
|
+
|
|
231
|
+
// Each line should be valid JSON
|
|
232
|
+
for (const line of lines) {
|
|
233
|
+
expect(() => JSON.parse(line)).not.toThrow()
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('should import from JSONL', async () => {
|
|
238
|
+
const jsonl = await exportJSONL(provider)
|
|
239
|
+
|
|
240
|
+
const newProvider = createMemoryProvider()
|
|
241
|
+
await importJSONL(newProvider, jsonl)
|
|
242
|
+
|
|
243
|
+
const nouns = await newProvider.listNouns()
|
|
244
|
+
expect(nouns).toHaveLength(2)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('should export to R2', async () => {
|
|
248
|
+
await exportToR2(provider, r2, 'exports/test-ns.jsonl')
|
|
249
|
+
|
|
250
|
+
expect(r2.storage.has('exports/test-ns.jsonl')).toBe(true)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('should import from R2', async () => {
|
|
254
|
+
await exportToR2(provider, r2, 'exports/test-ns.jsonl')
|
|
255
|
+
|
|
256
|
+
const newProvider = createMemoryProvider()
|
|
257
|
+
await importFromR2(newProvider, r2, 'exports/test-ns.jsonl')
|
|
258
|
+
|
|
259
|
+
const things = await newProvider.list('Post')
|
|
260
|
+
expect(things).toHaveLength(2)
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
})
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R2 Persistence Layer
|
|
3
|
+
*
|
|
4
|
+
* Provides backup, restore, and export functionality for digital-objects
|
|
5
|
+
* using Cloudflare R2 object storage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
9
|
+
|
|
10
|
+
import type { DigitalObjectsProvider, Noun, Verb, Thing, Action } from './types.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Snapshot data structure
|
|
14
|
+
*/
|
|
15
|
+
export interface Snapshot {
|
|
16
|
+
version: number
|
|
17
|
+
timestamp: number
|
|
18
|
+
namespace: string
|
|
19
|
+
nouns: Noun[]
|
|
20
|
+
verbs: Verb[]
|
|
21
|
+
things: Thing<unknown>[]
|
|
22
|
+
actions: Action<unknown>[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* WAL entry types
|
|
27
|
+
*/
|
|
28
|
+
export type WALEntry =
|
|
29
|
+
| { type: 'defineNoun'; data: Noun; timestamp: number }
|
|
30
|
+
| { type: 'defineVerb'; data: Verb; timestamp: number }
|
|
31
|
+
| { type: 'create'; noun: string; id: string; data: unknown; timestamp: number }
|
|
32
|
+
| { type: 'update'; id: string; data: unknown; timestamp: number }
|
|
33
|
+
| { type: 'delete'; id: string; timestamp: number }
|
|
34
|
+
| {
|
|
35
|
+
type: 'perform'
|
|
36
|
+
verb: string
|
|
37
|
+
subject?: string
|
|
38
|
+
object?: string
|
|
39
|
+
data?: unknown
|
|
40
|
+
timestamp: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SnapshotOptions {
|
|
44
|
+
/** Include timestamp in filename */
|
|
45
|
+
timestamp?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SnapshotResult {
|
|
49
|
+
key: string
|
|
50
|
+
size: number
|
|
51
|
+
timestamp: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create a complete snapshot of all data
|
|
56
|
+
*/
|
|
57
|
+
export async function createSnapshot(
|
|
58
|
+
provider: DigitalObjectsProvider,
|
|
59
|
+
r2: R2Bucket,
|
|
60
|
+
namespace: string,
|
|
61
|
+
options?: SnapshotOptions
|
|
62
|
+
): Promise<SnapshotResult> {
|
|
63
|
+
const timestamp = Date.now()
|
|
64
|
+
|
|
65
|
+
// Collect all data
|
|
66
|
+
const [nouns, verbs] = await Promise.all([provider.listNouns(), provider.listVerbs()])
|
|
67
|
+
|
|
68
|
+
// Collect things for each noun type
|
|
69
|
+
const things: Thing<unknown>[] = []
|
|
70
|
+
for (const noun of nouns) {
|
|
71
|
+
const nounThings = await provider.list(noun.name)
|
|
72
|
+
things.push(...nounThings)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Collect all actions
|
|
76
|
+
const actions = await provider.listActions()
|
|
77
|
+
|
|
78
|
+
const snapshot: Snapshot = {
|
|
79
|
+
version: 1,
|
|
80
|
+
timestamp,
|
|
81
|
+
namespace,
|
|
82
|
+
nouns,
|
|
83
|
+
verbs,
|
|
84
|
+
things,
|
|
85
|
+
actions,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const content = JSON.stringify(snapshot, null, 2)
|
|
89
|
+
const key = options?.timestamp
|
|
90
|
+
? `snapshots/${namespace}/${timestamp}.json`
|
|
91
|
+
: `snapshots/${namespace}/latest.json`
|
|
92
|
+
|
|
93
|
+
await r2.put(key, content)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
key,
|
|
97
|
+
size: content.length,
|
|
98
|
+
timestamp,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Restore provider state from a snapshot
|
|
104
|
+
*/
|
|
105
|
+
export async function restoreSnapshot(
|
|
106
|
+
provider: DigitalObjectsProvider,
|
|
107
|
+
r2: R2Bucket,
|
|
108
|
+
namespace: string,
|
|
109
|
+
snapshotKey?: string
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
const key = snapshotKey ?? `snapshots/${namespace}/latest.json`
|
|
112
|
+
const obj = await r2.get(key)
|
|
113
|
+
|
|
114
|
+
if (!obj) {
|
|
115
|
+
throw new Error(`Snapshot not found: ${key}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const snapshot = await obj.json<Snapshot>()
|
|
119
|
+
|
|
120
|
+
// Restore nouns
|
|
121
|
+
for (const noun of snapshot.nouns) {
|
|
122
|
+
await provider.defineNoun({
|
|
123
|
+
name: noun.name,
|
|
124
|
+
singular: noun.singular,
|
|
125
|
+
plural: noun.plural,
|
|
126
|
+
description: noun.description,
|
|
127
|
+
schema: noun.schema,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Restore verbs
|
|
132
|
+
for (const verb of snapshot.verbs) {
|
|
133
|
+
await provider.defineVerb({
|
|
134
|
+
name: verb.name,
|
|
135
|
+
action: verb.action,
|
|
136
|
+
act: verb.act,
|
|
137
|
+
activity: verb.activity,
|
|
138
|
+
event: verb.event,
|
|
139
|
+
reverseBy: verb.reverseBy,
|
|
140
|
+
inverse: verb.inverse,
|
|
141
|
+
description: verb.description,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Restore things
|
|
146
|
+
for (const thing of snapshot.things) {
|
|
147
|
+
await provider.create(thing.noun, thing.data, thing.id)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Restore actions
|
|
151
|
+
for (const action of snapshot.actions) {
|
|
152
|
+
await provider.perform(action.verb, action.subject, action.object, action.data)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Append an operation to the WAL
|
|
158
|
+
*/
|
|
159
|
+
export async function appendWAL(r2: R2Bucket, namespace: string, entry: WALEntry): Promise<void> {
|
|
160
|
+
const key = `wal/${namespace}/${entry.timestamp}.json`
|
|
161
|
+
await r2.put(key, JSON.stringify(entry))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Replay WAL entries on top of current state
|
|
166
|
+
*/
|
|
167
|
+
export async function replayWAL(
|
|
168
|
+
provider: DigitalObjectsProvider,
|
|
169
|
+
r2: R2Bucket,
|
|
170
|
+
namespace: string,
|
|
171
|
+
afterTimestamp?: number
|
|
172
|
+
): Promise<number> {
|
|
173
|
+
const list = await r2.list({ prefix: `wal/${namespace}/` })
|
|
174
|
+
|
|
175
|
+
// Sort by timestamp (filename)
|
|
176
|
+
const entries = list.objects
|
|
177
|
+
.map((obj) => ({
|
|
178
|
+
key: obj.key,
|
|
179
|
+
timestamp: parseInt(obj.key.split('/').pop()?.replace('.json', '') ?? '0'),
|
|
180
|
+
}))
|
|
181
|
+
.filter((e) => !afterTimestamp || e.timestamp > afterTimestamp)
|
|
182
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
183
|
+
|
|
184
|
+
let replayed = 0
|
|
185
|
+
|
|
186
|
+
for (const { key } of entries) {
|
|
187
|
+
const obj = await r2.get(key)
|
|
188
|
+
if (!obj) continue
|
|
189
|
+
|
|
190
|
+
const entry = await obj.json<WALEntry>()
|
|
191
|
+
|
|
192
|
+
switch (entry.type) {
|
|
193
|
+
case 'defineNoun':
|
|
194
|
+
await provider.defineNoun(entry.data)
|
|
195
|
+
break
|
|
196
|
+
case 'defineVerb':
|
|
197
|
+
await provider.defineVerb(entry.data)
|
|
198
|
+
break
|
|
199
|
+
case 'create':
|
|
200
|
+
await provider.create(entry.noun, entry.data, entry.id)
|
|
201
|
+
break
|
|
202
|
+
case 'update':
|
|
203
|
+
await provider.update(entry.id, entry.data as Record<string, unknown>)
|
|
204
|
+
break
|
|
205
|
+
case 'delete':
|
|
206
|
+
await provider.delete(entry.id)
|
|
207
|
+
break
|
|
208
|
+
case 'perform':
|
|
209
|
+
await provider.perform(entry.verb, entry.subject, entry.object, entry.data)
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
replayed++
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return replayed
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Compact WAL by deleting entries older than the latest snapshot
|
|
221
|
+
*/
|
|
222
|
+
export async function compactWAL(
|
|
223
|
+
r2: R2Bucket,
|
|
224
|
+
namespace: string,
|
|
225
|
+
beforeTimestamp?: number
|
|
226
|
+
): Promise<number> {
|
|
227
|
+
const list = await r2.list({ prefix: `wal/${namespace}/` })
|
|
228
|
+
|
|
229
|
+
const toDelete = list.objects
|
|
230
|
+
.filter((obj) => {
|
|
231
|
+
if (!beforeTimestamp) return true
|
|
232
|
+
const ts = parseInt(obj.key.split('/').pop()?.replace('.json', '') ?? '0')
|
|
233
|
+
return ts < beforeTimestamp
|
|
234
|
+
})
|
|
235
|
+
.map((obj) => obj.key)
|
|
236
|
+
|
|
237
|
+
if (toDelete.length > 0) {
|
|
238
|
+
await r2.delete(toDelete)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return toDelete.length
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Export all data as JSONL (JSON Lines) format
|
|
246
|
+
*/
|
|
247
|
+
export async function exportJSONL(provider: DigitalObjectsProvider): Promise<string> {
|
|
248
|
+
const lines: string[] = []
|
|
249
|
+
|
|
250
|
+
// Export nouns
|
|
251
|
+
const nouns = await provider.listNouns()
|
|
252
|
+
for (const noun of nouns) {
|
|
253
|
+
lines.push(JSON.stringify({ type: 'noun', data: noun }))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Export verbs
|
|
257
|
+
const verbs = await provider.listVerbs()
|
|
258
|
+
for (const verb of verbs) {
|
|
259
|
+
lines.push(JSON.stringify({ type: 'verb', data: verb }))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Export things
|
|
263
|
+
for (const noun of nouns) {
|
|
264
|
+
const things = await provider.list(noun.name)
|
|
265
|
+
for (const thing of things) {
|
|
266
|
+
lines.push(JSON.stringify({ type: 'thing', data: thing }))
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Export actions
|
|
271
|
+
const actions = await provider.listActions()
|
|
272
|
+
for (const action of actions) {
|
|
273
|
+
lines.push(JSON.stringify({ type: 'action', data: action }))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return lines.join('\n')
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Import data from JSONL format
|
|
281
|
+
*/
|
|
282
|
+
export async function importJSONL(
|
|
283
|
+
provider: DigitalObjectsProvider,
|
|
284
|
+
jsonl: string
|
|
285
|
+
): Promise<{ nouns: number; verbs: number; things: number; actions: number }> {
|
|
286
|
+
const stats = { nouns: 0, verbs: 0, things: 0, actions: 0 }
|
|
287
|
+
|
|
288
|
+
const lines = jsonl.trim().split('\n').filter(Boolean)
|
|
289
|
+
|
|
290
|
+
for (const line of lines) {
|
|
291
|
+
const entry = JSON.parse(line) as { type: string; data: unknown }
|
|
292
|
+
|
|
293
|
+
switch (entry.type) {
|
|
294
|
+
case 'noun': {
|
|
295
|
+
const noun = entry.data as Noun
|
|
296
|
+
await provider.defineNoun({
|
|
297
|
+
name: noun.name,
|
|
298
|
+
singular: noun.singular,
|
|
299
|
+
plural: noun.plural,
|
|
300
|
+
description: noun.description,
|
|
301
|
+
schema: noun.schema,
|
|
302
|
+
})
|
|
303
|
+
stats.nouns++
|
|
304
|
+
break
|
|
305
|
+
}
|
|
306
|
+
case 'verb': {
|
|
307
|
+
const verb = entry.data as Verb
|
|
308
|
+
await provider.defineVerb({
|
|
309
|
+
name: verb.name,
|
|
310
|
+
action: verb.action,
|
|
311
|
+
act: verb.act,
|
|
312
|
+
activity: verb.activity,
|
|
313
|
+
event: verb.event,
|
|
314
|
+
reverseBy: verb.reverseBy,
|
|
315
|
+
inverse: verb.inverse,
|
|
316
|
+
description: verb.description,
|
|
317
|
+
})
|
|
318
|
+
stats.verbs++
|
|
319
|
+
break
|
|
320
|
+
}
|
|
321
|
+
case 'thing': {
|
|
322
|
+
const thing = entry.data as Thing
|
|
323
|
+
await provider.create(thing.noun, thing.data, thing.id)
|
|
324
|
+
stats.things++
|
|
325
|
+
break
|
|
326
|
+
}
|
|
327
|
+
case 'action': {
|
|
328
|
+
const action = entry.data as Action
|
|
329
|
+
await provider.perform(action.verb, action.subject, action.object, action.data)
|
|
330
|
+
stats.actions++
|
|
331
|
+
break
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return stats
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Export to R2 as JSONL
|
|
341
|
+
*/
|
|
342
|
+
export async function exportToR2(
|
|
343
|
+
provider: DigitalObjectsProvider,
|
|
344
|
+
r2: R2Bucket,
|
|
345
|
+
key: string
|
|
346
|
+
): Promise<{ key: string; size: number }> {
|
|
347
|
+
const jsonl = await exportJSONL(provider)
|
|
348
|
+
await r2.put(key, jsonl)
|
|
349
|
+
return { key, size: jsonl.length }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Import from R2 JSONL file
|
|
354
|
+
*/
|
|
355
|
+
export async function importFromR2(
|
|
356
|
+
provider: DigitalObjectsProvider,
|
|
357
|
+
r2: R2Bucket,
|
|
358
|
+
key: string
|
|
359
|
+
): Promise<{ nouns: number; verbs: number; things: number; actions: number }> {
|
|
360
|
+
const obj = await r2.get(key)
|
|
361
|
+
if (!obj) {
|
|
362
|
+
throw new Error(`File not found: ${key}`)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const jsonl = await obj.text()
|
|
366
|
+
return importJSONL(provider, jsonl)
|
|
367
|
+
}
|