@open-mercato/cache 0.6.8-develop.6931.1.4a1e1c786b → 0.6.8-develop.6940.1.177ea30c6e
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/AGENTS.md +11 -5
- package/package.json +1 -1
- package/src/__tests__/cache-di-contract-docs.test.ts +160 -0
package/AGENTS.md
CHANGED
|
@@ -21,7 +21,7 @@ The memory strategy is bounded so a process-shared instance (`OM_BOOTSTRAP_CACHE
|
|
|
21
21
|
|
|
22
22
|
## Always
|
|
23
23
|
|
|
24
|
-
1. **MUST resolve via DI** — always use `container.resolve('
|
|
24
|
+
1. **MUST resolve via DI** — always use `container.resolve('cache')`, never instantiate cache directly. The DI token is `cache` (registered in `packages/core/src/bootstrap.ts`); there is no `cacheService` registration.
|
|
25
25
|
2. **MUST scope to tenant** — include `tenantId` in cache keys or use `runWithCacheTenant()` for automatic scoping
|
|
26
26
|
3. **MUST use tag-based invalidation** for CRUD side effects — tag entries so related data can be invalidated together
|
|
27
27
|
|
|
@@ -48,13 +48,18 @@ yarn workspace @open-mercato/cache build
|
|
|
48
48
|
Use tags when cached data relates to a specific entity or scope. Invalidating a tag clears all entries with that tag.
|
|
49
49
|
|
|
50
50
|
```typescript
|
|
51
|
+
const cache = container.resolve('cache')
|
|
52
|
+
|
|
51
53
|
// When caching, attach tags
|
|
52
|
-
await
|
|
54
|
+
await cache.set('key', value, { tags: ['tenant:123', 'customers'] })
|
|
53
55
|
|
|
54
|
-
// When data changes, invalidate by tag
|
|
55
|
-
|
|
56
|
+
// When data changes, invalidate by tag — `deleteByTags` takes an ARRAY of tags
|
|
57
|
+
// (any entry carrying ANY of them is dropped) and returns the number of deleted keys.
|
|
58
|
+
await cache.deleteByTags(['customers']) // Clears all customer-related cache
|
|
56
59
|
```
|
|
57
60
|
|
|
61
|
+
`CacheStrategy` is the only cache surface: `get`, `set`, `has`, `delete`, `deleteByTags`, `clear`, `keys`, `stats`, plus optional `healthcheck`, `cleanup`, `close`. There is no `invalidateTag` — MUST NOT invent per-tag helpers in module code.
|
|
62
|
+
|
|
58
63
|
## Consistency vs commit timing
|
|
59
64
|
|
|
60
65
|
Cache invalidation and query-index side effects (`emitCrudSideEffects`) MUST fire **after** the originating domain write commits — the same rule that keeps them OUTSIDE the `withAtomicFlush` block (see `packages/core/AGENTS.md` → "Entity Update Safety — `withAtomicFlush`"). Because invalidation runs post-commit and the query-index read-projection tail (search tokens, vectors, fulltext, coverage) converges asynchronously, reads can briefly see a short convergence window after a write.
|
|
@@ -63,7 +68,7 @@ The opt-in env flag `OM_CACHE_SAFETY_ALWAYS_CONSISTENT` (default **OFF**, 100% b
|
|
|
63
68
|
|
|
64
69
|
## Adding Caching to a Module
|
|
65
70
|
|
|
66
|
-
1. Resolve `
|
|
71
|
+
1. Resolve the `cache` service from DI (`container.resolve('cache')`) in your service or route handler
|
|
67
72
|
2. Define cache keys with tenant scoping: `${tenantId}:${module}:${identifier}`
|
|
68
73
|
3. Tag entries with entity type and tenant for targeted invalidation
|
|
69
74
|
4. Add cache invalidation to CRUD side effects (`emitCrudSideEffects` with `cacheAliases`)
|
|
@@ -82,3 +87,4 @@ packages/cache/src/
|
|
|
82
87
|
- Follow the strategy pattern — add new strategies in `strategies/` with the same interface
|
|
83
88
|
- Run `yarn test` in `packages/cache` after changes
|
|
84
89
|
- Verify tag invalidation works across all strategies when modifying invalidation logic
|
|
90
|
+
- The DI token and method names quoted in this file and in `.ai/review-checklist.md` are pinned to the real sources by `src/__tests__/cache-di-contract-docs.test.ts` (it re-derives the token from `packages/core/src/bootstrap.ts` and the members from `CacheStrategy`). Renaming the token or a method MUST update both docs in the same change.
|
package/package.json
CHANGED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cache DI-contract documentation audit.
|
|
6
|
+
*
|
|
7
|
+
* `packages/cache/AGENTS.md` and `.ai/review-checklist.md` are the two places an agent
|
|
8
|
+
* looks up how to reach the cache. Both had drifted away from the runtime: they told
|
|
9
|
+
* readers to resolve a `cacheService` DI token (nothing registers that name — the real
|
|
10
|
+
* token is `cache`, registered in `packages/core/src/bootstrap.ts`) and to invalidate a
|
|
11
|
+
* tag with `invalidateTag('customers')` (no such member exists — `CacheStrategy` exposes
|
|
12
|
+
* `deleteByTags(tags: string[])`). Both snippets throw at runtime, so the docs were
|
|
13
|
+
* actively producing broken module code.
|
|
14
|
+
*
|
|
15
|
+
* This audit re-derives the ground truth from the sources on every run — the registered
|
|
16
|
+
* token from the bootstrap registration, the callable members from the `CacheStrategy`
|
|
17
|
+
* type — and fails when either document quotes a token or a method that no longer exists.
|
|
18
|
+
* Renaming the token or a strategy method therefore breaks this test until the docs are
|
|
19
|
+
* updated in the same change.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const repoRoot = join(__dirname, '..', '..', '..', '..')
|
|
23
|
+
const bootstrapPath = join(repoRoot, 'packages', 'core', 'src', 'bootstrap.ts')
|
|
24
|
+
const cacheTypesPath = join(repoRoot, 'packages', 'cache', 'src', 'types.ts')
|
|
25
|
+
const cacheAgentsPath = join(repoRoot, 'packages', 'cache', 'AGENTS.md')
|
|
26
|
+
const reviewChecklistPath = join(repoRoot, '.ai', 'review-checklist.md')
|
|
27
|
+
|
|
28
|
+
function readSource(path: string): string {
|
|
29
|
+
return readFileSync(path, 'utf8')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The DI token `bootstrap()` registers the cache service under. */
|
|
33
|
+
function registeredCacheToken(): string {
|
|
34
|
+
const source = readSource(bootstrapPath)
|
|
35
|
+
const match = source.match(/container\.register\(\{\s*([A-Za-z_$][\w$]*)\s*:\s*asValue\(cache\)\s*\}\)/)
|
|
36
|
+
if (!match) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'[internal] Could not locate the cache registration in packages/core/src/bootstrap.ts. ' +
|
|
39
|
+
'Update this audit if the registration shape changed.',
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
return match[1]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The body of the `CacheStrategy` type declaration. */
|
|
46
|
+
function cacheStrategyBody(): string {
|
|
47
|
+
const source = readSource(cacheTypesPath)
|
|
48
|
+
const start = source.indexOf('export type CacheStrategy = {')
|
|
49
|
+
if (start === -1) throw new Error('[internal] CacheStrategy type not found in packages/cache/src/types.ts')
|
|
50
|
+
const rest = source.slice(start)
|
|
51
|
+
const endOffset = rest.search(/^\}/m)
|
|
52
|
+
if (endOffset === -1) throw new Error('[internal] CacheStrategy type body is not terminated')
|
|
53
|
+
return rest.slice(0, endOffset)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every callable member of the `CacheStrategy` contract, optional ones included. */
|
|
57
|
+
function cacheStrategyMembers(body: string): Set<string> {
|
|
58
|
+
const members = new Set<string>()
|
|
59
|
+
const memberPattern = /^ {2}([A-Za-z_$][\w$]*)\??\(/gm
|
|
60
|
+
let match = memberPattern.exec(body)
|
|
61
|
+
while (match) {
|
|
62
|
+
members.add(match[1])
|
|
63
|
+
match = memberPattern.exec(body)
|
|
64
|
+
}
|
|
65
|
+
return members
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The member that performs tag-based invalidation — the one taking a `string[]` of tags. */
|
|
69
|
+
function tagInvalidationMember(body: string): string {
|
|
70
|
+
const match = body.match(/^ {2}([A-Za-z_$][\w$]*)\??\(tags: string\[\]\)/m)
|
|
71
|
+
if (!match) throw new Error('[internal] CacheStrategy no longer declares a tags: string[] member')
|
|
72
|
+
return match[1]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every `container.resolve('x')` / `resolve('x')` token quoted by a document. */
|
|
76
|
+
function documentedResolveTokens(markdown: string): string[] {
|
|
77
|
+
const tokens: string[] = []
|
|
78
|
+
const pattern = /\bresolve\(\s*['"`]([^'"`]+)['"`]\s*\)/g
|
|
79
|
+
let match = pattern.exec(markdown)
|
|
80
|
+
while (match) {
|
|
81
|
+
tokens.push(match[1])
|
|
82
|
+
match = pattern.exec(markdown)
|
|
83
|
+
}
|
|
84
|
+
return tokens
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every method invoked on a cache-shaped identifier inside a document. */
|
|
88
|
+
function documentedCacheMethodCalls(markdown: string): string[] {
|
|
89
|
+
const calls: string[] = []
|
|
90
|
+
const pattern = /\b([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\(/g
|
|
91
|
+
let match = pattern.exec(markdown)
|
|
92
|
+
while (match) {
|
|
93
|
+
if (/cache/i.test(match[1])) calls.push(match[2])
|
|
94
|
+
match = pattern.exec(markdown)
|
|
95
|
+
}
|
|
96
|
+
return calls
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The review checklist documents every subsystem, so only its Cache section is in scope —
|
|
101
|
+
* other sections legitimately quote other DI tokens.
|
|
102
|
+
*/
|
|
103
|
+
function cacheSectionOf(markdown: string): string {
|
|
104
|
+
const heading = markdown.match(/^## \d+\. Cache$/m)
|
|
105
|
+
if (!heading || heading.index === undefined) {
|
|
106
|
+
throw new Error('[internal] .ai/review-checklist.md no longer has a "## <n>. Cache" section')
|
|
107
|
+
}
|
|
108
|
+
const rest = markdown.slice(heading.index + heading[0].length)
|
|
109
|
+
const nextHeading = rest.search(/^## /m)
|
|
110
|
+
return nextHeading === -1 ? rest : rest.slice(0, nextHeading)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type AuditedDocument = { file: string; text: string }
|
|
114
|
+
|
|
115
|
+
function auditedDocuments(): AuditedDocument[] {
|
|
116
|
+
return [
|
|
117
|
+
{ file: 'packages/cache/AGENTS.md', text: readSource(cacheAgentsPath) },
|
|
118
|
+
{ file: '.ai/review-checklist.md', text: cacheSectionOf(readSource(reviewChecklistPath)) },
|
|
119
|
+
]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe('cache DI contract documentation', () => {
|
|
123
|
+
const token = registeredCacheToken()
|
|
124
|
+
const strategyBody = cacheStrategyBody()
|
|
125
|
+
const members = cacheStrategyMembers(strategyBody)
|
|
126
|
+
const tagMember = tagInvalidationMember(strategyBody)
|
|
127
|
+
const documents = auditedDocuments()
|
|
128
|
+
|
|
129
|
+
it('derives the ground truth it audits against', () => {
|
|
130
|
+
expect(token).toBe('cache')
|
|
131
|
+
const required = ['get', 'set', 'has', 'delete', 'deleteByTags', 'clear', 'keys', 'stats']
|
|
132
|
+
expect(required.filter((member) => !members.has(member))).toEqual([])
|
|
133
|
+
expect(members.has('invalidateTag')).toBe(false)
|
|
134
|
+
expect(tagMember).toBe('deleteByTags')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('every audited document resolves only the registered DI token', () => {
|
|
138
|
+
for (const { file, text } of documents) {
|
|
139
|
+
const quoted = documentedResolveTokens(text)
|
|
140
|
+
expect({ file, quotedCount: quoted.length > 0 }).toEqual({ file, quotedCount: true })
|
|
141
|
+
for (const quotedToken of quoted) {
|
|
142
|
+
expect({ file, quotedToken }).toEqual({ file, quotedToken: token })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('every audited document only calls methods that exist on CacheStrategy', () => {
|
|
148
|
+
for (const { file, text } of documents) {
|
|
149
|
+
for (const method of documentedCacheMethodCalls(text)) {
|
|
150
|
+
expect({ file, method, exists: members.has(method) }).toEqual({ file, method, exists: true })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('every audited document names tag invalidation by its real member', () => {
|
|
156
|
+
for (const { file, text } of documents) {
|
|
157
|
+
expect({ file, documented: text.includes(`${tagMember}(`) }).toEqual({ file, documented: true })
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
})
|