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
package/src/ns.test.ts
ADDED
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NS Durable Object Tests
|
|
3
|
+
*
|
|
4
|
+
* Since we can't easily test actual Durable Objects in vitest,
|
|
5
|
+
* we mock the SqlStorage interface and test the NS class methods directly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
|
|
9
|
+
import { NS, type Env } from './ns'
|
|
10
|
+
|
|
11
|
+
// Mock data storage for our fake SQLite
|
|
12
|
+
type Row = Record<string, unknown>
|
|
13
|
+
|
|
14
|
+
interface MockSqlStorage {
|
|
15
|
+
exec: Mock<(...args: unknown[]) => { rowsWritten: number } & Iterable<Row>>
|
|
16
|
+
_tables: Map<string, Row[]>
|
|
17
|
+
_lastQuery: string
|
|
18
|
+
_lastParams: unknown[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Create a mock SqlStorage that simulates SQLite behavior
|
|
22
|
+
const createMockSqlStorage = (): MockSqlStorage => {
|
|
23
|
+
const tables = new Map<string, Row[]>()
|
|
24
|
+
let lastQuery = ''
|
|
25
|
+
let lastParams: unknown[] = []
|
|
26
|
+
|
|
27
|
+
// Initialize tables
|
|
28
|
+
tables.set('nouns', [])
|
|
29
|
+
tables.set('verbs', [])
|
|
30
|
+
tables.set('things', [])
|
|
31
|
+
tables.set('actions', [])
|
|
32
|
+
|
|
33
|
+
const exec = vi.fn((...args: unknown[]) => {
|
|
34
|
+
const sql = args[0] as string
|
|
35
|
+
const params = args.slice(1)
|
|
36
|
+
lastQuery = sql
|
|
37
|
+
lastParams = params
|
|
38
|
+
|
|
39
|
+
// Handle CREATE TABLE / CREATE INDEX (schema initialization)
|
|
40
|
+
if (sql.includes('CREATE TABLE') || sql.includes('CREATE INDEX')) {
|
|
41
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => [][Symbol.iterator]() }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Handle INSERT OR REPLACE INTO nouns
|
|
45
|
+
if (sql.includes('INSERT OR REPLACE INTO nouns')) {
|
|
46
|
+
const row: Row = {
|
|
47
|
+
name: params[0],
|
|
48
|
+
singular: params[1],
|
|
49
|
+
plural: params[2],
|
|
50
|
+
slug: params[3],
|
|
51
|
+
description: params[4],
|
|
52
|
+
schema: params[5],
|
|
53
|
+
created_at: params[6],
|
|
54
|
+
}
|
|
55
|
+
const nouns = tables.get('nouns')!
|
|
56
|
+
const existingIndex = nouns.findIndex((n) => n.name === params[0])
|
|
57
|
+
if (existingIndex >= 0) {
|
|
58
|
+
nouns[existingIndex] = row
|
|
59
|
+
} else {
|
|
60
|
+
nouns.push(row)
|
|
61
|
+
}
|
|
62
|
+
return { rowsWritten: 1, [Symbol.iterator]: () => [][Symbol.iterator]() }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Handle INSERT OR REPLACE INTO verbs
|
|
66
|
+
if (sql.includes('INSERT OR REPLACE INTO verbs')) {
|
|
67
|
+
const row: Row = {
|
|
68
|
+
name: params[0],
|
|
69
|
+
action: params[1],
|
|
70
|
+
act: params[2],
|
|
71
|
+
activity: params[3],
|
|
72
|
+
event: params[4],
|
|
73
|
+
reverse_by: params[5],
|
|
74
|
+
reverse_at: params[6],
|
|
75
|
+
inverse: params[7],
|
|
76
|
+
description: params[8],
|
|
77
|
+
created_at: params[9],
|
|
78
|
+
}
|
|
79
|
+
const verbs = tables.get('verbs')!
|
|
80
|
+
const existingIndex = verbs.findIndex((v) => v.name === params[0])
|
|
81
|
+
if (existingIndex >= 0) {
|
|
82
|
+
verbs[existingIndex] = row
|
|
83
|
+
} else {
|
|
84
|
+
verbs.push(row)
|
|
85
|
+
}
|
|
86
|
+
return { rowsWritten: 1, [Symbol.iterator]: () => [][Symbol.iterator]() }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Handle INSERT INTO things
|
|
90
|
+
if (sql.includes('INSERT INTO things')) {
|
|
91
|
+
const row: Row = {
|
|
92
|
+
id: params[0],
|
|
93
|
+
noun: params[1],
|
|
94
|
+
data: params[2],
|
|
95
|
+
created_at: params[3],
|
|
96
|
+
updated_at: params[4],
|
|
97
|
+
}
|
|
98
|
+
tables.get('things')!.push(row)
|
|
99
|
+
return { rowsWritten: 1, [Symbol.iterator]: () => [][Symbol.iterator]() }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Handle INSERT INTO actions
|
|
103
|
+
if (sql.includes('INSERT INTO actions')) {
|
|
104
|
+
const row: Row = {
|
|
105
|
+
id: params[0],
|
|
106
|
+
verb: params[1],
|
|
107
|
+
subject: params[2],
|
|
108
|
+
object: params[3],
|
|
109
|
+
data: params[4],
|
|
110
|
+
status: params[5],
|
|
111
|
+
created_at: params[6],
|
|
112
|
+
completed_at: params[7],
|
|
113
|
+
}
|
|
114
|
+
tables.get('actions')!.push(row)
|
|
115
|
+
return { rowsWritten: 1, [Symbol.iterator]: () => [][Symbol.iterator]() }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Handle SELECT * FROM nouns WHERE name = ?
|
|
119
|
+
if (sql.includes('SELECT * FROM nouns WHERE name = ?')) {
|
|
120
|
+
const results = tables.get('nouns')!.filter((n) => n.name === params[0])
|
|
121
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Handle SELECT * FROM nouns (list all)
|
|
125
|
+
if (sql === 'SELECT * FROM nouns') {
|
|
126
|
+
const results = tables.get('nouns')!
|
|
127
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Handle SELECT * FROM verbs WHERE name = ?
|
|
131
|
+
if (sql.includes('SELECT * FROM verbs WHERE name = ?')) {
|
|
132
|
+
const results = tables.get('verbs')!.filter((v) => v.name === params[0])
|
|
133
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Handle SELECT * FROM verbs (list all)
|
|
137
|
+
if (sql === 'SELECT * FROM verbs') {
|
|
138
|
+
const results = tables.get('verbs')!
|
|
139
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Handle SELECT * FROM things WHERE id = ?
|
|
143
|
+
if (sql.includes('SELECT * FROM things WHERE id = ?')) {
|
|
144
|
+
const results = tables.get('things')!.filter((t) => t.id === params[0])
|
|
145
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Handle SELECT * FROM things WHERE id IN (?, ?, ...) - batch query for getMany()
|
|
149
|
+
if (sql.includes('SELECT * FROM things WHERE id IN (')) {
|
|
150
|
+
const ids = params as string[]
|
|
151
|
+
const results = tables.get('things')!.filter((t) => ids.includes(t.id as string))
|
|
152
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Handle SELECT * FROM things WHERE noun = ? (with potential json_extract, ORDER BY, LIMIT, OFFSET)
|
|
156
|
+
if (sql.includes('SELECT * FROM things WHERE noun = ?')) {
|
|
157
|
+
let results = tables.get('things')!.filter((t) => t.noun === params[0])
|
|
158
|
+
let paramIndex = 1
|
|
159
|
+
|
|
160
|
+
// Handle json_extract WHERE clauses for filtering
|
|
161
|
+
const jsonExtractMatches = sql.match(/json_extract\(data, '\$\.(\w+)'\) = \?/g)
|
|
162
|
+
if (jsonExtractMatches) {
|
|
163
|
+
for (const match of jsonExtractMatches) {
|
|
164
|
+
const fieldMatch = match.match(/json_extract\(data, '\$\.(\w+)'\)/)
|
|
165
|
+
if (fieldMatch) {
|
|
166
|
+
const field = fieldMatch[1]
|
|
167
|
+
const value = params[paramIndex++]
|
|
168
|
+
results = results.filter((t) => {
|
|
169
|
+
const data = typeof t.data === 'string' ? JSON.parse(t.data as string) : t.data
|
|
170
|
+
return data[field] === value
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Handle LIMIT
|
|
177
|
+
const limitMatch = sql.match(/LIMIT \?/)
|
|
178
|
+
if (limitMatch) {
|
|
179
|
+
const limit = params[paramIndex++] as number
|
|
180
|
+
results = results.slice(0, limit)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Handle UPDATE things
|
|
187
|
+
if (sql.includes('UPDATE things SET data = ?')) {
|
|
188
|
+
const things = tables.get('things')!
|
|
189
|
+
const idx = things.findIndex((t) => t.id === params[2])
|
|
190
|
+
if (idx >= 0) {
|
|
191
|
+
things[idx].data = params[0]
|
|
192
|
+
things[idx].updated_at = params[1]
|
|
193
|
+
}
|
|
194
|
+
return { rowsWritten: idx >= 0 ? 1 : 0, [Symbol.iterator]: () => [][Symbol.iterator]() }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Handle DELETE FROM things
|
|
198
|
+
if (sql.includes('DELETE FROM things WHERE id = ?')) {
|
|
199
|
+
const things = tables.get('things')!
|
|
200
|
+
const idx = things.findIndex((t) => t.id === params[0])
|
|
201
|
+
if (idx >= 0) {
|
|
202
|
+
things.splice(idx, 1)
|
|
203
|
+
return { rowsWritten: 1, [Symbol.iterator]: () => [][Symbol.iterator]() }
|
|
204
|
+
}
|
|
205
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => [][Symbol.iterator]() }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Handle search query
|
|
209
|
+
if (sql.includes('WHERE LOWER(data) LIKE ?')) {
|
|
210
|
+
const query = (params[0] as string).replace(/%/g, '').toLowerCase()
|
|
211
|
+
let results = tables.get('things')!.filter((t) => {
|
|
212
|
+
const data = (t.data as string).toLowerCase()
|
|
213
|
+
return data.includes(query)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// Handle LIMIT for search
|
|
217
|
+
if (params.length > 1) {
|
|
218
|
+
results = results.slice(0, params[1] as number)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Handle SELECT * FROM actions WHERE id = ?
|
|
225
|
+
if (sql.includes('SELECT * FROM actions WHERE id = ?')) {
|
|
226
|
+
const results = tables.get('actions')!.filter((a) => a.id === params[0])
|
|
227
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Handle SELECT * FROM actions WHERE 1=1 (with filters)
|
|
231
|
+
if (sql.includes('SELECT * FROM actions WHERE 1=1')) {
|
|
232
|
+
let results = [...tables.get('actions')!]
|
|
233
|
+
|
|
234
|
+
// Parse and apply filters
|
|
235
|
+
let paramIndex = 0
|
|
236
|
+
if (sql.includes('AND verb = ?')) {
|
|
237
|
+
results = results.filter((a) => a.verb === params[paramIndex++])
|
|
238
|
+
}
|
|
239
|
+
if (sql.includes('AND subject = ?')) {
|
|
240
|
+
results = results.filter((a) => a.subject === params[paramIndex++])
|
|
241
|
+
}
|
|
242
|
+
if (sql.includes('AND object = ?')) {
|
|
243
|
+
results = results.filter((a) => a.object === params[paramIndex++])
|
|
244
|
+
}
|
|
245
|
+
if (sql.includes('AND status IN')) {
|
|
246
|
+
const statusCount = (sql.match(/\?/g) || []).length - paramIndex
|
|
247
|
+
const statuses = params.slice(paramIndex, paramIndex + statusCount)
|
|
248
|
+
paramIndex += statusCount
|
|
249
|
+
results = results.filter((a) => statuses.includes(a.status))
|
|
250
|
+
}
|
|
251
|
+
if (sql.includes('LIMIT ?')) {
|
|
252
|
+
const limit = params[paramIndex] as number
|
|
253
|
+
results = results.slice(0, limit)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Handle SELECT * FROM actions WHERE subject = ? (edges out)
|
|
260
|
+
if (sql.includes('SELECT * FROM actions WHERE subject = ?') && !sql.includes('OR object')) {
|
|
261
|
+
let results = tables.get('actions')!.filter((a) => a.subject === params[0])
|
|
262
|
+
if (sql.includes('AND verb = ?') && params.length > 1) {
|
|
263
|
+
results = results.filter((a) => a.verb === params[1])
|
|
264
|
+
}
|
|
265
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Handle SELECT * FROM actions WHERE object = ? (edges in)
|
|
269
|
+
if (sql.includes('SELECT * FROM actions WHERE object = ?') && !sql.includes('OR')) {
|
|
270
|
+
let results = tables.get('actions')!.filter((a) => a.object === params[0])
|
|
271
|
+
if (sql.includes('AND verb = ?') && params.length > 1) {
|
|
272
|
+
results = results.filter((a) => a.verb === params[1])
|
|
273
|
+
}
|
|
274
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Handle SELECT * FROM actions WHERE subject = ? OR object = ? (edges both)
|
|
278
|
+
if (sql.includes('SELECT * FROM actions WHERE subject = ? OR object = ?')) {
|
|
279
|
+
let results = tables
|
|
280
|
+
.get('actions')!
|
|
281
|
+
.filter((a) => a.subject === params[0] || a.object === params[1])
|
|
282
|
+
if (sql.includes('AND verb = ?') && params.length > 2) {
|
|
283
|
+
results = results.filter((a) => a.verb === params[2])
|
|
284
|
+
}
|
|
285
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Default: return empty
|
|
289
|
+
return { rowsWritten: 0, [Symbol.iterator]: () => [][Symbol.iterator]() }
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
exec,
|
|
294
|
+
_tables: tables,
|
|
295
|
+
_lastQuery: lastQuery,
|
|
296
|
+
_lastParams: lastParams,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Create mock DurableObjectState
|
|
301
|
+
const createMockState = (mockSql: MockSqlStorage) => ({
|
|
302
|
+
storage: {
|
|
303
|
+
sql: mockSql,
|
|
304
|
+
},
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// Create mock Env
|
|
308
|
+
const createMockEnv = (): Env => ({
|
|
309
|
+
NS: {
|
|
310
|
+
idFromName: vi.fn(),
|
|
311
|
+
get: vi.fn(),
|
|
312
|
+
} as unknown as DurableObjectNamespace,
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
describe('NS Durable Object', () => {
|
|
316
|
+
let ns: NS
|
|
317
|
+
let mockSql: MockSqlStorage
|
|
318
|
+
let mockEnv: Env
|
|
319
|
+
|
|
320
|
+
beforeEach(() => {
|
|
321
|
+
mockSql = createMockSqlStorage()
|
|
322
|
+
const mockState = createMockState(mockSql)
|
|
323
|
+
mockEnv = createMockEnv()
|
|
324
|
+
ns = new NS(mockState as unknown as DurableObjectState, mockEnv)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
describe('Schema Initialization', () => {
|
|
328
|
+
it('should create all required tables on first operation', async () => {
|
|
329
|
+
// Trigger initialization by calling any method
|
|
330
|
+
await ns.listNouns()
|
|
331
|
+
|
|
332
|
+
// Check that CREATE TABLE statements were executed
|
|
333
|
+
const calls = mockSql.exec.mock.calls
|
|
334
|
+
const createTableCall = calls.find((call) => {
|
|
335
|
+
const sql = call[0] as string
|
|
336
|
+
return sql.includes('CREATE TABLE')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
expect(createTableCall).toBeDefined()
|
|
340
|
+
const sql = createTableCall![0] as string
|
|
341
|
+
|
|
342
|
+
// Verify all tables are created
|
|
343
|
+
expect(sql).toContain('CREATE TABLE IF NOT EXISTS nouns')
|
|
344
|
+
expect(sql).toContain('CREATE TABLE IF NOT EXISTS verbs')
|
|
345
|
+
expect(sql).toContain('CREATE TABLE IF NOT EXISTS things')
|
|
346
|
+
expect(sql).toContain('CREATE TABLE IF NOT EXISTS actions')
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('should create indexes on things and actions tables', async () => {
|
|
350
|
+
await ns.listNouns()
|
|
351
|
+
|
|
352
|
+
const calls = mockSql.exec.mock.calls
|
|
353
|
+
const createTableCall = calls.find((call) => {
|
|
354
|
+
const sql = call[0] as string
|
|
355
|
+
return sql.includes('CREATE INDEX')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
expect(createTableCall).toBeDefined()
|
|
359
|
+
const sql = createTableCall![0] as string
|
|
360
|
+
|
|
361
|
+
expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_things_noun')
|
|
362
|
+
expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_actions_verb')
|
|
363
|
+
expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_actions_subject')
|
|
364
|
+
expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_actions_object')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('should only initialize once (memoization)', async () => {
|
|
368
|
+
await ns.listNouns()
|
|
369
|
+
await ns.listNouns()
|
|
370
|
+
await ns.listNouns()
|
|
371
|
+
|
|
372
|
+
// Count CREATE TABLE calls
|
|
373
|
+
const createCalls = mockSql.exec.mock.calls.filter((call) => {
|
|
374
|
+
const sql = call[0] as string
|
|
375
|
+
return sql.includes('CREATE TABLE IF NOT EXISTS nouns')
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
expect(createCalls.length).toBe(1)
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
describe('Noun Operations', () => {
|
|
383
|
+
it('should define a noun with auto-derived forms', async () => {
|
|
384
|
+
const noun = await ns.defineNoun({ name: 'Post' })
|
|
385
|
+
|
|
386
|
+
expect(noun.name).toBe('Post')
|
|
387
|
+
expect(noun.singular).toBe('post')
|
|
388
|
+
expect(noun.plural).toBe('posts')
|
|
389
|
+
expect(noun.slug).toBe('post')
|
|
390
|
+
expect(noun.createdAt).toBeInstanceOf(Date)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('should define a noun with explicit forms', async () => {
|
|
394
|
+
const noun = await ns.defineNoun({
|
|
395
|
+
name: 'Person',
|
|
396
|
+
singular: 'person',
|
|
397
|
+
plural: 'people',
|
|
398
|
+
description: 'A human being',
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
expect(noun.singular).toBe('person')
|
|
402
|
+
expect(noun.plural).toBe('people')
|
|
403
|
+
expect(noun.description).toBe('A human being')
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('should define a noun with schema', async () => {
|
|
407
|
+
const schema = { title: 'string', body: 'markdown' as const }
|
|
408
|
+
const noun = await ns.defineNoun({
|
|
409
|
+
name: 'Article',
|
|
410
|
+
schema,
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
expect(noun.schema).toEqual(schema)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('should get a noun by name', async () => {
|
|
417
|
+
await ns.defineNoun({ name: 'Author' })
|
|
418
|
+
const noun = await ns.getNoun('Author')
|
|
419
|
+
|
|
420
|
+
expect(noun).not.toBeNull()
|
|
421
|
+
expect(noun!.name).toBe('Author')
|
|
422
|
+
expect(noun!.singular).toBe('author')
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('should return null for unknown noun', async () => {
|
|
426
|
+
const noun = await ns.getNoun('NonExistent')
|
|
427
|
+
expect(noun).toBeNull()
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('should list all nouns', async () => {
|
|
431
|
+
await ns.defineNoun({ name: 'Post' })
|
|
432
|
+
await ns.defineNoun({ name: 'Author' })
|
|
433
|
+
|
|
434
|
+
const nouns = await ns.listNouns()
|
|
435
|
+
expect(nouns).toHaveLength(2)
|
|
436
|
+
expect(nouns.map((n) => n.name)).toContain('Post')
|
|
437
|
+
expect(nouns.map((n) => n.name)).toContain('Author')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('should handle upsert (INSERT OR REPLACE)', async () => {
|
|
441
|
+
await ns.defineNoun({ name: 'Post', description: 'Original' })
|
|
442
|
+
await ns.defineNoun({ name: 'Post', description: 'Updated' })
|
|
443
|
+
|
|
444
|
+
const noun = await ns.getNoun('Post')
|
|
445
|
+
expect(noun!.description).toBe('Updated')
|
|
446
|
+
|
|
447
|
+
const nouns = await ns.listNouns()
|
|
448
|
+
expect(nouns).toHaveLength(1)
|
|
449
|
+
})
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
describe('Verb Operations', () => {
|
|
453
|
+
it('should define a verb with auto-derived conjugations', async () => {
|
|
454
|
+
const verb = await ns.defineVerb({ name: 'create' })
|
|
455
|
+
|
|
456
|
+
expect(verb.name).toBe('create')
|
|
457
|
+
expect(verb.action).toBe('create')
|
|
458
|
+
expect(verb.act).toBe('creates')
|
|
459
|
+
expect(verb.activity).toBe('creating')
|
|
460
|
+
expect(verb.event).toBe('created')
|
|
461
|
+
expect(verb.reverseBy).toBe('createdBy')
|
|
462
|
+
expect(verb.reverseAt).toBe('createdAt')
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('should define a verb with explicit conjugations', async () => {
|
|
466
|
+
const verb = await ns.defineVerb({
|
|
467
|
+
name: 'write',
|
|
468
|
+
action: 'write',
|
|
469
|
+
act: 'writes',
|
|
470
|
+
activity: 'writing',
|
|
471
|
+
event: 'written',
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
expect(verb.event).toBe('written')
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('should define a verb with inverse', async () => {
|
|
478
|
+
const verb = await ns.defineVerb({
|
|
479
|
+
name: 'publish',
|
|
480
|
+
inverse: 'unpublish',
|
|
481
|
+
description: 'Make content public',
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
expect(verb.inverse).toBe('unpublish')
|
|
485
|
+
expect(verb.description).toBe('Make content public')
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('should get a verb by name', async () => {
|
|
489
|
+
await ns.defineVerb({ name: 'like' })
|
|
490
|
+
const verb = await ns.getVerb('like')
|
|
491
|
+
|
|
492
|
+
expect(verb).not.toBeNull()
|
|
493
|
+
expect(verb!.name).toBe('like')
|
|
494
|
+
expect(verb!.activity).toBe('liking')
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('should return null for unknown verb', async () => {
|
|
498
|
+
const verb = await ns.getVerb('nonexistent')
|
|
499
|
+
expect(verb).toBeNull()
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('should list all verbs', async () => {
|
|
503
|
+
await ns.defineVerb({ name: 'create' })
|
|
504
|
+
await ns.defineVerb({ name: 'update' })
|
|
505
|
+
await ns.defineVerb({ name: 'delete' })
|
|
506
|
+
|
|
507
|
+
const verbs = await ns.listVerbs()
|
|
508
|
+
expect(verbs).toHaveLength(3)
|
|
509
|
+
})
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
describe('Thing Operations', () => {
|
|
513
|
+
it('should create a thing with auto-generated ID', async () => {
|
|
514
|
+
const thing = await ns.create('Post', { title: 'Hello World' })
|
|
515
|
+
|
|
516
|
+
expect(thing.id).toBeDefined()
|
|
517
|
+
expect(thing.id.length).toBeGreaterThan(0)
|
|
518
|
+
expect(thing.noun).toBe('Post')
|
|
519
|
+
expect(thing.data).toEqual({ title: 'Hello World' })
|
|
520
|
+
expect(thing.createdAt).toBeInstanceOf(Date)
|
|
521
|
+
expect(thing.updatedAt).toBeInstanceOf(Date)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('should create a thing with custom ID', async () => {
|
|
525
|
+
const thing = await ns.create('Post', { title: 'Custom' }, 'my-custom-id')
|
|
526
|
+
expect(thing.id).toBe('my-custom-id')
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('should get a thing by ID', async () => {
|
|
530
|
+
const created = await ns.create('Post', { title: 'Test', body: 'Content' })
|
|
531
|
+
const retrieved = await ns.get(created.id)
|
|
532
|
+
|
|
533
|
+
expect(retrieved).not.toBeNull()
|
|
534
|
+
expect(retrieved!.id).toBe(created.id)
|
|
535
|
+
expect(retrieved!.data).toEqual({ title: 'Test', body: 'Content' })
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('should return null for unknown thing', async () => {
|
|
539
|
+
const thing = await ns.get('nonexistent-id')
|
|
540
|
+
expect(thing).toBeNull()
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('should list things by noun', async () => {
|
|
544
|
+
await ns.create('Post', { title: 'First' })
|
|
545
|
+
await ns.create('Post', { title: 'Second' })
|
|
546
|
+
await ns.create('Author', { name: 'Alice' })
|
|
547
|
+
|
|
548
|
+
const posts = await ns.list('Post')
|
|
549
|
+
expect(posts).toHaveLength(2)
|
|
550
|
+
expect(posts.every((p) => p.noun === 'Post')).toBe(true)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('should list things with limit option', async () => {
|
|
554
|
+
await ns.create('Post', { title: 'First' })
|
|
555
|
+
await ns.create('Post', { title: 'Second' })
|
|
556
|
+
await ns.create('Post', { title: 'Third' })
|
|
557
|
+
|
|
558
|
+
const posts = await ns.list('Post', { limit: 2 })
|
|
559
|
+
expect(posts).toHaveLength(2)
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('should update a thing', async () => {
|
|
563
|
+
const created = await ns.create('Post', { title: 'Original', status: 'draft' })
|
|
564
|
+
const updated = await ns.update(created.id, { title: 'Updated' })
|
|
565
|
+
|
|
566
|
+
expect(updated.data).toEqual({ title: 'Updated', status: 'draft' })
|
|
567
|
+
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.createdAt.getTime())
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('should throw error when updating non-existent thing', async () => {
|
|
571
|
+
await expect(ns.update('nonexistent', { title: 'Test' })).rejects.toThrow(
|
|
572
|
+
'Thing not found: nonexistent'
|
|
573
|
+
)
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('should delete a thing', async () => {
|
|
577
|
+
const created = await ns.create('Post', { title: 'ToDelete' })
|
|
578
|
+
const deleted = await ns.delete(created.id)
|
|
579
|
+
|
|
580
|
+
expect(deleted).toBe(true)
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it('should return false when deleting non-existent thing', async () => {
|
|
584
|
+
const deleted = await ns.delete('nonexistent-id')
|
|
585
|
+
expect(deleted).toBe(false)
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
it('should find things by criteria', async () => {
|
|
589
|
+
await ns.create('Post', { title: 'Draft 1', status: 'draft' })
|
|
590
|
+
await ns.create('Post', { title: 'Published', status: 'published' })
|
|
591
|
+
await ns.create('Post', { title: 'Draft 2', status: 'draft' })
|
|
592
|
+
|
|
593
|
+
const drafts = await ns.find<{ title: string; status: string }>('Post', { status: 'draft' })
|
|
594
|
+
expect(drafts).toHaveLength(2)
|
|
595
|
+
expect(drafts.every((p) => p.data.status === 'draft')).toBe(true)
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
it('should search things by query', async () => {
|
|
599
|
+
await ns.create('Post', { title: 'Hello World', body: 'This is a test' })
|
|
600
|
+
await ns.create('Post', { title: 'Goodbye World', body: 'Another post' })
|
|
601
|
+
|
|
602
|
+
const results = await ns.search('hello')
|
|
603
|
+
expect(results).toHaveLength(1)
|
|
604
|
+
expect(results[0].data.title).toBe('Hello World')
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
it('should search with limit', async () => {
|
|
608
|
+
await ns.create('Post', { title: 'Test 1', body: 'testing' })
|
|
609
|
+
await ns.create('Post', { title: 'Test 2', body: 'testing again' })
|
|
610
|
+
await ns.create('Post', { title: 'Test 3', body: 'testing more' })
|
|
611
|
+
|
|
612
|
+
const results = await ns.search('test', { limit: 2 })
|
|
613
|
+
expect(results).toHaveLength(2)
|
|
614
|
+
})
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
describe('Action Operations', () => {
|
|
618
|
+
it('should perform an action with subject and object', async () => {
|
|
619
|
+
const author = await ns.create('Author', { name: 'Alice' })
|
|
620
|
+
const post = await ns.create('Post', { title: 'My Post' })
|
|
621
|
+
|
|
622
|
+
const action = await ns.perform('write', author.id, post.id)
|
|
623
|
+
|
|
624
|
+
expect(action.id).toBeDefined()
|
|
625
|
+
expect(action.verb).toBe('write')
|
|
626
|
+
expect(action.subject).toBe(author.id)
|
|
627
|
+
expect(action.object).toBe(post.id)
|
|
628
|
+
expect(action.status).toBe('completed')
|
|
629
|
+
expect(action.createdAt).toBeInstanceOf(Date)
|
|
630
|
+
expect(action.completedAt).toBeInstanceOf(Date)
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('should perform an action with data payload', async () => {
|
|
634
|
+
const post = await ns.create('Post', { title: 'Draft' })
|
|
635
|
+
|
|
636
|
+
const action = await ns.perform('publish', undefined, post.id, {
|
|
637
|
+
publishedBy: 'admin',
|
|
638
|
+
publishedAt: '2024-01-15',
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
expect(action.data).toEqual({
|
|
642
|
+
publishedBy: 'admin',
|
|
643
|
+
publishedAt: '2024-01-15',
|
|
644
|
+
})
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('should perform an action with only subject', async () => {
|
|
648
|
+
const user = await ns.create('User', { name: 'Bob' })
|
|
649
|
+
|
|
650
|
+
const action = await ns.perform('login', user.id)
|
|
651
|
+
|
|
652
|
+
expect(action.subject).toBe(user.id)
|
|
653
|
+
expect(action.object).toBeUndefined()
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it('should perform an action with only object', async () => {
|
|
657
|
+
const resource = await ns.create('Resource', { name: 'Document' })
|
|
658
|
+
|
|
659
|
+
const action = await ns.perform('view', undefined, resource.id)
|
|
660
|
+
|
|
661
|
+
expect(action.subject).toBeUndefined()
|
|
662
|
+
expect(action.object).toBe(resource.id)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it('should get an action by ID', async () => {
|
|
666
|
+
const created = await ns.perform('test', 'subject-1', 'object-1', { key: 'value' })
|
|
667
|
+
const retrieved = await ns.getAction(created.id)
|
|
668
|
+
|
|
669
|
+
expect(retrieved).not.toBeNull()
|
|
670
|
+
expect(retrieved!.id).toBe(created.id)
|
|
671
|
+
expect(retrieved!.verb).toBe('test')
|
|
672
|
+
expect(retrieved!.data).toEqual({ key: 'value' })
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
it('should return null for unknown action', async () => {
|
|
676
|
+
const action = await ns.getAction('nonexistent')
|
|
677
|
+
expect(action).toBeNull()
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
it('should list actions with verb filter', async () => {
|
|
681
|
+
await ns.perform('write', 'a1', 'p1')
|
|
682
|
+
await ns.perform('write', 'a2', 'p2')
|
|
683
|
+
await ns.perform('publish', undefined, 'p1')
|
|
684
|
+
|
|
685
|
+
const writeActions = await ns.listActions({ verb: 'write' })
|
|
686
|
+
expect(writeActions.length).toBeGreaterThanOrEqual(1)
|
|
687
|
+
expect(writeActions.every((a) => a.verb === 'write')).toBe(true)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('should list actions with subject filter', async () => {
|
|
691
|
+
await ns.perform('write', 'author-1', 'post-1')
|
|
692
|
+
await ns.perform('write', 'author-1', 'post-2')
|
|
693
|
+
await ns.perform('write', 'author-2', 'post-3')
|
|
694
|
+
|
|
695
|
+
const authorActions = await ns.listActions({ subject: 'author-1' })
|
|
696
|
+
expect(authorActions.length).toBeGreaterThanOrEqual(1)
|
|
697
|
+
expect(authorActions.every((a) => a.subject === 'author-1')).toBe(true)
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
it('should list actions with object filter', async () => {
|
|
701
|
+
await ns.perform('like', 'user-1', 'post-1')
|
|
702
|
+
await ns.perform('like', 'user-2', 'post-1')
|
|
703
|
+
await ns.perform('like', 'user-3', 'post-2')
|
|
704
|
+
|
|
705
|
+
const postActions = await ns.listActions({ object: 'post-1' })
|
|
706
|
+
expect(postActions.length).toBeGreaterThanOrEqual(1)
|
|
707
|
+
expect(postActions.every((a) => a.object === 'post-1')).toBe(true)
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
it('should list actions with limit', async () => {
|
|
711
|
+
await ns.perform('action', 's1', 'o1')
|
|
712
|
+
await ns.perform('action', 's2', 'o2')
|
|
713
|
+
await ns.perform('action', 's3', 'o3')
|
|
714
|
+
|
|
715
|
+
const actions = await ns.listActions({ limit: 2 })
|
|
716
|
+
expect(actions).toHaveLength(2)
|
|
717
|
+
})
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
describe('Graph Traversal', () => {
|
|
721
|
+
beforeEach(async () => {
|
|
722
|
+
// Set up a small graph
|
|
723
|
+
await ns.create('Author', { name: 'Alice' }, 'alice')
|
|
724
|
+
await ns.create('Author', { name: 'Bob' }, 'bob')
|
|
725
|
+
await ns.create('Post', { title: 'Post 1' }, 'post-1')
|
|
726
|
+
await ns.create('Post', { title: 'Post 2' }, 'post-2')
|
|
727
|
+
await ns.create('Post', { title: 'Post 3' }, 'post-3')
|
|
728
|
+
|
|
729
|
+
// Alice writes post-1 and post-2
|
|
730
|
+
await ns.perform('write', 'alice', 'post-1')
|
|
731
|
+
await ns.perform('write', 'alice', 'post-2')
|
|
732
|
+
|
|
733
|
+
// Bob writes post-3
|
|
734
|
+
await ns.perform('write', 'bob', 'post-3')
|
|
735
|
+
|
|
736
|
+
// Bob likes post-1
|
|
737
|
+
await ns.perform('like', 'bob', 'post-1')
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
describe('edges()', () => {
|
|
741
|
+
it('should get outbound edges for a thing', async () => {
|
|
742
|
+
const edges = await ns.edges('alice', undefined, 'out')
|
|
743
|
+
|
|
744
|
+
expect(edges).toHaveLength(2)
|
|
745
|
+
expect(edges.every((e) => e.subject === 'alice')).toBe(true)
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
it('should get inbound edges for a thing', async () => {
|
|
749
|
+
const edges = await ns.edges('post-1', undefined, 'in')
|
|
750
|
+
|
|
751
|
+
expect(edges).toHaveLength(2) // written by alice, liked by bob
|
|
752
|
+
expect(edges.every((e) => e.object === 'post-1')).toBe(true)
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
it('should get both directions edges', async () => {
|
|
756
|
+
const edges = await ns.edges('bob', undefined, 'both')
|
|
757
|
+
|
|
758
|
+
// Bob has: write (out), like (out)
|
|
759
|
+
expect(edges.length).toBeGreaterThanOrEqual(2)
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
it('should filter edges by verb', async () => {
|
|
763
|
+
const edges = await ns.edges('bob', 'like', 'out')
|
|
764
|
+
|
|
765
|
+
expect(edges).toHaveLength(1)
|
|
766
|
+
expect(edges[0].verb).toBe('like')
|
|
767
|
+
expect(edges[0].object).toBe('post-1')
|
|
768
|
+
})
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
describe('related()', () => {
|
|
772
|
+
it('should get related things via outbound edges', async () => {
|
|
773
|
+
const posts = await ns.related('alice', 'write', 'out')
|
|
774
|
+
|
|
775
|
+
expect(posts).toHaveLength(2)
|
|
776
|
+
expect(posts.map((p) => p.id)).toContain('post-1')
|
|
777
|
+
expect(posts.map((p) => p.id)).toContain('post-2')
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
it('should get related things via inbound edges', async () => {
|
|
781
|
+
const authors = await ns.related('post-1', 'write', 'in')
|
|
782
|
+
|
|
783
|
+
expect(authors).toHaveLength(1)
|
|
784
|
+
expect(authors[0].id).toBe('alice')
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
it('should get related things in both directions', async () => {
|
|
788
|
+
const related = await ns.related('bob', undefined, 'both')
|
|
789
|
+
|
|
790
|
+
// Bob -> post-3 (write), Bob -> post-1 (like)
|
|
791
|
+
expect(related.length).toBeGreaterThanOrEqual(2)
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
it('should return empty array when no relations', async () => {
|
|
795
|
+
const related = await ns.related('post-3', 'like', 'in')
|
|
796
|
+
expect(related).toHaveLength(0)
|
|
797
|
+
})
|
|
798
|
+
})
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
describe('HTTP Request Handling', () => {
|
|
802
|
+
// Helper to create mock requests
|
|
803
|
+
const createRequest = (
|
|
804
|
+
method: string,
|
|
805
|
+
path: string,
|
|
806
|
+
body?: unknown,
|
|
807
|
+
searchParams?: Record<string, string>
|
|
808
|
+
) => {
|
|
809
|
+
let url = `https://example.com${path}`
|
|
810
|
+
if (searchParams) {
|
|
811
|
+
const params = new URLSearchParams(searchParams)
|
|
812
|
+
url += `?${params.toString()}`
|
|
813
|
+
}
|
|
814
|
+
return new Request(url, {
|
|
815
|
+
method,
|
|
816
|
+
headers: body ? { 'Content-Type': 'application/json' } : {},
|
|
817
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
818
|
+
})
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
describe('Noun Routes', () => {
|
|
822
|
+
it('POST /nouns should define a noun', async () => {
|
|
823
|
+
const request = createRequest('POST', '/nouns', { name: 'Post' })
|
|
824
|
+
const response = await ns.fetch(request)
|
|
825
|
+
|
|
826
|
+
expect(response.status).toBe(200)
|
|
827
|
+
const noun = await response.json()
|
|
828
|
+
expect(noun.name).toBe('Post')
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
it('GET /nouns should list all nouns', async () => {
|
|
832
|
+
await ns.defineNoun({ name: 'Post' })
|
|
833
|
+
await ns.defineNoun({ name: 'Author' })
|
|
834
|
+
|
|
835
|
+
const request = createRequest('GET', '/nouns')
|
|
836
|
+
const response = await ns.fetch(request)
|
|
837
|
+
|
|
838
|
+
expect(response.status).toBe(200)
|
|
839
|
+
const nouns = await response.json()
|
|
840
|
+
expect(nouns).toHaveLength(2)
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
it('GET /nouns/:name should get a specific noun', async () => {
|
|
844
|
+
await ns.defineNoun({ name: 'Post' })
|
|
845
|
+
|
|
846
|
+
const request = createRequest('GET', '/nouns/Post')
|
|
847
|
+
const response = await ns.fetch(request)
|
|
848
|
+
|
|
849
|
+
expect(response.status).toBe(200)
|
|
850
|
+
const noun = await response.json()
|
|
851
|
+
expect(noun.name).toBe('Post')
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
it('GET /nouns/:name should return 404 for unknown noun', async () => {
|
|
855
|
+
const request = createRequest('GET', '/nouns/Unknown')
|
|
856
|
+
const response = await ns.fetch(request)
|
|
857
|
+
|
|
858
|
+
expect(response.status).toBe(404)
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
it('GET /nouns/:name should handle URL-encoded names', async () => {
|
|
862
|
+
await ns.defineNoun({ name: 'Blog Post' })
|
|
863
|
+
|
|
864
|
+
const request = createRequest('GET', '/nouns/Blog%20Post')
|
|
865
|
+
const response = await ns.fetch(request)
|
|
866
|
+
|
|
867
|
+
expect(response.status).toBe(200)
|
|
868
|
+
const noun = await response.json()
|
|
869
|
+
expect(noun.name).toBe('Blog Post')
|
|
870
|
+
})
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
describe('Verb Routes', () => {
|
|
874
|
+
it('POST /verbs should define a verb', async () => {
|
|
875
|
+
const request = createRequest('POST', '/verbs', { name: 'create' })
|
|
876
|
+
const response = await ns.fetch(request)
|
|
877
|
+
|
|
878
|
+
expect(response.status).toBe(200)
|
|
879
|
+
const verb = await response.json()
|
|
880
|
+
expect(verb.name).toBe('create')
|
|
881
|
+
expect(verb.event).toBe('created')
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
it('GET /verbs should list all verbs', async () => {
|
|
885
|
+
await ns.defineVerb({ name: 'create' })
|
|
886
|
+
await ns.defineVerb({ name: 'update' })
|
|
887
|
+
|
|
888
|
+
const request = createRequest('GET', '/verbs')
|
|
889
|
+
const response = await ns.fetch(request)
|
|
890
|
+
|
|
891
|
+
expect(response.status).toBe(200)
|
|
892
|
+
const verbs = await response.json()
|
|
893
|
+
expect(verbs).toHaveLength(2)
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
it('GET /verbs/:name should get a specific verb', async () => {
|
|
897
|
+
await ns.defineVerb({ name: 'publish' })
|
|
898
|
+
|
|
899
|
+
const request = createRequest('GET', '/verbs/publish')
|
|
900
|
+
const response = await ns.fetch(request)
|
|
901
|
+
|
|
902
|
+
expect(response.status).toBe(200)
|
|
903
|
+
const verb = await response.json()
|
|
904
|
+
expect(verb.name).toBe('publish')
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
it('GET /verbs/:name should return 404 for unknown verb', async () => {
|
|
908
|
+
const request = createRequest('GET', '/verbs/unknown')
|
|
909
|
+
const response = await ns.fetch(request)
|
|
910
|
+
|
|
911
|
+
expect(response.status).toBe(404)
|
|
912
|
+
})
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
describe('Thing Routes', () => {
|
|
916
|
+
it('POST /things should create a thing', async () => {
|
|
917
|
+
const request = createRequest('POST', '/things', {
|
|
918
|
+
noun: 'Post',
|
|
919
|
+
data: { title: 'Hello' },
|
|
920
|
+
})
|
|
921
|
+
const response = await ns.fetch(request)
|
|
922
|
+
|
|
923
|
+
expect(response.status).toBe(200)
|
|
924
|
+
const thing = await response.json()
|
|
925
|
+
expect(thing.noun).toBe('Post')
|
|
926
|
+
expect(thing.data.title).toBe('Hello')
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
it('POST /things should support custom ID', async () => {
|
|
930
|
+
const request = createRequest('POST', '/things', {
|
|
931
|
+
noun: 'Post',
|
|
932
|
+
data: { title: 'Custom' },
|
|
933
|
+
id: 'my-id',
|
|
934
|
+
})
|
|
935
|
+
const response = await ns.fetch(request)
|
|
936
|
+
|
|
937
|
+
expect(response.status).toBe(200)
|
|
938
|
+
const thing = await response.json()
|
|
939
|
+
expect(thing.id).toBe('my-id')
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
it('GET /things/:id should get a thing', async () => {
|
|
943
|
+
const created = await ns.create('Post', { title: 'Test' })
|
|
944
|
+
|
|
945
|
+
const request = createRequest('GET', `/things/${created.id}`)
|
|
946
|
+
const response = await ns.fetch(request)
|
|
947
|
+
|
|
948
|
+
expect(response.status).toBe(200)
|
|
949
|
+
const thing = await response.json()
|
|
950
|
+
expect(thing.id).toBe(created.id)
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
it('GET /things/:id should return 404 for unknown thing', async () => {
|
|
954
|
+
const request = createRequest('GET', '/things/nonexistent')
|
|
955
|
+
const response = await ns.fetch(request)
|
|
956
|
+
|
|
957
|
+
expect(response.status).toBe(404)
|
|
958
|
+
})
|
|
959
|
+
|
|
960
|
+
it('GET /things should list things by noun', async () => {
|
|
961
|
+
await ns.create('Post', { title: 'First' })
|
|
962
|
+
await ns.create('Post', { title: 'Second' })
|
|
963
|
+
|
|
964
|
+
const request = createRequest('GET', '/things', undefined, { noun: 'Post' })
|
|
965
|
+
const response = await ns.fetch(request)
|
|
966
|
+
|
|
967
|
+
expect(response.status).toBe(200)
|
|
968
|
+
const things = await response.json()
|
|
969
|
+
expect(things).toHaveLength(2)
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
it('GET /things without noun should return 400', async () => {
|
|
973
|
+
const request = createRequest('GET', '/things')
|
|
974
|
+
const response = await ns.fetch(request)
|
|
975
|
+
|
|
976
|
+
expect(response.status).toBe(400)
|
|
977
|
+
expect(await response.text()).toBe('noun parameter required')
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
it('GET /things should support pagination params', async () => {
|
|
981
|
+
await ns.create('Post', { title: '1' })
|
|
982
|
+
await ns.create('Post', { title: '2' })
|
|
983
|
+
await ns.create('Post', { title: '3' })
|
|
984
|
+
|
|
985
|
+
const request = createRequest('GET', '/things', undefined, {
|
|
986
|
+
noun: 'Post',
|
|
987
|
+
limit: '2',
|
|
988
|
+
})
|
|
989
|
+
const response = await ns.fetch(request)
|
|
990
|
+
|
|
991
|
+
expect(response.status).toBe(200)
|
|
992
|
+
const things = await response.json()
|
|
993
|
+
expect(things).toHaveLength(2)
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
it('PATCH /things/:id should update a thing', async () => {
|
|
997
|
+
const created = await ns.create('Post', { title: 'Original' })
|
|
998
|
+
|
|
999
|
+
const request = createRequest('PATCH', `/things/${created.id}`, {
|
|
1000
|
+
data: { title: 'Updated' },
|
|
1001
|
+
})
|
|
1002
|
+
const response = await ns.fetch(request)
|
|
1003
|
+
|
|
1004
|
+
expect(response.status).toBe(200)
|
|
1005
|
+
const thing = await response.json()
|
|
1006
|
+
expect(thing.data.title).toBe('Updated')
|
|
1007
|
+
})
|
|
1008
|
+
|
|
1009
|
+
it('DELETE /things/:id should delete a thing', async () => {
|
|
1010
|
+
const created = await ns.create('Post', { title: 'ToDelete' })
|
|
1011
|
+
|
|
1012
|
+
const request = createRequest('DELETE', `/things/${created.id}`)
|
|
1013
|
+
const response = await ns.fetch(request)
|
|
1014
|
+
|
|
1015
|
+
expect(response.status).toBe(200)
|
|
1016
|
+
const result = await response.json()
|
|
1017
|
+
expect(result.deleted).toBe(true)
|
|
1018
|
+
})
|
|
1019
|
+
})
|
|
1020
|
+
|
|
1021
|
+
describe('Search Route', () => {
|
|
1022
|
+
it('GET /search should search things', async () => {
|
|
1023
|
+
await ns.create('Post', { title: 'Hello World' })
|
|
1024
|
+
await ns.create('Post', { title: 'Goodbye' })
|
|
1025
|
+
|
|
1026
|
+
const request = createRequest('GET', '/search', undefined, { q: 'hello' })
|
|
1027
|
+
const response = await ns.fetch(request)
|
|
1028
|
+
|
|
1029
|
+
expect(response.status).toBe(200)
|
|
1030
|
+
const results = await response.json()
|
|
1031
|
+
expect(results).toHaveLength(1)
|
|
1032
|
+
})
|
|
1033
|
+
|
|
1034
|
+
it('GET /search should support limit', async () => {
|
|
1035
|
+
await ns.create('Post', { title: 'Test 1' })
|
|
1036
|
+
await ns.create('Post', { title: 'Test 2' })
|
|
1037
|
+
await ns.create('Post', { title: 'Test 3' })
|
|
1038
|
+
|
|
1039
|
+
const request = createRequest('GET', '/search', undefined, { q: 'test', limit: '2' })
|
|
1040
|
+
const response = await ns.fetch(request)
|
|
1041
|
+
|
|
1042
|
+
expect(response.status).toBe(200)
|
|
1043
|
+
const results = await response.json()
|
|
1044
|
+
expect(results).toHaveLength(2)
|
|
1045
|
+
})
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
describe('Action Routes', () => {
|
|
1049
|
+
it('POST /actions should perform an action', async () => {
|
|
1050
|
+
const request = createRequest('POST', '/actions', {
|
|
1051
|
+
verb: 'like',
|
|
1052
|
+
subject: 'user-1',
|
|
1053
|
+
object: 'post-1',
|
|
1054
|
+
})
|
|
1055
|
+
const response = await ns.fetch(request)
|
|
1056
|
+
|
|
1057
|
+
expect(response.status).toBe(200)
|
|
1058
|
+
const action = await response.json()
|
|
1059
|
+
expect(action.verb).toBe('like')
|
|
1060
|
+
expect(action.status).toBe('completed')
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
it('POST /actions should support data payload', async () => {
|
|
1064
|
+
const request = createRequest('POST', '/actions', {
|
|
1065
|
+
verb: 'rate',
|
|
1066
|
+
subject: 'user-1',
|
|
1067
|
+
object: 'product-1',
|
|
1068
|
+
data: { stars: 5, comment: 'Great!' },
|
|
1069
|
+
})
|
|
1070
|
+
const response = await ns.fetch(request)
|
|
1071
|
+
|
|
1072
|
+
expect(response.status).toBe(200)
|
|
1073
|
+
const action = await response.json()
|
|
1074
|
+
expect(action.data.stars).toBe(5)
|
|
1075
|
+
})
|
|
1076
|
+
|
|
1077
|
+
it('GET /actions/:id should get an action', async () => {
|
|
1078
|
+
const created = await ns.perform('test', 'a', 'b')
|
|
1079
|
+
|
|
1080
|
+
const request = createRequest('GET', `/actions/${created.id}`)
|
|
1081
|
+
const response = await ns.fetch(request)
|
|
1082
|
+
|
|
1083
|
+
expect(response.status).toBe(200)
|
|
1084
|
+
const action = await response.json()
|
|
1085
|
+
expect(action.id).toBe(created.id)
|
|
1086
|
+
})
|
|
1087
|
+
|
|
1088
|
+
it('GET /actions/:id should return 404 for unknown action', async () => {
|
|
1089
|
+
const request = createRequest('GET', '/actions/nonexistent')
|
|
1090
|
+
const response = await ns.fetch(request)
|
|
1091
|
+
|
|
1092
|
+
expect(response.status).toBe(404)
|
|
1093
|
+
})
|
|
1094
|
+
|
|
1095
|
+
it('GET /actions should list actions with filters', async () => {
|
|
1096
|
+
await ns.perform('write', 'a1', 'p1')
|
|
1097
|
+
await ns.perform('like', 'u1', 'p1')
|
|
1098
|
+
|
|
1099
|
+
const request = createRequest('GET', '/actions', undefined, { verb: 'write' })
|
|
1100
|
+
const response = await ns.fetch(request)
|
|
1101
|
+
|
|
1102
|
+
expect(response.status).toBe(200)
|
|
1103
|
+
const actions = await response.json()
|
|
1104
|
+
expect(actions).toHaveLength(1)
|
|
1105
|
+
expect(actions[0].verb).toBe('write')
|
|
1106
|
+
})
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
describe('Graph Routes', () => {
|
|
1110
|
+
beforeEach(async () => {
|
|
1111
|
+
await ns.create('Author', { name: 'Alice' }, 'alice')
|
|
1112
|
+
await ns.create('Post', { title: 'Post 1' }, 'post-1')
|
|
1113
|
+
await ns.perform('write', 'alice', 'post-1')
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
it('GET /edges/:id should get edges', async () => {
|
|
1117
|
+
const request = createRequest('GET', '/edges/alice')
|
|
1118
|
+
const response = await ns.fetch(request)
|
|
1119
|
+
|
|
1120
|
+
expect(response.status).toBe(200)
|
|
1121
|
+
const edges = await response.json()
|
|
1122
|
+
expect(edges).toHaveLength(1)
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
it('GET /edges/:id should support verb filter', async () => {
|
|
1126
|
+
await ns.perform('like', 'alice', 'post-1')
|
|
1127
|
+
|
|
1128
|
+
const request = createRequest('GET', '/edges/alice', undefined, { verb: 'write' })
|
|
1129
|
+
const response = await ns.fetch(request)
|
|
1130
|
+
|
|
1131
|
+
expect(response.status).toBe(200)
|
|
1132
|
+
const edges = await response.json()
|
|
1133
|
+
expect(edges).toHaveLength(1)
|
|
1134
|
+
expect(edges[0].verb).toBe('write')
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1137
|
+
it('GET /edges/:id should support direction param', async () => {
|
|
1138
|
+
const request = createRequest('GET', '/edges/post-1', undefined, { direction: 'in' })
|
|
1139
|
+
const response = await ns.fetch(request)
|
|
1140
|
+
|
|
1141
|
+
expect(response.status).toBe(200)
|
|
1142
|
+
const edges = await response.json()
|
|
1143
|
+
expect(edges).toHaveLength(1)
|
|
1144
|
+
expect(edges[0].object).toBe('post-1')
|
|
1145
|
+
})
|
|
1146
|
+
|
|
1147
|
+
it('GET /related/:id should get related things', async () => {
|
|
1148
|
+
const request = createRequest('GET', '/related/alice')
|
|
1149
|
+
const response = await ns.fetch(request)
|
|
1150
|
+
|
|
1151
|
+
expect(response.status).toBe(200)
|
|
1152
|
+
const related = await response.json()
|
|
1153
|
+
expect(related).toHaveLength(1)
|
|
1154
|
+
expect(related[0].id).toBe('post-1')
|
|
1155
|
+
})
|
|
1156
|
+
|
|
1157
|
+
it('GET /related/:id should support verb and direction params', async () => {
|
|
1158
|
+
const request = createRequest('GET', '/related/post-1', undefined, {
|
|
1159
|
+
verb: 'write',
|
|
1160
|
+
direction: 'in',
|
|
1161
|
+
})
|
|
1162
|
+
const response = await ns.fetch(request)
|
|
1163
|
+
|
|
1164
|
+
expect(response.status).toBe(200)
|
|
1165
|
+
const related = await response.json()
|
|
1166
|
+
expect(related).toHaveLength(1)
|
|
1167
|
+
expect(related[0].id).toBe('alice')
|
|
1168
|
+
})
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
describe('Error Handling', () => {
|
|
1172
|
+
it('should return 404 for unknown routes', async () => {
|
|
1173
|
+
const request = createRequest('GET', '/unknown/route')
|
|
1174
|
+
const response = await ns.fetch(request)
|
|
1175
|
+
|
|
1176
|
+
expect(response.status).toBe(404)
|
|
1177
|
+
})
|
|
1178
|
+
|
|
1179
|
+
it('should return 404 for not found errors with proper error response', async () => {
|
|
1180
|
+
// Create a request that will cause a NotFoundError (update non-existent thing)
|
|
1181
|
+
const request = createRequest('PATCH', '/things/nonexistent', { data: { title: 'Test' } })
|
|
1182
|
+
const response = await ns.fetch(request)
|
|
1183
|
+
|
|
1184
|
+
expect(response.status).toBe(404)
|
|
1185
|
+
const body = await response.json()
|
|
1186
|
+
expect(body.error).toBe('NOT_FOUND')
|
|
1187
|
+
expect(body.message).toContain('Thing not found')
|
|
1188
|
+
})
|
|
1189
|
+
|
|
1190
|
+
it('should handle malformed JSON gracefully', async () => {
|
|
1191
|
+
const request = new Request('https://example.com/things', {
|
|
1192
|
+
method: 'POST',
|
|
1193
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1194
|
+
body: 'not valid json',
|
|
1195
|
+
})
|
|
1196
|
+
|
|
1197
|
+
const response = await ns.fetch(request)
|
|
1198
|
+
expect(response.status).toBe(500)
|
|
1199
|
+
})
|
|
1200
|
+
})
|
|
1201
|
+
})
|
|
1202
|
+
|
|
1203
|
+
describe('SQL Query Generation', () => {
|
|
1204
|
+
it('should use parameterized queries for nouns', async () => {
|
|
1205
|
+
await ns.defineNoun({ name: "Test'; DROP TABLE nouns;--" })
|
|
1206
|
+
|
|
1207
|
+
// The SQL should use parameterized queries, not string interpolation
|
|
1208
|
+
const insertCall = mockSql.exec.mock.calls.find((call) => {
|
|
1209
|
+
const sql = call[0] as string
|
|
1210
|
+
return sql.includes('INSERT OR REPLACE INTO nouns')
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
expect(insertCall).toBeDefined()
|
|
1214
|
+
// Parameters should be passed separately, not interpolated into the SQL
|
|
1215
|
+
expect(insertCall![1]).toBe("Test'; DROP TABLE nouns;--")
|
|
1216
|
+
})
|
|
1217
|
+
|
|
1218
|
+
it('should use parameterized queries for things', async () => {
|
|
1219
|
+
await ns.create('Post', { title: "Test'; DROP TABLE things;--" })
|
|
1220
|
+
|
|
1221
|
+
const insertCall = mockSql.exec.mock.calls.find((call) => {
|
|
1222
|
+
const sql = call[0] as string
|
|
1223
|
+
return sql.includes('INSERT INTO things')
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
expect(insertCall).toBeDefined()
|
|
1227
|
+
// Data is JSON stringified and passed as parameter at index 3 (id, noun, data, ...)
|
|
1228
|
+
const dataParam = insertCall![3] as string
|
|
1229
|
+
expect(dataParam).toContain("Test'; DROP TABLE things;--")
|
|
1230
|
+
})
|
|
1231
|
+
|
|
1232
|
+
it('should use parameterized queries for actions', async () => {
|
|
1233
|
+
await ns.perform('test', "subject'; DROP TABLE actions;--", 'object')
|
|
1234
|
+
|
|
1235
|
+
const insertCall = mockSql.exec.mock.calls.find((call) => {
|
|
1236
|
+
const sql = call[0] as string
|
|
1237
|
+
return sql.includes('INSERT INTO actions')
|
|
1238
|
+
})
|
|
1239
|
+
|
|
1240
|
+
expect(insertCall).toBeDefined()
|
|
1241
|
+
// Subject is at index 3 (id, verb, subject, object, ...)
|
|
1242
|
+
expect(insertCall![3]).toBe("subject'; DROP TABLE actions;--")
|
|
1243
|
+
})
|
|
1244
|
+
|
|
1245
|
+
it('should use parameterized queries for search', async () => {
|
|
1246
|
+
await ns.search("'; DROP TABLE things;--")
|
|
1247
|
+
|
|
1248
|
+
const searchCall = mockSql.exec.mock.calls.find((call) => {
|
|
1249
|
+
const sql = call[0] as string
|
|
1250
|
+
return sql.includes('LIKE ?')
|
|
1251
|
+
})
|
|
1252
|
+
|
|
1253
|
+
expect(searchCall).toBeDefined()
|
|
1254
|
+
// The query should be passed as a parameter (wrapped with % for LIKE)
|
|
1255
|
+
const queryParam = searchCall![1] as string
|
|
1256
|
+
expect(queryParam.toLowerCase()).toContain("'; drop table things;--")
|
|
1257
|
+
})
|
|
1258
|
+
})
|
|
1259
|
+
|
|
1260
|
+
describe('Data Serialization', () => {
|
|
1261
|
+
it('should properly serialize and deserialize JSON data in things', async () => {
|
|
1262
|
+
const complexData = {
|
|
1263
|
+
nested: { deep: { value: 123 } },
|
|
1264
|
+
array: [1, 2, 3],
|
|
1265
|
+
boolean: true,
|
|
1266
|
+
nullValue: null,
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const created = await ns.create('Test', complexData)
|
|
1270
|
+
const retrieved = await ns.get(created.id)
|
|
1271
|
+
|
|
1272
|
+
expect(retrieved!.data).toEqual(complexData)
|
|
1273
|
+
})
|
|
1274
|
+
|
|
1275
|
+
it('should properly serialize and deserialize schema in nouns', async () => {
|
|
1276
|
+
const schema = {
|
|
1277
|
+
title: 'string' as const,
|
|
1278
|
+
body: 'markdown' as const,
|
|
1279
|
+
published: 'boolean' as const,
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
await ns.defineNoun({ name: 'Article', schema })
|
|
1283
|
+
const noun = await ns.getNoun('Article')
|
|
1284
|
+
|
|
1285
|
+
expect(noun!.schema).toEqual(schema)
|
|
1286
|
+
})
|
|
1287
|
+
|
|
1288
|
+
it('should properly serialize and deserialize action data', async () => {
|
|
1289
|
+
const actionData = {
|
|
1290
|
+
metadata: { source: 'api', version: '1.0' },
|
|
1291
|
+
timestamp: 1234567890,
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const action = await ns.perform('test', 'subject', 'object', actionData)
|
|
1295
|
+
const retrieved = await ns.getAction(action.id)
|
|
1296
|
+
|
|
1297
|
+
expect(retrieved!.data).toEqual(actionData)
|
|
1298
|
+
})
|
|
1299
|
+
|
|
1300
|
+
it('should handle undefined schema gracefully', async () => {
|
|
1301
|
+
await ns.defineNoun({ name: 'Simple' })
|
|
1302
|
+
const noun = await ns.getNoun('Simple')
|
|
1303
|
+
|
|
1304
|
+
expect(noun!.schema).toBeUndefined()
|
|
1305
|
+
})
|
|
1306
|
+
|
|
1307
|
+
it('should handle undefined action data gracefully', async () => {
|
|
1308
|
+
const action = await ns.perform('test', 'subject', 'object')
|
|
1309
|
+
const retrieved = await ns.getAction(action.id)
|
|
1310
|
+
|
|
1311
|
+
expect(retrieved!.data).toBeUndefined()
|
|
1312
|
+
})
|
|
1313
|
+
})
|
|
1314
|
+
|
|
1315
|
+
describe('Edge Cases', () => {
|
|
1316
|
+
it('should handle empty string IDs', async () => {
|
|
1317
|
+
const thing = await ns.create('Post', { title: 'Test' }, '')
|
|
1318
|
+
expect(thing.id).toBe('')
|
|
1319
|
+
|
|
1320
|
+
const retrieved = await ns.get('')
|
|
1321
|
+
expect(retrieved!.data.title).toBe('Test')
|
|
1322
|
+
})
|
|
1323
|
+
|
|
1324
|
+
it('should handle special characters in noun names', async () => {
|
|
1325
|
+
const noun = await ns.defineNoun({ name: 'Blog Post Category' })
|
|
1326
|
+
expect(noun.slug).toBe('blog-post-category')
|
|
1327
|
+
|
|
1328
|
+
const retrieved = await ns.getNoun('Blog Post Category')
|
|
1329
|
+
expect(retrieved!.name).toBe('Blog Post Category')
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1332
|
+
it('should handle empty data objects', async () => {
|
|
1333
|
+
const thing = await ns.create('Empty', {})
|
|
1334
|
+
expect(thing.data).toEqual({})
|
|
1335
|
+
|
|
1336
|
+
const retrieved = await ns.get(thing.id)
|
|
1337
|
+
expect(retrieved!.data).toEqual({})
|
|
1338
|
+
})
|
|
1339
|
+
|
|
1340
|
+
it('should handle very long strings in data', async () => {
|
|
1341
|
+
const longString = 'a'.repeat(10000)
|
|
1342
|
+
const thing = await ns.create('Post', { content: longString })
|
|
1343
|
+
|
|
1344
|
+
const retrieved = await ns.get(thing.id)
|
|
1345
|
+
expect(retrieved!.data.content).toBe(longString)
|
|
1346
|
+
})
|
|
1347
|
+
|
|
1348
|
+
it('should handle unicode characters', async () => {
|
|
1349
|
+
const unicodeData = {
|
|
1350
|
+
emoji: '\u{1F600}\u{1F389}',
|
|
1351
|
+
japanese: '\u3053\u3093\u306B\u3061\u306F',
|
|
1352
|
+
arabic: '\u0645\u0631\u062D\u0628\u0627',
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
const thing = await ns.create('Unicode', unicodeData)
|
|
1356
|
+
const retrieved = await ns.get(thing.id)
|
|
1357
|
+
|
|
1358
|
+
expect(retrieved!.data).toEqual(unicodeData)
|
|
1359
|
+
})
|
|
1360
|
+
|
|
1361
|
+
it('should handle concurrent operations', async () => {
|
|
1362
|
+
// Create multiple things concurrently
|
|
1363
|
+
const promises = Array.from({ length: 10 }, (_, i) =>
|
|
1364
|
+
ns.create('Post', { title: `Post ${i}` })
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
const results = await Promise.all(promises)
|
|
1368
|
+
expect(results).toHaveLength(10)
|
|
1369
|
+
|
|
1370
|
+
// All should have unique IDs
|
|
1371
|
+
const ids = new Set(results.map((r) => r.id))
|
|
1372
|
+
expect(ids.size).toBe(10)
|
|
1373
|
+
})
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
describe('close() method', () => {
|
|
1377
|
+
it('should be a no-op and resolve successfully', async () => {
|
|
1378
|
+
await expect(ns.close()).resolves.toBeUndefined()
|
|
1379
|
+
})
|
|
1380
|
+
})
|
|
1381
|
+
})
|