@open-mercato/cache 0.6.8-develop.6931.1.4a1e1c786b → 0.6.8-develop.6941.1.3c26284542

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 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('cacheService')`, never instantiate cache directly
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 cacheService.set('key', value, { tags: ['tenant:123', 'customers'] })
54
+ await cache.set('key', value, { tags: ['tenant:123', 'customers'] })
53
55
 
54
- // When data changes, invalidate by tag
55
- await cacheService.invalidateTag('customers') // Clears all customer-related cache
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 `cacheService` from DI in your service or route handler
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cache",
3
- "version": "0.6.8-develop.6931.1.4a1e1c786b",
3
+ "version": "0.6.8-develop.6941.1.3c26284542",
4
4
  "license": "MIT",
5
5
  "description": "Multi-strategy cache service with tag-based invalidation support",
6
6
  "type": "module",
@@ -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
+ })