@chidchanun/bcp 0.2.15 → 0.2.16

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,487 @@
1
+ # Cache Platform v2
2
+
3
+ BCP Framework `0.2.16` extends `bcp/cache` with provider-neutral asynchronous cache storage, distributed locking, cache-stampede protection, Redis-compatible adapters and metrics integration while preserving the existing request-cache and `cache()` APIs.
4
+
5
+ ## Compatibility
6
+
7
+ The existing APIs remain available and keep their process-local behavior:
8
+
9
+ ```ts
10
+ import {
11
+ cache,
12
+ dedupe,
13
+ revalidatePath,
14
+ revalidateTag,
15
+ } from "bcp/cache";
16
+ ```
17
+
18
+ `cache()` is still useful for simple single-process data caching and `dedupe()` is still request-scoped work sharing.
19
+
20
+ Use Cache Platform v2 when cached state must be shared across multiple application instances or when cache fill work needs distributed stampede protection.
21
+
22
+ ## Create a cache store
23
+
24
+ ```ts
25
+ import {
26
+ createCacheStore,
27
+ } from "bcp/cache";
28
+
29
+ export const applicationCache =
30
+ createCacheStore();
31
+ ```
32
+
33
+ Without an adapter, the store uses the built-in memory adapter.
34
+
35
+ Basic operations:
36
+
37
+ ```ts
38
+ await applicationCache.set(
39
+ "user:42",
40
+ {
41
+ id: 42,
42
+ name: "Bank",
43
+ },
44
+ {
45
+ ttlMs: 60_000,
46
+ tags: ["users"],
47
+ paths: ["/users/42"],
48
+ }
49
+ );
50
+
51
+ const user =
52
+ await applicationCache.get(
53
+ "user:42"
54
+ );
55
+
56
+ await applicationCache.delete(
57
+ "user:42"
58
+ );
59
+ ```
60
+
61
+ ## Cache adapter contract
62
+
63
+ Shared cache providers implement `CacheAdapter`:
64
+
65
+ ```ts
66
+ interface CacheAdapter {
67
+ get<T>(key: string):
68
+ Promise<CacheAdapterEntry<T> | null>;
69
+
70
+ set<T>(
71
+ key: string,
72
+ value: T,
73
+ options?: CacheAdapterSetOptions
74
+ ): Promise<void>;
75
+
76
+ delete(key: string): Promise<boolean>;
77
+ clear(): Promise<number>;
78
+ revalidateTag(tag: string): Promise<number>;
79
+ revalidatePath(path: string): Promise<number>;
80
+ entries?(): Promise<number>;
81
+ close?(): Promise<void>;
82
+ }
83
+ ```
84
+
85
+ The built-in implementations are:
86
+
87
+ ```ts
88
+ createMemoryCacheAdapter()
89
+ createRedisCacheAdapter()
90
+ ```
91
+
92
+ This contract allows another provider to be added without changing application cache usage.
93
+
94
+ ## TTL
95
+
96
+ Cache Store v2 uses millisecond TTL values:
97
+
98
+ ```ts
99
+ await applicationCache.set(
100
+ "dashboard:summary",
101
+ summary,
102
+ {
103
+ ttlMs: 30_000,
104
+ }
105
+ );
106
+ ```
107
+
108
+ `ttlMs: false` or an omitted TTL creates an entry without an application-level expiration.
109
+
110
+ `ttlMs: 0` deletes the existing entry instead of storing a new one.
111
+
112
+ The older `cache()` API continues to use `revalidate` in seconds for backward compatibility.
113
+
114
+ ## Tag invalidation
115
+
116
+ ```ts
117
+ await applicationCache.set(
118
+ "user:42",
119
+ user,
120
+ {
121
+ tags: [
122
+ "users",
123
+ "tenant:7",
124
+ ],
125
+ }
126
+ );
127
+
128
+ await applicationCache.revalidateTag(
129
+ "users"
130
+ );
131
+ ```
132
+
133
+ The returned number is the count of matching entries removed by the adapter.
134
+
135
+ ## Path invalidation
136
+
137
+ ```ts
138
+ await applicationCache.set(
139
+ "users-page",
140
+ data,
141
+ {
142
+ paths: [
143
+ "/dashboard/users",
144
+ ],
145
+ }
146
+ );
147
+
148
+ await applicationCache.revalidatePath(
149
+ "/dashboard"
150
+ );
151
+ ```
152
+
153
+ Path invalidation is hierarchical. Invalidating `/dashboard` also invalidates cached paths below `/dashboard/...`.
154
+
155
+ ## `getOrSet()`
156
+
157
+ Use `getOrSet()` for cache-aside loading:
158
+
159
+ ```ts
160
+ const user =
161
+ await applicationCache.getOrSet(
162
+ "user:42",
163
+ async () =>
164
+ databaseUser(42),
165
+ {
166
+ ttlMs: 60_000,
167
+ tags: ["users"],
168
+ }
169
+ );
170
+ ```
171
+
172
+ Within one `CacheStore`, concurrent calls for the same key share one in-flight loader.
173
+
174
+ ```text
175
+ Request A ----\
176
+ Request B -----+--> one loader --> cache
177
+ Request C ----/
178
+ ```
179
+
180
+ This local singleflight behavior is available even without a distributed lock adapter.
181
+
182
+ ## Distributed stampede protection
183
+
184
+ For multiple application instances, provide a shared `CacheLockAdapter`:
185
+
186
+ ```ts
187
+ const applicationCache =
188
+ createCacheStore({
189
+ adapter: redisCache,
190
+ lock: redisLock,
191
+ });
192
+ ```
193
+
194
+ The load flow becomes:
195
+
196
+ ```text
197
+ Instance A cache miss
198
+ |
199
+ +--> acquire distributed lock
200
+ | |
201
+ | +--> double-check cache
202
+ | +--> execute loader
203
+ | +--> store result
204
+ | +--> release lock
205
+ |
206
+ Instance B cache miss
207
+ |
208
+ +--> lock contention
209
+ |
210
+ +--> poll shared cache
211
+ +--> consume A's result
212
+ ```
213
+
214
+ Default values:
215
+
216
+ ```text
217
+ lockTtlMs 30000
218
+ waitTimeoutMs 5000
219
+ pollIntervalMs 25
220
+ ```
221
+
222
+ Override them per operation:
223
+
224
+ ```ts
225
+ await applicationCache.getOrSet(
226
+ "report:monthly",
227
+ buildReport,
228
+ {
229
+ lockTtlMs: 60_000,
230
+ waitTimeoutMs: 10_000,
231
+ pollIntervalMs: 50,
232
+ }
233
+ );
234
+ ```
235
+
236
+ ## Lock heartbeat
237
+
238
+ If the selected lock adapter implements `extend()`, BCP renews the acquired load lock while the loader is still active. The renewal interval is approximately one third of `lockTtlMs`.
239
+
240
+ This reduces duplicate work when a legitimate load takes longer than the original lease.
241
+
242
+ A process crash can still let the lease expire, which is intentional: another process must eventually be able to recover and refill the cache.
243
+
244
+ ## Lock timeout policy
245
+
246
+ The default timeout behavior is availability-oriented. If a process waits for another lock owner but still cannot acquire the lock after the timeout, it executes the loader rather than blocking indefinitely.
247
+
248
+ For workloads where duplicate computation is worse than a failed request:
249
+
250
+ ```ts
251
+ await applicationCache.getOrSet(
252
+ "expensive-model",
253
+ buildModel,
254
+ {
255
+ onLockTimeout: "error",
256
+ }
257
+ );
258
+ ```
259
+
260
+ This throws instead of running an unlocked fallback loader.
261
+
262
+ ## Memory lock adapter
263
+
264
+ Tests and single-process deployments can use:
265
+
266
+ ```ts
267
+ import {
268
+ createMemoryCacheAdapter,
269
+ createMemoryCacheLockAdapter,
270
+ } from "bcp/cache";
271
+
272
+ const adapter =
273
+ createMemoryCacheAdapter();
274
+ const lock =
275
+ createMemoryCacheLockAdapter();
276
+ ```
277
+
278
+ Multiple `CacheStore` instances can share these objects during tests to exercise cross-store contention behavior deterministically.
279
+
280
+ ## Redis-compatible cache adapter
281
+
282
+ BCP does not install or own a Redis library.
283
+
284
+ Provide a minimal command client:
285
+
286
+ ```ts
287
+ interface RedisCacheCommandClient {
288
+ sendCommand(
289
+ command: string[]
290
+ ): Promise<unknown>;
291
+ }
292
+ ```
293
+
294
+ Example adapter wiring:
295
+
296
+ ```ts
297
+ import {
298
+ createCacheStore,
299
+ createRedisCacheAdapter,
300
+ createRedisCacheLockAdapter,
301
+ } from "bcp/cache";
302
+
303
+ const redisCache =
304
+ createRedisCacheAdapter({
305
+ client: redisClient,
306
+ });
307
+
308
+ const redisLock =
309
+ createRedisCacheLockAdapter({
310
+ client: redisClient,
311
+ });
312
+
313
+ export const cache =
314
+ createCacheStore({
315
+ adapter: redisCache,
316
+ lock: redisLock,
317
+ });
318
+ ```
319
+
320
+ The default namespace is:
321
+
322
+ ```text
323
+ bcp:{cache}
324
+ ```
325
+
326
+ The `{cache}` hash tag keeps the reference adapter's related keys in one Redis Cluster slot for its Lua state transitions.
327
+
328
+ When overriding the namespace for Redis Cluster, keep a hash tag, for example:
329
+
330
+ ```text
331
+ my-app:{cache}
332
+ ```
333
+
334
+ ## Redis value serialization
335
+
336
+ The reference Redis adapter stores serialized values inside cache records. The default serializer is JSON.
337
+
338
+ Use custom serialization for values that JSON cannot safely represent:
339
+
340
+ ```ts
341
+ createRedisCacheAdapter({
342
+ client: redisClient,
343
+ serialize(value) {
344
+ return customEncode(value);
345
+ },
346
+ deserialize(value) {
347
+ return customDecode(value);
348
+ },
349
+ });
350
+ ```
351
+
352
+ The serializer must return a string.
353
+
354
+ ## Redis connection ownership
355
+
356
+ BCP never creates a Redis connection. The application owns:
357
+
358
+ - connection URL and credentials
359
+ - TLS
360
+ - Sentinel/Cluster configuration
361
+ - reconnect behavior
362
+ - process shutdown
363
+
364
+ An adapter can receive a close hook:
365
+
366
+ ```ts
367
+ createRedisCacheAdapter({
368
+ client: redisClient,
369
+ close: async () => {
370
+ await redisClient.quit();
371
+ },
372
+ });
373
+ ```
374
+
375
+ If the cache and lock adapters share one Redis connection, avoid registering two independent close hooks for that same connection. Prefer application-owned coordinated shutdown.
376
+
377
+ ## Redis atomic transitions
378
+
379
+ The reference cache adapter uses Lua for cache record/index mutations, tag invalidation and distributed lock compare-and-release / compare-and-renew operations.
380
+
381
+ This prevents one lock owner from releasing or renewing a lock that has already expired and been acquired by another owner.
382
+
383
+ Path invalidation first discovers registered path indexes and then performs a Lua cleanup over the matching sets. For extremely large path catalogs, a specialized provider can implement a more scalable indexing strategy behind the same `CacheAdapter` contract.
384
+
385
+ ## Metrics integration
386
+
387
+ Cache Platform v2 can emit metrics to the existing BCP metrics registry:
388
+
389
+ ```ts
390
+ import {
391
+ createCacheMetrics,
392
+ createCacheStore,
393
+ } from "bcp/cache";
394
+ import {
395
+ createMetricsRegistry,
396
+ } from "bcp/observability";
397
+
398
+ const metrics =
399
+ createMetricsRegistry();
400
+
401
+ const cache =
402
+ createCacheStore({
403
+ metrics:
404
+ createCacheMetrics(
405
+ metrics
406
+ ),
407
+ });
408
+ ```
409
+
410
+ The default metrics are:
411
+
412
+ ```text
413
+ bcp_cache_operations_total{event="..."}
414
+ bcp_cache_in_flight
415
+ ```
416
+
417
+ Events include hits, misses, sets, deletes, invalidations, loader outcomes and lock contention/timeout activity.
418
+
419
+ Use a custom prefix when multiple logical caches need separate metric families:
420
+
421
+ ```ts
422
+ createCacheMetrics(
423
+ metrics,
424
+ {
425
+ prefix:
426
+ "bcp_session_cache",
427
+ }
428
+ );
429
+ ```
430
+
431
+ ## Store statistics
432
+
433
+ ```ts
434
+ const stats =
435
+ await cache.stats();
436
+ ```
437
+
438
+ The snapshot includes:
439
+
440
+ ```text
441
+ hits
442
+ misses
443
+ sets
444
+ deletes
445
+ invalidations
446
+ loads
447
+ loadErrors
448
+ lockAcquired
449
+ lockContentions
450
+ lockTimeouts
451
+ inFlight
452
+ adapterEntries (when supported)
453
+ ```
454
+
455
+ Statistics belong to the `CacheStore` process. `adapterEntries` can come from shared storage when the provider supports it.
456
+
457
+ ## Failure model
458
+
459
+ A cache is an optimization, not the system of record.
460
+
461
+ Recommended production rules:
462
+
463
+ 1. Keep durable application truth in the database/object store.
464
+ 2. Design loaders so cache loss can be recovered by recomputation.
465
+ 3. Choose `onLockTimeout` based on whether availability or duplicate-work avoidance matters more.
466
+ 4. Do not use distributed cache locks as a replacement for database transaction/uniqueness rules protecting business invariants.
467
+ 5. Treat Redis/cache outages separately from durable queues, workflows and outbox guarantees.
468
+
469
+ ## Closing the store
470
+
471
+ ```ts
472
+ await cache.close();
473
+ ```
474
+
475
+ `close()` clears local in-flight bookkeeping and calls configured adapter/lock lifecycle hooks.
476
+
477
+ ## Published runtime
478
+
479
+ `0.2.16` prepares `bcp/cache` as compiled `cache.mjs` for the npm package. TypeScript source remains the type surface, while standalone Node imports execute the compiled runtime.
480
+
481
+ ## Related guides
482
+
483
+ - [Caching](caching.md)
484
+ - [Observability Platform v2](observability.md)
485
+ - [Plugin & Module Platform](plugin-module-platform.md)
486
+ - [Testing Platform](testing-platform.md)
487
+ - [Production Hardening](production-hardening.md)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "framework": "bcp",
4
- "versionTarget": "0.2.15",
4
+ "versionTarget": "0.2.16",
5
5
  "releaseState": "unreleased",
6
6
  "sections": [
7
7
  {
@@ -58,7 +58,7 @@
58
58
  {
59
59
  "id": "runtime",
60
60
  "title": "Runtime & Infrastructure",
61
- "description": "Middleware, jobs, scheduling, workflows, event delivery, realtime channels, observability, caching, security and production hardening.",
61
+ "description": "Middleware, jobs, scheduling, workflows, event delivery, realtime channels, observability, distributed caching, security and production hardening.",
62
62
  "pages": [
63
63
  { "route": "/docs/middleware", "source": "middleware.md", "title": "Middleware" },
64
64
  { "route": "/docs/hydration", "source": "hydration.md", "title": "Hydration" },
@@ -70,6 +70,7 @@
70
70
  { "route": "/docs/workflow-orchestration", "source": "workflow-orchestration.md", "title": "Workflow Orchestration" },
71
71
  { "route": "/docs/realtime-platform", "source": "realtime-platform.md", "title": "Realtime Platform" },
72
72
  { "route": "/docs/caching", "source": "caching.md", "title": "Caching" },
73
+ { "route": "/docs/cache-platform-v2", "source": "cache-platform-v2.md", "title": "Cache Platform v2" },
73
74
  { "route": "/docs/security", "source": "security.md", "title": "Security" },
74
75
  { "route": "/docs/production-hardening", "source": "production-hardening.md", "title": "Production Hardening" }
75
76
  ]
@@ -116,7 +117,8 @@
116
117
  }
117
118
  ],
118
119
  "releases": [
119
- { "route": "/releases/0.2.15", "source": "releases/0.2.15.md", "version": "0.2.15", "state": "unreleased" },
120
+ { "route": "/releases/0.2.16", "source": "releases/0.2.16.md", "version": "0.2.16", "state": "unreleased" },
121
+ { "route": "/releases/0.2.15", "source": "releases/0.2.15.md", "version": "0.2.15" },
120
122
  { "route": "/releases/0.2.14", "source": "releases/0.2.14.md", "version": "0.2.14" },
121
123
  { "route": "/releases/0.2.13", "source": "releases/0.2.13.md", "version": "0.2.13" },
122
124
  { "route": "/releases/0.2.12", "source": "releases/0.2.12.md", "version": "0.2.12" },
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "framework": "bcp",
4
- "version": "0.2.15",
4
+ "version": "0.2.16",
5
5
  "releaseState": "unreleased",
6
- "baseline": "plugin-module-platform",
6
+ "baseline": "cache-platform-v2",
7
7
  "runtime": {
8
8
  "node": ">=24.11.0",
9
9
  "react": "19",
@@ -148,6 +148,19 @@
148
148
  "pluginServiceRegistry": true,
149
149
  "pluginHookBus": true,
150
150
  "pluginLifecycleRollback": true,
151
+ "cachePlatformV2": true,
152
+ "cacheAdapterContract": true,
153
+ "memoryCacheAdapter": true,
154
+ "redisCacheAdapter": true,
155
+ "cacheLockAdapter": true,
156
+ "redisCacheLockAdapter": true,
157
+ "cacheStampedeProtection": true,
158
+ "cacheLockHeartbeats": true,
159
+ "cacheTtl": true,
160
+ "cacheTagInvalidation": true,
161
+ "cachePathInvalidation": true,
162
+ "cacheMetrics": true,
163
+ "compiledCacheRuntime": true,
151
164
  "databaseMigrations": true,
152
165
  "databaseAdapterContract": true,
153
166
  "databasePostgresql": true,
@@ -187,7 +200,7 @@
187
200
  "s3-compatible"
188
201
  ],
189
202
  "compatibility": {
190
- "previousBaseline": "0.2.14",
203
+ "previousBaseline": "0.2.15",
191
204
  "intentionalBreakingChangesFromPreviousBaseline": false,
192
205
  "migrationGuide": "migration-0.2.md"
193
206
  },
@@ -212,7 +225,8 @@
212
225
  "realtimePlatform": "realtime-platform.md",
213
226
  "testingPlatform": "testing-platform.md",
214
227
  "pluginModulePlatform": "plugin-module-platform.md",
228
+ "cachePlatformV2": "cache-platform-v2.md",
215
229
  "migrationGuide": "migration-0.2.md",
216
- "releaseNotes": "releases/0.2.15.md"
230
+ "releaseNotes": "releases/0.2.16.md"
217
231
  }
218
232
  }
@@ -0,0 +1,147 @@
1
+ # BCP Framework 0.2.16
2
+
3
+ **State:** unreleased
4
+
5
+ ## Cache Platform v2
6
+
7
+ `0.2.16` upgrades the existing `bcp/cache` public entrypoint with provider-neutral asynchronous cache storage, distributed lock contracts, Redis-compatible reference adapters, cache-stampede protection and metrics integration.
8
+
9
+ The existing `cache()`, `dedupe()`, `revalidateTag()` and `revalidatePath()` APIs remain available with their previous process-local behavior.
10
+
11
+ ## New public APIs
12
+
13
+ ```ts
14
+ createCacheStore()
15
+ createMemoryCacheAdapter()
16
+ createMemoryCacheLockAdapter()
17
+ createRedisCacheAdapter()
18
+ createRedisCacheLockAdapter()
19
+ createCacheMetrics()
20
+ ```
21
+
22
+ New public contracts include:
23
+
24
+ ```text
25
+ CacheAdapter
26
+ CacheAdapterEntry
27
+ CacheAdapterSetOptions
28
+ CacheLockAdapter
29
+ CacheStore
30
+ CacheStoreOptions
31
+ CacheStoreSetOptions
32
+ CacheGetOrSetOptions
33
+ CacheStoreStats
34
+ CacheMetricsSink
35
+ CacheMetricsRegistryLike
36
+ RedisCacheCommandClient
37
+ RedisCacheAdapterOptions
38
+ RedisCacheLockAdapterOptions
39
+ ```
40
+
41
+ ## Cache-aside loading
42
+
43
+ `CacheStore.getOrSet()` provides a framework-native cache-aside primitive.
44
+
45
+ Within one process it deduplicates concurrent loaders for the same key. With a shared `CacheLockAdapter`, multiple BCP instances coordinate cache fills through distributed leases.
46
+
47
+ ## Distributed lock behavior
48
+
49
+ The built-in lock implementations support:
50
+
51
+ - acquire with owner identity and TTL
52
+ - compare-and-release
53
+ - optional compare-and-extend
54
+ - heartbeat renewal while a loader is active
55
+ - contention wait/poll against shared cache state
56
+ - explicit timeout failure with `onLockTimeout: "error"`
57
+ - availability-oriented unlocked fallback by default after timeout
58
+
59
+ Distributed locks reduce duplicate cache fill work. They are not a substitute for database transactions or uniqueness constraints protecting business invariants.
60
+
61
+ ## Redis-compatible adapters
62
+
63
+ BCP still does not depend on a Redis package.
64
+
65
+ Applications provide a minimal command client:
66
+
67
+ ```ts
68
+ interface RedisCacheCommandClient {
69
+ sendCommand(
70
+ command: string[]
71
+ ): Promise<unknown>;
72
+ }
73
+ ```
74
+
75
+ The default namespace is `bcp:{cache}` so related keys share a Redis Cluster hash slot.
76
+
77
+ The reference adapter uses Lua for record/index changes, tag invalidation, lock release and lock renewal.
78
+
79
+ Applications remain responsible for Redis authentication, TLS, Cluster/Sentinel configuration, reconnect behavior and connection shutdown.
80
+
81
+ ## TTL and invalidation
82
+
83
+ Cache Store v2 supports:
84
+
85
+ - millisecond TTL via `ttlMs`
86
+ - tag invalidation
87
+ - hierarchical path invalidation
88
+ - adapter-wide clear
89
+ - optional provider entry counts
90
+
91
+ The older `cache()` API continues to accept `revalidate` in seconds for compatibility.
92
+
93
+ ## Observability
94
+
95
+ `createCacheMetrics()` adapts Cache Store events to `bcp/observability`'s existing `MetricsRegistry` contract.
96
+
97
+ Default metric families:
98
+
99
+ ```text
100
+ bcp_cache_operations_total{event="..."}
101
+ bcp_cache_in_flight
102
+ ```
103
+
104
+ ## Published runtime
105
+
106
+ The prepared npm package now compiles `bcp/cache` to:
107
+
108
+ ```text
109
+ packages/client/src/cache.mjs
110
+ ```
111
+
112
+ The prepared package export points runtime imports at this compiled file while preserving `cache.ts` as the public type source.
113
+
114
+ ## Testing
115
+
116
+ The `0.2.16` suite adds coverage for:
117
+
118
+ - TTL expiration
119
+ - tag/path invalidation
120
+ - store statistics
121
+ - local in-flight deduplication
122
+ - cross-store distributed stampede protection
123
+ - lock timeout errors
124
+ - BCP metrics registry integration
125
+ - Redis command/namespace contract
126
+ - compiled prepared-package runtime smoke
127
+
128
+ ## Compatibility
129
+
130
+ `0.2.16` has no intentional breaking changes from `0.2.15`.
131
+
132
+ Existing application calls using the original cache APIs do not need to migrate.
133
+
134
+ ## Release validation
135
+
136
+ Before publishing:
137
+
138
+ ```bash
139
+ npm run typecheck
140
+ npm run test:unit
141
+ npm run test:integration
142
+ npm run test:e2e
143
+ npm run test:package
144
+ npm run rc:check
145
+ ```
146
+
147
+ Tag and publish only the exact commit that passes the complete RC sequence.