@avelonjs/conformance 0.1.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/LICENSE +21 -0
- package/README.md +155 -0
- package/package.json +50 -0
- package/src/fakes/ai.ts +113 -0
- package/src/fakes/cache.ts +153 -0
- package/src/fakes/database.ts +612 -0
- package/src/fakes/flags.ts +67 -0
- package/src/fakes/identity.ts +253 -0
- package/src/fakes/logs.ts +101 -0
- package/src/fakes/mail.ts +140 -0
- package/src/fakes/notifications.ts +73 -0
- package/src/fakes/payments.ts +188 -0
- package/src/fakes/queue.ts +205 -0
- package/src/fakes/ratelimit.ts +143 -0
- package/src/fakes/realtime.ts +96 -0
- package/src/fakes/search.ts +127 -0
- package/src/fakes/social.ts +83 -0
- package/src/fakes/storage.ts +125 -0
- package/src/fakes/tokens.ts +106 -0
- package/src/harness.ts +103 -0
- package/src/index.ts +21 -0
- package/src/suites/ai.ts +167 -0
- package/src/suites/cache.ts +174 -0
- package/src/suites/database.ts +853 -0
- package/src/suites/flags.ts +80 -0
- package/src/suites/identity.ts +293 -0
- package/src/suites/index.ts +16 -0
- package/src/suites/logs.ts +116 -0
- package/src/suites/mail.ts +160 -0
- package/src/suites/notifications.ts +119 -0
- package/src/suites/payments.ts +200 -0
- package/src/suites/queue.ts +238 -0
- package/src/suites/ratelimit.ts +134 -0
- package/src/suites/realtime.ts +123 -0
- package/src/suites/search.ts +115 -0
- package/src/suites/social.ts +129 -0
- package/src/suites/storage.ts +158 -0
- package/src/suites/tokens.ts +105 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Conflict,
|
|
3
|
+
Invalid,
|
|
4
|
+
type DatabaseDriver,
|
|
5
|
+
type DatabaseTransaction,
|
|
6
|
+
type MigrationPlan,
|
|
7
|
+
type MigrationStatus,
|
|
8
|
+
type OrderTerm,
|
|
9
|
+
type Predicate,
|
|
10
|
+
type QueryIR,
|
|
11
|
+
type QueryResult,
|
|
12
|
+
type RelationLoad,
|
|
13
|
+
type TransactionSurface,
|
|
14
|
+
} from '@avelonjs/core'
|
|
15
|
+
|
|
16
|
+
const capabilities = {
|
|
17
|
+
transactions: true,
|
|
18
|
+
rowSecurity: false,
|
|
19
|
+
maxRelationDepth: 2,
|
|
20
|
+
fullTextSearch: false,
|
|
21
|
+
upsert: true,
|
|
22
|
+
returning: true,
|
|
23
|
+
windowFunctions: false,
|
|
24
|
+
jsonOperators: false,
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
const schema = {
|
|
28
|
+
assay_users: ['id', 'email', 'name', 'age', 'nickname'],
|
|
29
|
+
assay_profiles: ['id', 'user_id', 'bio'],
|
|
30
|
+
assay_posts: ['id', 'user_id', 'title', 'score', 'published_at'],
|
|
31
|
+
assay_comments: ['id', 'post_id', 'body', 'position'],
|
|
32
|
+
assay_reactions: ['id', 'comment_id', 'kind'],
|
|
33
|
+
users: ['id', 'email', 'name'],
|
|
34
|
+
posts: [
|
|
35
|
+
'id',
|
|
36
|
+
'user_id',
|
|
37
|
+
'title',
|
|
38
|
+
'body',
|
|
39
|
+
'published_at',
|
|
40
|
+
'created_at',
|
|
41
|
+
'updated_at',
|
|
42
|
+
'deleted_at',
|
|
43
|
+
],
|
|
44
|
+
subscribers: ['id', 'email', 'created_at'],
|
|
45
|
+
} as const
|
|
46
|
+
|
|
47
|
+
const uniqueColumns = {
|
|
48
|
+
assay_users: [['id'], ['email']],
|
|
49
|
+
assay_profiles: [['id'], ['user_id']],
|
|
50
|
+
assay_posts: [['id']],
|
|
51
|
+
assay_comments: [['id']],
|
|
52
|
+
assay_reactions: [['id']],
|
|
53
|
+
users: [['id'], ['email']],
|
|
54
|
+
posts: [['id']],
|
|
55
|
+
subscribers: [['id'], ['email']],
|
|
56
|
+
} as const
|
|
57
|
+
|
|
58
|
+
type TableName = keyof typeof schema
|
|
59
|
+
type Row = Record<string, unknown>
|
|
60
|
+
type TruthValue = boolean | null
|
|
61
|
+
|
|
62
|
+
const tableNames = Object.keys(schema) as TableName[]
|
|
63
|
+
|
|
64
|
+
function invalid(message: string, field: string, detail: string): Invalid {
|
|
65
|
+
return new Invalid(message, { metadata: { fields: { [field]: [detail] } } })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function emptyTables(): Record<TableName, Row[]> {
|
|
69
|
+
return Object.fromEntries(tableNames.map((table) => [table, [] as Row[]])) as Record<
|
|
70
|
+
TableName,
|
|
71
|
+
Row[]
|
|
72
|
+
>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isTableName(table: string): table is TableName {
|
|
76
|
+
return Object.hasOwn(schema, table)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hasColumn(table: TableName, column: string): boolean {
|
|
80
|
+
return schema[table].some((candidate) => candidate === column)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cloneRow(row: Row): Row {
|
|
84
|
+
return structuredClone(row)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function compare(left: unknown, op: QueryIRCompareOp, right: unknown): TruthValue {
|
|
88
|
+
if (left === null || left === undefined || right === null || right === undefined) return null
|
|
89
|
+
|
|
90
|
+
switch (op) {
|
|
91
|
+
case '=':
|
|
92
|
+
return left === right
|
|
93
|
+
case '!=':
|
|
94
|
+
return left !== right
|
|
95
|
+
case '<':
|
|
96
|
+
return relationalCompare(left, right, (result) => result < 0)
|
|
97
|
+
case '<=':
|
|
98
|
+
return relationalCompare(left, right, (result) => result <= 0)
|
|
99
|
+
case '>':
|
|
100
|
+
return relationalCompare(left, right, (result) => result > 0)
|
|
101
|
+
case '>=':
|
|
102
|
+
return relationalCompare(left, right, (result) => result >= 0)
|
|
103
|
+
case 'like':
|
|
104
|
+
return like(left, right, false)
|
|
105
|
+
case 'ilike':
|
|
106
|
+
return like(left, right, true)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type QueryIRCompareOp = Extract<Predicate, { kind: 'compare' }>['op']
|
|
111
|
+
|
|
112
|
+
function relationalCompare(
|
|
113
|
+
left: unknown,
|
|
114
|
+
right: unknown,
|
|
115
|
+
predicate: (result: number) => boolean,
|
|
116
|
+
): boolean {
|
|
117
|
+
if (typeof left === 'number' && typeof right === 'number') return predicate(left - right)
|
|
118
|
+
if (typeof left === 'string' && typeof right === 'string')
|
|
119
|
+
return predicate(left.localeCompare(right))
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function like(left: unknown, right: unknown, insensitive: boolean): boolean {
|
|
124
|
+
if (typeof left !== 'string' || typeof right !== 'string') return false
|
|
125
|
+
const escaped = right.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
126
|
+
const pattern = `^${escaped.replaceAll('%', '.*').replaceAll('_', '.')}$`
|
|
127
|
+
return new RegExp(pattern, insensitive ? 'i' : undefined).test(left)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function not(value: TruthValue): TruthValue {
|
|
131
|
+
return value === null ? null : !value
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function and(values: readonly TruthValue[]): TruthValue {
|
|
135
|
+
if (values.includes(false)) return false
|
|
136
|
+
if (values.includes(null)) return null
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function or(values: readonly TruthValue[]): TruthValue {
|
|
141
|
+
if (values.includes(true)) return true
|
|
142
|
+
if (values.includes(null)) return null
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function evaluate(row: Row, predicate: Predicate): TruthValue {
|
|
147
|
+
switch (predicate.kind) {
|
|
148
|
+
case 'const':
|
|
149
|
+
return predicate.value
|
|
150
|
+
case 'compare':
|
|
151
|
+
return compare(row[predicate.column], predicate.op, predicate.value)
|
|
152
|
+
case 'null': {
|
|
153
|
+
const result = row[predicate.column] === null || row[predicate.column] === undefined
|
|
154
|
+
return predicate.negated ? !result : result
|
|
155
|
+
}
|
|
156
|
+
case 'in': {
|
|
157
|
+
if (predicate.values.length === 0) return predicate.negated
|
|
158
|
+
const value = row[predicate.column]
|
|
159
|
+
if (value === null || value === undefined) return null
|
|
160
|
+
if (predicate.values.some((candidate) => candidate === value)) return !predicate.negated
|
|
161
|
+
const result: TruthValue = predicate.values.some((candidate) => candidate === null)
|
|
162
|
+
? null
|
|
163
|
+
: false
|
|
164
|
+
return predicate.negated ? not(result) : result
|
|
165
|
+
}
|
|
166
|
+
case 'and':
|
|
167
|
+
return and(predicate.predicates.map((entry) => evaluate(row, entry)))
|
|
168
|
+
case 'or':
|
|
169
|
+
return or(predicate.predicates.map((entry) => evaluate(row, entry)))
|
|
170
|
+
case 'not':
|
|
171
|
+
return not(evaluate(row, predicate.predicate))
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function constant(predicate: Predicate): boolean | undefined {
|
|
176
|
+
switch (predicate.kind) {
|
|
177
|
+
case 'const':
|
|
178
|
+
return predicate.value
|
|
179
|
+
case 'in':
|
|
180
|
+
return predicate.values.length === 0 ? predicate.negated : undefined
|
|
181
|
+
case 'not': {
|
|
182
|
+
const value = constant(predicate.predicate)
|
|
183
|
+
return value === undefined ? undefined : !value
|
|
184
|
+
}
|
|
185
|
+
case 'and': {
|
|
186
|
+
const values = predicate.predicates.map(constant)
|
|
187
|
+
if (values.includes(false)) return false
|
|
188
|
+
return values.every((value) => value === true) ? true : undefined
|
|
189
|
+
}
|
|
190
|
+
case 'or': {
|
|
191
|
+
const values = predicate.predicates.map(constant)
|
|
192
|
+
if (values.includes(true)) return true
|
|
193
|
+
return values.every((value) => value === false) ? false : undefined
|
|
194
|
+
}
|
|
195
|
+
default:
|
|
196
|
+
return undefined
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isConstantFalse(query: QueryIR): boolean {
|
|
201
|
+
return [...query.where, ...(query.ward ? [query.ward] : [])].some(
|
|
202
|
+
(predicate) => constant(predicate) === false,
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function validatePredicate(table: TableName, predicate: Predicate): void {
|
|
207
|
+
switch (predicate.kind) {
|
|
208
|
+
case 'compare':
|
|
209
|
+
case 'null':
|
|
210
|
+
case 'in':
|
|
211
|
+
validateColumn(table, predicate.column, 'predicate')
|
|
212
|
+
return
|
|
213
|
+
case 'and':
|
|
214
|
+
case 'or':
|
|
215
|
+
predicate.predicates.forEach((entry) => validatePredicate(table, entry))
|
|
216
|
+
return
|
|
217
|
+
case 'not':
|
|
218
|
+
validatePredicate(table, predicate.predicate)
|
|
219
|
+
return
|
|
220
|
+
case 'const':
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function validateColumn(table: TableName, column: string, field: string): void {
|
|
226
|
+
if (!hasColumn(table, column)) {
|
|
227
|
+
throw invalid(`Unknown column ${column} on ${table}.`, field, `Unknown identifier: ${column}`)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function validateColumns(table: TableName, columns: readonly string[] | '*', field: string): void {
|
|
232
|
+
if (columns === '*') return
|
|
233
|
+
columns.forEach((column) => validateColumn(table, column, field))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function validateOrder(table: TableName, order: readonly OrderTerm[]): void {
|
|
237
|
+
order.forEach((term) => validateColumn(table, term.column, 'order'))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function validateRelation(parentTable: TableName, relation: RelationLoad, depth: number): void {
|
|
241
|
+
if (depth > capabilities.maxRelationDepth) {
|
|
242
|
+
throw invalid(
|
|
243
|
+
`Relation depth ${depth} exceeds the declared maximum.`,
|
|
244
|
+
'relations',
|
|
245
|
+
`Maximum relation depth is ${capabilities.maxRelationDepth}`,
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
if (!isTableName(relation.table)) {
|
|
249
|
+
throw invalid(`Unknown relation table ${relation.table}.`, 'relations', 'Unknown identifier')
|
|
250
|
+
}
|
|
251
|
+
const relatedTable = relation.table
|
|
252
|
+
validateColumn(parentTable, relation.localKey, 'relations')
|
|
253
|
+
validateColumn(relatedTable, relation.foreignKey, 'relations')
|
|
254
|
+
validateColumns(relatedTable, relation.select, 'relations')
|
|
255
|
+
validateOrder(relatedTable, relation.order)
|
|
256
|
+
if (relation.where) validatePredicate(relatedTable, relation.where)
|
|
257
|
+
if (relation.limit !== undefined && (!Number.isInteger(relation.limit) || relation.limit < 0)) {
|
|
258
|
+
throw invalid('Relation limit must be a non-negative integer.', 'relations', 'Invalid limit')
|
|
259
|
+
}
|
|
260
|
+
relation.relations.forEach((nested) => validateRelation(relatedTable, nested, depth + 1))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function validateValues(
|
|
264
|
+
table: TableName,
|
|
265
|
+
values: QueryIR['values'],
|
|
266
|
+
): asserts values is Row | Row[] {
|
|
267
|
+
if (values === undefined || (Array.isArray(values) && values.length === 0)) {
|
|
268
|
+
throw invalid('Write operations require values.', 'values', 'Expected one or more rows')
|
|
269
|
+
}
|
|
270
|
+
const rows = Array.isArray(values) ? values : [values]
|
|
271
|
+
rows.forEach((row) =>
|
|
272
|
+
Object.keys(row).forEach((column) => validateColumn(table, column, 'values')),
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function validateQuery(query: QueryIR): TableName {
|
|
277
|
+
if (!isTableName(query.table)) {
|
|
278
|
+
throw invalid(`Unknown table ${query.table}.`, 'table', 'Unknown identifier')
|
|
279
|
+
}
|
|
280
|
+
const table = query.table
|
|
281
|
+
validateColumns(table, query.select, 'select')
|
|
282
|
+
query.where.forEach((predicate) => validatePredicate(table, predicate))
|
|
283
|
+
if (query.ward) validatePredicate(table, query.ward)
|
|
284
|
+
validateOrder(table, query.order)
|
|
285
|
+
query.relations.forEach((relation) => {
|
|
286
|
+
validateRelation(table, relation, 1)
|
|
287
|
+
})
|
|
288
|
+
if (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit < 0)) {
|
|
289
|
+
throw invalid('Limit must be a non-negative integer.', 'limit', 'Invalid limit')
|
|
290
|
+
}
|
|
291
|
+
if (query.offset !== undefined && (!Number.isInteger(query.offset) || query.offset < 0)) {
|
|
292
|
+
throw invalid('Offset must be a non-negative integer.', 'offset', 'Invalid offset')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (query.mode === 'count') {
|
|
296
|
+
const hasProjection = query.select === '*' || query.select.length > 0
|
|
297
|
+
if (
|
|
298
|
+
hasProjection ||
|
|
299
|
+
query.relations.length > 0 ||
|
|
300
|
+
query.order.length > 0 ||
|
|
301
|
+
query.limit !== undefined ||
|
|
302
|
+
query.offset !== undefined ||
|
|
303
|
+
query.returning !== undefined
|
|
304
|
+
) {
|
|
305
|
+
throw invalid(
|
|
306
|
+
'Count queries cannot carry result shaping.',
|
|
307
|
+
'mode',
|
|
308
|
+
'Use an empty select, relations, and order with no limit, offset, or returning',
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (query.returning !== undefined) validateColumns(table, query.returning, 'returning')
|
|
314
|
+
|
|
315
|
+
if (query.mode === 'insert' || query.mode === 'update' || query.mode === 'upsert') {
|
|
316
|
+
validateValues(table, query.values)
|
|
317
|
+
}
|
|
318
|
+
if (query.mode === 'update' && Array.isArray(query.values)) {
|
|
319
|
+
throw invalid('Update values must be one object.', 'values', 'Expected one row')
|
|
320
|
+
}
|
|
321
|
+
if (query.mode === 'upsert') {
|
|
322
|
+
if (!query.conflict) {
|
|
323
|
+
throw invalid('Upsert requires a conflict target.', 'conflict', 'Required for upsert')
|
|
324
|
+
}
|
|
325
|
+
query.conflict.columns.forEach((column) => validateColumn(table, column, 'conflict'))
|
|
326
|
+
if (query.conflict.update !== '*') {
|
|
327
|
+
query.conflict.update.forEach((column) => validateColumn(table, column, 'conflict'))
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return table
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function matches(row: Row, query: Pick<QueryIR, 'where' | 'ward'>): boolean {
|
|
334
|
+
const predicates = query.ward ? [...query.where, query.ward] : query.where
|
|
335
|
+
return and(predicates.map((predicate) => evaluate(row, predicate))) === true
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function compareRows(left: Row, right: Row, order: readonly OrderTerm[]): number {
|
|
339
|
+
for (const term of order) {
|
|
340
|
+
const leftValue = left[term.column]
|
|
341
|
+
const rightValue = right[term.column]
|
|
342
|
+
const leftNull = leftValue === null || leftValue === undefined
|
|
343
|
+
const rightNull = rightValue === null || rightValue === undefined
|
|
344
|
+
if (leftNull || rightNull) {
|
|
345
|
+
if (leftNull && rightNull) continue
|
|
346
|
+
const nullResult = term.nulls === 'first' ? -1 : 1
|
|
347
|
+
return leftNull ? nullResult : -nullResult
|
|
348
|
+
}
|
|
349
|
+
const result = relationalCompare(leftValue, rightValue, (comparison) => comparison < 0)
|
|
350
|
+
? -1
|
|
351
|
+
: relationalCompare(leftValue, rightValue, (comparison) => comparison > 0)
|
|
352
|
+
? 1
|
|
353
|
+
: 0
|
|
354
|
+
if (result !== 0) return term.direction === 'asc' ? result : -result
|
|
355
|
+
}
|
|
356
|
+
return 0
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function project(row: Row, columns: readonly string[] | '*'): Row {
|
|
360
|
+
if (columns === '*') return cloneRow(row)
|
|
361
|
+
return Object.fromEntries(columns.map((column) => [column, row[column]]))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** In-memory database reference implementation used by conformance and application tests. */
|
|
365
|
+
export class FakeDatabase
|
|
366
|
+
implements DatabaseDriver<typeof capabilities, { roundTrips: number }>, TransactionSurface
|
|
367
|
+
{
|
|
368
|
+
/** Driver implementation name. */
|
|
369
|
+
readonly name = 'fake'
|
|
370
|
+
|
|
371
|
+
/** Configured connection name. */
|
|
372
|
+
readonly instance = 'default'
|
|
373
|
+
|
|
374
|
+
/** Exact optional-feature declaration. */
|
|
375
|
+
readonly capabilities = capabilities
|
|
376
|
+
|
|
377
|
+
readonly #tables: Record<TableName, Row[]> = emptyTables()
|
|
378
|
+
|
|
379
|
+
#roundTrips = 0
|
|
380
|
+
|
|
381
|
+
/** Number of operations that reached the in-memory backing store. */
|
|
382
|
+
get roundTrips(): number {
|
|
383
|
+
return this.#roundTrips
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Returns an observable representation of the in-memory client. */
|
|
387
|
+
raw(): { roundTrips: number } {
|
|
388
|
+
return { roundTrips: this.#roundTrips }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Compiles and executes one query against the in-memory tables. */
|
|
392
|
+
async execute<TRow = Record<string, unknown>>(query: QueryIR): Promise<QueryResult<TRow>> {
|
|
393
|
+
const result = await this.#execute(query)
|
|
394
|
+
// `TRow` is selected by the caller at the contract's hydration boundary.
|
|
395
|
+
return result as QueryResult<TRow>
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async #execute(query: QueryIR): Promise<QueryResult> {
|
|
399
|
+
const table = validateQuery(query)
|
|
400
|
+
if (isConstantFalse(query)) {
|
|
401
|
+
return query.mode === 'count'
|
|
402
|
+
? { rows: [], affected: 0, count: 0 }
|
|
403
|
+
: { rows: [], affected: 0 }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
this.#roundTrips += 1
|
|
407
|
+
switch (query.mode) {
|
|
408
|
+
case 'select':
|
|
409
|
+
return { rows: this.#select(table, query), affected: 0 }
|
|
410
|
+
case 'count':
|
|
411
|
+
return {
|
|
412
|
+
// Count is scalar metadata, not a selected or affected row set.
|
|
413
|
+
rows: [],
|
|
414
|
+
affected: 0,
|
|
415
|
+
count: this.#tables[table].filter((row) => matches(row, query)).length,
|
|
416
|
+
}
|
|
417
|
+
case 'insert':
|
|
418
|
+
return this.#insert(table, query)
|
|
419
|
+
case 'update':
|
|
420
|
+
return this.#update(table, query)
|
|
421
|
+
case 'delete':
|
|
422
|
+
return this.#delete(table, query)
|
|
423
|
+
case 'upsert':
|
|
424
|
+
return this.#upsert(table, query)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
#select(table: TableName, query: QueryIR): Row[] {
|
|
429
|
+
let rows = this.#tables[table].filter((row) => matches(row, query)).map(cloneRow)
|
|
430
|
+
if (query.order.length > 0) rows.sort((left, right) => compareRows(left, right, query.order))
|
|
431
|
+
if (query.offset !== undefined) rows = rows.slice(query.offset)
|
|
432
|
+
if (query.limit !== undefined) rows = rows.slice(0, query.limit)
|
|
433
|
+
return rows
|
|
434
|
+
.map((row) => this.#loadRelations(row, query.relations))
|
|
435
|
+
.map((row) => {
|
|
436
|
+
const selected = project(row, query.select)
|
|
437
|
+
query.relations.forEach((relation) => {
|
|
438
|
+
selected[relation.relation] = row[relation.relation]
|
|
439
|
+
})
|
|
440
|
+
return selected
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
#loadRelations(parent: Row, relations: readonly RelationLoad[]): Row {
|
|
445
|
+
const loaded = cloneRow(parent)
|
|
446
|
+
for (const relation of relations) {
|
|
447
|
+
if (!isTableName(relation.table)) {
|
|
448
|
+
throw invalid(
|
|
449
|
+
`Unknown relation table ${relation.table}.`,
|
|
450
|
+
'relations',
|
|
451
|
+
'Unknown identifier',
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
const table = relation.table
|
|
455
|
+
let rows = this.#tables[table]
|
|
456
|
+
.filter((row) => row[relation.foreignKey] === parent[relation.localKey])
|
|
457
|
+
.filter((row) => !relation.where || evaluate(row, relation.where) === true)
|
|
458
|
+
.map(cloneRow)
|
|
459
|
+
if (relation.order.length > 0) {
|
|
460
|
+
rows.sort((left, right) => compareRows(left, right, relation.order))
|
|
461
|
+
}
|
|
462
|
+
if (relation.limit !== undefined) rows = rows.slice(0, relation.limit)
|
|
463
|
+
rows = rows
|
|
464
|
+
.map((row) => this.#loadRelations(row, relation.relations))
|
|
465
|
+
.map((row) => {
|
|
466
|
+
const selected = project(row, relation.select)
|
|
467
|
+
relation.relations.forEach((nested) => {
|
|
468
|
+
selected[nested.relation] = row[nested.relation]
|
|
469
|
+
})
|
|
470
|
+
return selected
|
|
471
|
+
})
|
|
472
|
+
loaded[relation.relation] = relation.kind === 'hasMany' ? rows : (rows[0] ?? null)
|
|
473
|
+
}
|
|
474
|
+
return loaded
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#rowsFromValues(values: QueryIR['values']): Row[] {
|
|
478
|
+
if (values === undefined) {
|
|
479
|
+
throw invalid('Write operations require values.', 'values', 'Expected one or more rows')
|
|
480
|
+
}
|
|
481
|
+
return (Array.isArray(values) ? values : [values]).map(cloneRow)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
#assertUnique(table: TableName, candidate: Row, ignored?: Row): void {
|
|
485
|
+
for (const columns of uniqueColumns[table]) {
|
|
486
|
+
const conflict = this.#tables[table].find(
|
|
487
|
+
(row) =>
|
|
488
|
+
row !== ignored &&
|
|
489
|
+
columns.every(
|
|
490
|
+
(column) => candidate[column] !== undefined && candidate[column] === row[column],
|
|
491
|
+
),
|
|
492
|
+
)
|
|
493
|
+
if (conflict) {
|
|
494
|
+
throw new Conflict(`Unique value already exists on ${table}.`, {
|
|
495
|
+
metadata: { resource: table, key: columns.join(',') },
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#writeResult(rows: readonly Row[], returning: QueryIR['returning']): QueryResult {
|
|
502
|
+
return {
|
|
503
|
+
rows: returning === undefined ? [] : rows.map((row) => project(row, returning)),
|
|
504
|
+
affected: rows.length,
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
#insert(table: TableName, query: QueryIR): QueryResult {
|
|
509
|
+
const rows = this.#rowsFromValues(query.values)
|
|
510
|
+
rows.forEach((row) => this.#assertUnique(table, row))
|
|
511
|
+
this.#tables[table].push(...rows.map(cloneRow))
|
|
512
|
+
return this.#writeResult(rows, query.returning)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
#update(table: TableName, query: QueryIR): QueryResult {
|
|
516
|
+
if (query.values === undefined || Array.isArray(query.values)) {
|
|
517
|
+
throw invalid('Update values must be one object.', 'values', 'Expected one row')
|
|
518
|
+
}
|
|
519
|
+
const values = query.values
|
|
520
|
+
const rows = this.#tables[table].filter((row) => matches(row, query))
|
|
521
|
+
rows.forEach((row) => {
|
|
522
|
+
const candidate = { ...row, ...values }
|
|
523
|
+
this.#assertUnique(table, candidate, row)
|
|
524
|
+
Object.assign(row, values)
|
|
525
|
+
})
|
|
526
|
+
return this.#writeResult(rows, query.returning)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
#delete(table: TableName, query: QueryIR): QueryResult {
|
|
530
|
+
const deleted: Row[] = []
|
|
531
|
+
this.#tables[table] = this.#tables[table].filter((row) => {
|
|
532
|
+
if (!matches(row, query)) return true
|
|
533
|
+
deleted.push(row)
|
|
534
|
+
return false
|
|
535
|
+
})
|
|
536
|
+
return this.#writeResult(deleted, query.returning)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
#upsert(table: TableName, query: QueryIR): QueryResult {
|
|
540
|
+
if (!query.conflict) {
|
|
541
|
+
throw invalid('Upsert requires a conflict target.', 'conflict', 'Required for upsert')
|
|
542
|
+
}
|
|
543
|
+
const conflict = query.conflict
|
|
544
|
+
const written = this.#rowsFromValues(query.values).map((values) => {
|
|
545
|
+
const existing = this.#tables[table].find((row) =>
|
|
546
|
+
conflict.columns.every((column) => row[column] === values[column]),
|
|
547
|
+
)
|
|
548
|
+
if (!existing) {
|
|
549
|
+
this.#assertUnique(table, values)
|
|
550
|
+
this.#tables[table].push(cloneRow(values))
|
|
551
|
+
return values
|
|
552
|
+
}
|
|
553
|
+
const updateColumns =
|
|
554
|
+
conflict.update === '*'
|
|
555
|
+
? Object.keys(values).filter((column) => !conflict.columns.includes(column))
|
|
556
|
+
: conflict.update
|
|
557
|
+
const updates = Object.fromEntries(updateColumns.map((column) => [column, values[column]]))
|
|
558
|
+
const candidate = { ...existing, ...updates }
|
|
559
|
+
this.#assertUnique(table, candidate, existing)
|
|
560
|
+
Object.assign(existing, updates)
|
|
561
|
+
return existing
|
|
562
|
+
})
|
|
563
|
+
return this.#writeResult(written, query.returning)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Invokes a deterministic fake routine or rejects an unknown routine as invalid input. */
|
|
567
|
+
async rpc<TResult = unknown>(
|
|
568
|
+
routine: string,
|
|
569
|
+
args: Readonly<Record<string, unknown>>,
|
|
570
|
+
): Promise<TResult> {
|
|
571
|
+
if (routine !== 'assay_echo') {
|
|
572
|
+
// Missing routines indicate deployment drift; mapping them to NotFound would become a false 404.
|
|
573
|
+
throw invalid(`Unknown routine ${routine}.`, 'routine', routine)
|
|
574
|
+
}
|
|
575
|
+
return structuredClone(args) as TResult
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Returns the empty migration plan used by the in-memory implementation. */
|
|
579
|
+
async plan(): Promise<MigrationPlan> {
|
|
580
|
+
return { id: 'fake', migrations: [], steps: [] }
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Applies no migrations and returns no statuses. */
|
|
584
|
+
async apply(): Promise<readonly MigrationStatus[]> {
|
|
585
|
+
return []
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Rolls back no migrations and returns no statuses. */
|
|
589
|
+
async rollback(_steps?: number): Promise<readonly MigrationStatus[]> {
|
|
590
|
+
return []
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Returns no migration statuses. */
|
|
594
|
+
async status(): Promise<readonly MigrationStatus[]> {
|
|
595
|
+
return []
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Runs a callback atomically against a snapshot of the in-memory tables. */
|
|
599
|
+
async transaction<TResult>(
|
|
600
|
+
callback: (transaction: DatabaseTransaction) => Promise<TResult>,
|
|
601
|
+
): Promise<TResult> {
|
|
602
|
+
const snapshot = structuredClone(this.#tables)
|
|
603
|
+
try {
|
|
604
|
+
return await callback(this)
|
|
605
|
+
} catch (error: unknown) {
|
|
606
|
+
for (const table of tableNames) {
|
|
607
|
+
this.#tables[table] = snapshot[table]
|
|
608
|
+
}
|
|
609
|
+
throw error
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Invalid, type FlagCapabilities, type FlagContext, type FlagDriver } from '@avelonjs/core'
|
|
2
|
+
|
|
3
|
+
interface FakeFlagsRaw {
|
|
4
|
+
readonly evaluations: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
class FakeFlagsBase<TCapabilities extends FlagCapabilities> implements FlagDriver<
|
|
8
|
+
TCapabilities,
|
|
9
|
+
FakeFlagsRaw
|
|
10
|
+
> {
|
|
11
|
+
readonly name = 'fake'
|
|
12
|
+
readonly instance: string
|
|
13
|
+
readonly capabilities: TCapabilities
|
|
14
|
+
#evaluations = 0
|
|
15
|
+
|
|
16
|
+
constructor(capabilities: TCapabilities, instance = 'default') {
|
|
17
|
+
this.capabilities = capabilities
|
|
18
|
+
this.instance = instance
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
raw(): FakeFlagsRaw {
|
|
22
|
+
return { evaluations: this.#evaluations }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async evaluate<TValue>(
|
|
26
|
+
key: string,
|
|
27
|
+
defaultValue: TValue,
|
|
28
|
+
context?: FlagContext,
|
|
29
|
+
): Promise<TValue> {
|
|
30
|
+
this.#evaluations += 1
|
|
31
|
+
if (context && !this.capabilities.targeting) {
|
|
32
|
+
throw new Invalid('Targeting context is not supported by this flag driver.', {
|
|
33
|
+
metadata: { fields: { context: ['Targeting is not declared by this driver.'] } },
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const configured: unknown =
|
|
38
|
+
key === 'assay.enabled'
|
|
39
|
+
? true
|
|
40
|
+
: key === 'assay.variant'
|
|
41
|
+
? 'treatment'
|
|
42
|
+
: key === 'assay.targeted'
|
|
43
|
+
? context?.actorId === 'actor-enabled'
|
|
44
|
+
: undefined
|
|
45
|
+
return configured === undefined ? defaultValue : (configured as TValue)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const targetingCapabilities = { targeting: true } as const
|
|
50
|
+
|
|
51
|
+
/** In-memory feature-flag reference implementation with targeting enabled. */
|
|
52
|
+
export class FakeFlags extends FakeFlagsBase<typeof targetingCapabilities> {
|
|
53
|
+
/** Creates an isolated targeted flag connection. */
|
|
54
|
+
constructor(instance = 'default') {
|
|
55
|
+
super(targetingCapabilities, instance)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const noTargetingCapabilities = { targeting: false } as const
|
|
60
|
+
|
|
61
|
+
/** In-memory feature-flag reference implementation without targeting. */
|
|
62
|
+
export class FakeFlagsWithoutTargeting extends FakeFlagsBase<typeof noTargetingCapabilities> {
|
|
63
|
+
/** Creates an isolated untargeted flag connection. */
|
|
64
|
+
constructor(instance = 'default') {
|
|
65
|
+
super(noTargetingCapabilities, instance)
|
|
66
|
+
}
|
|
67
|
+
}
|