@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryan Yannelli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# @avelonjs/conformance
|
|
2
|
+
|
|
3
|
+
`@avelonjs/conformance` is the load-bearing test artifact for Avelon drivers. It ships one suite per frozen contract, written against the contract rather than any implementation, plus in-memory fakes that run those same suites. Reach for this package when you certify a driver: pass a `create` factory that returns a known-empty instance, and let the suite own the assertions. A passing driver means someone other than the driver author wrote the tests.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add -d @avelonjs/conformance
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Suites import `bun:test` and live at `@avelonjs/conformance/suites`. Fakes stay on the package root so a Next or Node production graph can import `FakeMail` without loading the test runner. Point a driver package at `@avelonjs/core` for the contract and at this package for the suite.
|
|
12
|
+
|
|
13
|
+
## Basic Usage
|
|
14
|
+
|
|
15
|
+
Wire the shared database suite to an in-memory fake, then swap `create` for your driver when you certify it.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { FakeDatabase } from '@avelonjs/conformance'
|
|
19
|
+
import { databaseSuite } from '@avelonjs/conformance/suites'
|
|
20
|
+
|
|
21
|
+
databaseSuite({
|
|
22
|
+
name: 'reference fake',
|
|
23
|
+
create: () => new FakeDatabase(),
|
|
24
|
+
})
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A live driver owns fixture reset and cleanup. The suite still assumes each `create()` returns a known-empty world.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { databaseSuite } from '@avelonjs/conformance/suites'
|
|
31
|
+
import { createPostgresDatabase } from '@avelonjs/postgres'
|
|
32
|
+
|
|
33
|
+
databaseSuite({
|
|
34
|
+
name: 'live postgres',
|
|
35
|
+
create: async () => {
|
|
36
|
+
const driver = createPostgresDatabase()
|
|
37
|
+
await driver.resetFixtures()
|
|
38
|
+
return driver
|
|
39
|
+
},
|
|
40
|
+
cleanup: (driver) => driver.close(),
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Shared Suites
|
|
45
|
+
|
|
46
|
+
Every contract has one suite. You did not write it. That is the point. Call the matching function from a `bun test` file; the suite registers `describe` and `test` blocks itself.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { FakeMail } from '@avelonjs/conformance'
|
|
50
|
+
import { mailSuite } from '@avelonjs/conformance/suites'
|
|
51
|
+
|
|
52
|
+
mailSuite({
|
|
53
|
+
name: 'reference fake',
|
|
54
|
+
create: () => new FakeMail('transactional', 'mailer@example.test'),
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A driver either passes a capability's tests or declares that capability `false`. Declaring `true` and failing, or declaring `false` while still implementing the method, is a suite failure.
|
|
59
|
+
|
|
60
|
+
## Reference Fakes
|
|
61
|
+
|
|
62
|
+
Each fake is an in-memory implementation of one contract. Fakes run the same suites as live drivers, so `new FakeMail()` cannot drift from the mail contract. Use them in application tests and in this package's own certification of the suites.
|
|
63
|
+
|
|
64
|
+
Capability-narrowed variants exist where a boolean or spectrum can be off: `FakeFlagsWithoutTargeting`, `FakeNotificationsWithoutChannels`, `FakeQueueWithoutRetries`, `FakeRateLimitWithoutAlgorithms`, and `FakeSearchWithoutFacets`.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { FakeFlags, FakeFlagsWithoutTargeting } from '@avelonjs/conformance'
|
|
68
|
+
|
|
69
|
+
const withTargeting = new FakeFlags()
|
|
70
|
+
const withoutTargeting = new FakeFlagsWithoutTargeting()
|
|
71
|
+
|
|
72
|
+
const targeted = await withTargeting.evaluate('assay.targeted', false, { actorId: 'actor-enabled' })
|
|
73
|
+
const globalOnly = await withoutTargeting.evaluate('assay.enabled', false)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Capability Surfaces
|
|
77
|
+
|
|
78
|
+
`assertCapabilitySurface` checks both lies: a declared capability whose method is missing, and an implemented method whose capability is `false`. Suites call it; you may also call it from a driver test when a surface is method-gated.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { assertCapabilitySurface, captureFailure, FakeStorage } from '@avelonjs/conformance'
|
|
82
|
+
|
|
83
|
+
const storage = new FakeStorage()
|
|
84
|
+
assertCapabilitySurface(storage, 'signedUrls', storage.capabilities.signedUrls, ['signedUrl'])
|
|
85
|
+
|
|
86
|
+
const error = await captureFailure(() => storage.get('missing/avatar.png'))
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`captureFailure` returns the thrown value so suites can assert the framework error taxonomy instead of a vendor code.
|
|
90
|
+
|
|
91
|
+
## Method Reference
|
|
92
|
+
|
|
93
|
+
| Method / export | Signature | Description |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `assertCapabilitySurface` | `(driver: object, capability: string, declared: boolean, methods: readonly string[]) => void` | Fails when a declared method is missing or an undeclared method is present. |
|
|
96
|
+
| `captureFailure` | `(operation: () => Promise<unknown>) => Promise<unknown>` | Runs an operation expected to throw and returns the thrown value. |
|
|
97
|
+
| `SuiteContext` | `interface SuiteContext<TDriver>` | `name`, `create`, optional `cleanup`, and live-service helpers for one suite. |
|
|
98
|
+
| `FakeAI` | `class FakeAI` | In-memory AI driver used as the AI contract reference. |
|
|
99
|
+
| `FakeCache` | `class FakeCache` | In-memory cache driver used as the cache contract reference. |
|
|
100
|
+
| `FakeDatabase` | `class FakeDatabase` | In-memory database driver used as the database contract reference. |
|
|
101
|
+
| `FakeFlags` | `class FakeFlags` | In-memory flags driver with targeting enabled. |
|
|
102
|
+
| `FakeFlagsWithoutTargeting` | `class FakeFlagsWithoutTargeting` | Flags fake with targeting declared unavailable. |
|
|
103
|
+
| `FakeIdentity` | `class FakeIdentity` | In-memory identity driver used as the identity contract reference. |
|
|
104
|
+
| `FakeLogs` | `class FakeLogs` | In-memory log driver with traces enabled. |
|
|
105
|
+
| `FakeTraceRecord` | `interface` | Observable span state retained by `FakeLogs`. |
|
|
106
|
+
| `FakeMail` | `class FakeMail` | In-memory mail driver with hosted templates enabled. |
|
|
107
|
+
| `FakeNotifications` | `class FakeNotifications` | In-memory notifications driver with every channel declared. |
|
|
108
|
+
| `FakeNotificationsWithoutChannels` | `class FakeNotificationsWithoutChannels` | Notifications fake with an empty channel list. |
|
|
109
|
+
| `FakeSentNotification` | `interface` | One notification accepted by a notifications fake. |
|
|
110
|
+
| `FakePayments` | `class FakePayments` | In-memory payments driver used as the payments contract reference. |
|
|
111
|
+
| `FakeQueue` | `class FakeQueue` | In-memory queue driver with retries enabled. |
|
|
112
|
+
| `FakeQueueWithoutRetries` | `class FakeQueueWithoutRetries` | Queue fake with retries declared unavailable. |
|
|
113
|
+
| `FakeRateLimit` | `class FakeRateLimit` | In-memory rate-limit driver with every algorithm declared. |
|
|
114
|
+
| `FakeRateLimitWithoutAlgorithms` | `class FakeRateLimitWithoutAlgorithms` | Rate-limit fake with an empty algorithm list. |
|
|
115
|
+
| `FakeRealtime` | `class FakeRealtime` | In-memory realtime driver used as the realtime contract reference. |
|
|
116
|
+
| `FakeSearch` | `class FakeSearch` | In-memory search driver with facets enabled. |
|
|
117
|
+
| `FakeSearchWithoutFacets` | `class FakeSearchWithoutFacets` | Search fake with facets declared unavailable. |
|
|
118
|
+
| `FakeSocial` | `class FakeSocial` | In-memory social identity driver used as the social contract reference. |
|
|
119
|
+
| `FakeStorage` | `class FakeStorage` | In-memory storage driver used as the storage contract reference. |
|
|
120
|
+
| `FakeTokens` | `class FakeTokens` | In-memory API-token driver used as the tokens contract reference. |
|
|
121
|
+
| `aiSuite` | `(context: SuiteContext<TDriver>) => void` | Shared AI contract suite from `@avelonjs/conformance/suites`. |
|
|
122
|
+
| `cacheSuite` | `(context: SuiteContext<TDriver>) => void` | Shared cache contract suite. |
|
|
123
|
+
| `databaseSuite` | `(context: SuiteContext<TDriver>) => void` | Shared database contract suite. |
|
|
124
|
+
| `flagsSuite` | `(context: SuiteContext<TDriver>) => void` | Shared flags contract suite. |
|
|
125
|
+
| `identitySuite` | `(context: SuiteContext<IdentityFactory<TDriver>>) => void` | Shared identity contract suite. |
|
|
126
|
+
| `logsSuite` | `(context: SuiteContext<TDriver>) => void` | Shared logs contract suite. |
|
|
127
|
+
| `mailSuite` | `(context: SuiteContext<TDriver>) => void` | Shared mail contract suite. |
|
|
128
|
+
| `notificationsSuite` | `(context: SuiteContext<TDriver>) => void` | Shared notifications contract suite. |
|
|
129
|
+
| `paymentsSuite` | `(context: SuiteContext<TDriver>) => void` | Shared payments contract suite. |
|
|
130
|
+
| `queueSuite` | `(context: SuiteContext<TDriver>) => void` | Shared queue contract suite. |
|
|
131
|
+
| `ratelimitSuite` | `(context: SuiteContext<TDriver>) => void` | Shared rate-limit contract suite. |
|
|
132
|
+
| `realtimeSuite` | `(context: SuiteContext<TDriver>) => void` | Shared realtime contract suite. |
|
|
133
|
+
| `searchSuite` | `(context: SuiteContext<TDriver>) => void` | Shared search contract suite. |
|
|
134
|
+
| `socialSuite` | `(context: SuiteContext<TDriver>) => void` | Shared social contract suite. |
|
|
135
|
+
| `storageSuite` | `(context: SuiteContext<TDriver>) => void` | Shared storage contract suite. |
|
|
136
|
+
| `tokensSuite` | `(context: SuiteContext<TDriver>) => void` | Shared tokens contract suite. |
|
|
137
|
+
|
|
138
|
+
## Testing
|
|
139
|
+
|
|
140
|
+
This package is the suite. Run it against the shipped fakes here, and again against a real driver in that driver's package. A deliberately broken stub in `tests/` proves the suite rejects compiler defects rather than echoing the fake back to itself.
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { FakeStorage } from '@avelonjs/conformance'
|
|
144
|
+
import { storageSuite } from '@avelonjs/conformance/suites'
|
|
145
|
+
|
|
146
|
+
storageSuite({
|
|
147
|
+
name: 'reference fake',
|
|
148
|
+
create: () => new FakeStorage(),
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
bun test
|
|
154
|
+
bun run typecheck
|
|
155
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@avelonjs/conformance",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Contract test suites and in-memory fakes for Avelon drivers.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Ryan Yannelli <ryanyannelli@gmail.com>",
|
|
8
|
+
"homepage": "https://github.com/yannelli/avelon",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/yannelli/avelon.git",
|
|
12
|
+
"directory": "packages/conformance"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/yannelli/avelon/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"avelon",
|
|
19
|
+
"typescript",
|
|
20
|
+
"testing",
|
|
21
|
+
"conformance"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./src/index.ts",
|
|
34
|
+
"./suites": "./src/suites/index.ts"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "bun test",
|
|
38
|
+
"typecheck": "tsc --noEmit"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@avelonjs/core": "workspace:*"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/bun": "1.3.14",
|
|
45
|
+
"typescript": "5.9.3"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"bun": ">=1.3.14"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/fakes/ai.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Invalid,
|
|
3
|
+
type AiDriver,
|
|
4
|
+
type CompletionAiSurface,
|
|
5
|
+
type CompletionChunk,
|
|
6
|
+
type CompletionRequest,
|
|
7
|
+
type CompletionResult,
|
|
8
|
+
type EmbeddingAiSurface,
|
|
9
|
+
type EmbeddingResult,
|
|
10
|
+
type StreamingAiSurface,
|
|
11
|
+
} from '@avelonjs/core'
|
|
12
|
+
|
|
13
|
+
const capabilities = {
|
|
14
|
+
modes: ['completion', 'embedding'],
|
|
15
|
+
streaming: true,
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
interface FakeAiRaw {
|
|
19
|
+
readonly completions: number
|
|
20
|
+
readonly embeddings: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function completionText(request: CompletionRequest): string {
|
|
24
|
+
return request.messages.map((message) => message.content).join(' ')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function vector(input: string): readonly number[] {
|
|
28
|
+
let sum = 0
|
|
29
|
+
let weighted = 0
|
|
30
|
+
for (const [index, character] of [...input].entries()) {
|
|
31
|
+
const code = character.codePointAt(0) ?? 0
|
|
32
|
+
sum += code
|
|
33
|
+
weighted += code * (index + 1)
|
|
34
|
+
}
|
|
35
|
+
return [input.length, sum % 997, weighted % 991, (sum + weighted) % 983]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Deterministic in-memory AI reference implementation used by conformance and application tests. */
|
|
39
|
+
export class FakeAI
|
|
40
|
+
implements
|
|
41
|
+
AiDriver<typeof capabilities, FakeAiRaw>,
|
|
42
|
+
CompletionAiSurface,
|
|
43
|
+
EmbeddingAiSurface,
|
|
44
|
+
StreamingAiSurface
|
|
45
|
+
{
|
|
46
|
+
/** Driver implementation name. */
|
|
47
|
+
readonly name = 'fake'
|
|
48
|
+
|
|
49
|
+
/** Configured AI connection name. */
|
|
50
|
+
readonly instance: string
|
|
51
|
+
|
|
52
|
+
/** Exact mode and streaming declaration. */
|
|
53
|
+
readonly capabilities = capabilities
|
|
54
|
+
|
|
55
|
+
#completions = 0
|
|
56
|
+
#embeddings = 0
|
|
57
|
+
|
|
58
|
+
/** Creates an isolated AI connection. */
|
|
59
|
+
constructor(instance = 'default') {
|
|
60
|
+
this.instance = instance
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Returns operation counts for the in-memory implementation. */
|
|
64
|
+
raw(): FakeAiRaw {
|
|
65
|
+
return { completions: this.#completions, embeddings: this.#embeddings }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Produces deterministic text from the ordered request messages. */
|
|
69
|
+
async complete(request: CompletionRequest): Promise<CompletionResult> {
|
|
70
|
+
this.validateModel(request.model)
|
|
71
|
+
this.#completions += 1
|
|
72
|
+
const text = completionText(request)
|
|
73
|
+
return {
|
|
74
|
+
text,
|
|
75
|
+
inputTokens: request.messages.reduce((total, message) => total + message.content.length, 0),
|
|
76
|
+
outputTokens: text.length,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Embeds every input into a deterministic four-dimensional vector. */
|
|
81
|
+
async embed(model: string, input: string | readonly string[]): Promise<EmbeddingResult> {
|
|
82
|
+
this.validateModel(model)
|
|
83
|
+
const inputs = typeof input === 'string' ? [input] : input
|
|
84
|
+
if (inputs.length === 0) {
|
|
85
|
+
throw new Invalid('At least one embedding input is required.', {
|
|
86
|
+
metadata: { fields: { input: ['Provide one or more strings to embed.'] } },
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
this.#embeddings += 1
|
|
90
|
+
return {
|
|
91
|
+
embeddings: inputs.map(vector),
|
|
92
|
+
inputTokens: inputs.reduce((total, value) => total + value.length, 0),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Streams deterministic completion text in ordered increments followed by one terminal chunk. */
|
|
97
|
+
async *stream(request: CompletionRequest): AsyncIterable<CompletionChunk> {
|
|
98
|
+
this.validateModel(request.model)
|
|
99
|
+
const text = completionText(request)
|
|
100
|
+
const split = Math.max(1, Math.ceil(text.length / 2))
|
|
101
|
+
yield { text: text.slice(0, split), done: false }
|
|
102
|
+
if (split < text.length) yield { text: text.slice(split), done: false }
|
|
103
|
+
yield { text: '', done: true }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private validateModel(model: string): void {
|
|
107
|
+
if (model.length === 0) {
|
|
108
|
+
throw new Invalid('An AI model identifier is required.', {
|
|
109
|
+
metadata: { fields: { model: ['A model identifier is required.'] } },
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Conflict,
|
|
3
|
+
type CacheDriver,
|
|
4
|
+
type CacheStore,
|
|
5
|
+
type LockCacheSurface,
|
|
6
|
+
type TaggedCacheSurface,
|
|
7
|
+
} from '@avelonjs/core'
|
|
8
|
+
|
|
9
|
+
const capabilities = {
|
|
10
|
+
tags: true,
|
|
11
|
+
locks: true,
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
interface CacheEntry {
|
|
15
|
+
value: unknown
|
|
16
|
+
expiresAt: number | null
|
|
17
|
+
tags: ReadonlySet<string>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface HeldLock {
|
|
21
|
+
token: symbol
|
|
22
|
+
expiresAt: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface FakeCacheRaw {
|
|
26
|
+
entries: number
|
|
27
|
+
locks: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** In-memory cache reference implementation used by conformance and application tests. */
|
|
31
|
+
export class FakeCache
|
|
32
|
+
implements CacheDriver<typeof capabilities, FakeCacheRaw>, TaggedCacheSurface, LockCacheSurface
|
|
33
|
+
{
|
|
34
|
+
/** Driver implementation name. */
|
|
35
|
+
readonly name = 'fake'
|
|
36
|
+
|
|
37
|
+
/** Configured cache store name. */
|
|
38
|
+
readonly instance: string
|
|
39
|
+
|
|
40
|
+
/** Exact optional-feature declaration. */
|
|
41
|
+
readonly capabilities = capabilities
|
|
42
|
+
|
|
43
|
+
readonly #entries = new Map<string, CacheEntry>()
|
|
44
|
+
readonly #locks = new Map<string, HeldLock>()
|
|
45
|
+
|
|
46
|
+
/** Creates an isolated cache store. */
|
|
47
|
+
constructor(instance = 'default') {
|
|
48
|
+
this.instance = instance
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Returns observable entry and active-lock counts. */
|
|
52
|
+
raw(): FakeCacheRaw {
|
|
53
|
+
return { entries: this.#entries.size, locks: this.#locks.size }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Reads a value or returns null when absent or expired. */
|
|
57
|
+
async get<TValue>(key: string): Promise<TValue | null> {
|
|
58
|
+
return this.getTagged<TValue>(key, [])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Stores a value with an optional lifetime in seconds. */
|
|
62
|
+
async put<TValue>(key: string, value: TValue, ttlSeconds?: number): Promise<void> {
|
|
63
|
+
this.putTagged(key, value, ttlSeconds, [])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Removes a key when present. */
|
|
67
|
+
async forget(key: string): Promise<void> {
|
|
68
|
+
this.forgetTagged(key, [])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Removes every cache entry. */
|
|
72
|
+
async flush(): Promise<void> {
|
|
73
|
+
this.#entries.clear()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Returns a namespace whose writes carry every supplied tag. */
|
|
77
|
+
tags(names: readonly string[]): CacheStore {
|
|
78
|
+
const required = [...new Set(names)]
|
|
79
|
+
return {
|
|
80
|
+
get: <TValue>(key: string) => this.getTagged<TValue>(key, required),
|
|
81
|
+
put: <TValue>(key: string, value: TValue, ttlSeconds?: number) => {
|
|
82
|
+
this.putTagged(key, value, ttlSeconds, required)
|
|
83
|
+
return Promise.resolve()
|
|
84
|
+
},
|
|
85
|
+
forget: (key: string) => {
|
|
86
|
+
this.forgetTagged(key, required)
|
|
87
|
+
return Promise.resolve()
|
|
88
|
+
},
|
|
89
|
+
flush: () => {
|
|
90
|
+
this.flushTagged(required)
|
|
91
|
+
return Promise.resolve()
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Runs a callback with exclusive ownership of a named expiring lock. */
|
|
97
|
+
async lock<TResult>(
|
|
98
|
+
key: string,
|
|
99
|
+
ttlSeconds: number,
|
|
100
|
+
callback: () => Promise<TResult>,
|
|
101
|
+
): Promise<TResult> {
|
|
102
|
+
const now = Date.now()
|
|
103
|
+
const held = this.#locks.get(key)
|
|
104
|
+
if (held && held.expiresAt > now) {
|
|
105
|
+
throw new Conflict(`Cache lock ${key} is already held.`, {
|
|
106
|
+
metadata: { resource: 'cache-lock', key },
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const token = Symbol(key)
|
|
111
|
+
this.#locks.set(key, { token, expiresAt: now + ttlSeconds * 1000 })
|
|
112
|
+
try {
|
|
113
|
+
return await callback()
|
|
114
|
+
} finally {
|
|
115
|
+
if (this.#locks.get(key)?.token === token) this.#locks.delete(key)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async getTagged<TValue>(key: string, tags: readonly string[]): Promise<TValue | null> {
|
|
120
|
+
const entry = this.#entries.get(key)
|
|
121
|
+
if (!entry) return null
|
|
122
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
123
|
+
this.#entries.delete(key)
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
if (!tags.every((tag) => entry.tags.has(tag))) return null
|
|
127
|
+
return entry.value as TValue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private putTagged<TValue>(
|
|
131
|
+
key: string,
|
|
132
|
+
value: TValue,
|
|
133
|
+
ttlSeconds: number | undefined,
|
|
134
|
+
tags: readonly string[],
|
|
135
|
+
): void {
|
|
136
|
+
this.#entries.set(key, {
|
|
137
|
+
value,
|
|
138
|
+
expiresAt: ttlSeconds === undefined ? null : Date.now() + ttlSeconds * 1000,
|
|
139
|
+
tags: new Set(tags),
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private forgetTagged(key: string, tags: readonly string[]): void {
|
|
144
|
+
const entry = this.#entries.get(key)
|
|
145
|
+
if (entry && tags.every((tag) => entry.tags.has(tag))) this.#entries.delete(key)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private flushTagged(tags: readonly string[]): void {
|
|
149
|
+
for (const [key, entry] of this.#entries) {
|
|
150
|
+
if (tags.every((tag) => entry.tags.has(tag))) this.#entries.delete(key)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|