@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.
@@ -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
+ }