@basaltkit/cache 1.4.0 → 1.4.1

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/README.md CHANGED
@@ -244,6 +244,12 @@ closed turns it into a stack trace at the call site.
244
244
  Single-tenant apps are untouched: no tenancy plugin, no marker, so the default stays `'global'`
245
245
  and nothing changes.
246
246
 
247
+ `flush()` follows the same gate. It wipes a whole prefix, so in a multi-tenant app an unresolved
248
+ scope must fail closed — otherwise one mis-scoped call clears every tenant's cache. Without
249
+ tenancy, the prefix *is* this app's own cache and clearing it is exactly what `flush()` means, so
250
+ it proceeds. An explicit `onMissingScope: 'error'` keeps `flush()` fail-closed either way, and
251
+ `scope: null` (a deliberate global cache) may always flush its namespace.
252
+
247
253
  ```ts
248
254
  // Multi-tenant: this now throws instead of poisoning the shared namespace.
249
255
  cachePlugin({ driver: 'redis', url }) // + tenancyPlugin() registered → onMissingScope: 'error'
package/dist/index.d.ts CHANGED
@@ -19,8 +19,9 @@ export interface CacheOptions {
19
19
  * What to do when the scope function resolves nothing (no tenant in context):
20
20
  * `'global'` (default) shares one namespace — convenient but a per-tenant value
21
21
  * cached without a tenant leaks to others; `'error'` fails closed (throws
22
- * {@link MissingCacheScopeError}) on read/write. `flush()` ALWAYS fails closed
23
- * regardless, so a mis-scoped call can't wipe every tenant's cache.
22
+ * {@link MissingCacheScopeError}) on read/write. In a multi-tenant app (or
23
+ * with an explicit `'error'`), `flush()` fails closed too, so a mis-scoped
24
+ * call can't wipe every tenant's cache.
24
25
  */
25
26
  onMissingScope?: 'global' | 'error';
26
27
  /** Injectable clock (ms) for stale-while-revalidate windows. Default: Date.now. */
@@ -39,13 +40,25 @@ export interface SwrOptions {
39
40
  }
40
41
  export declare class Cache {
41
42
  private readonly driver;
43
+ /**
44
+ * Whether the host app registered `@basaltkit/tenancy`. `cachePlugin` wires
45
+ * this to the container's `'tenancy:active'` metadata marker — a signal,
46
+ * not an import. Defaults to `false` (single-tenant).
47
+ */
48
+ private readonly tenancyActive;
42
49
  private readonly prefix;
43
50
  private readonly scope;
44
51
  private readonly onMissingScope;
45
52
  private readonly now;
46
53
  /** dedupe of in-flight factories — per-process stampede protection (also dedupes SWR revalidation) */
47
54
  private readonly pending;
48
- constructor(driver: CacheDriver, options?: CacheOptions);
55
+ constructor(driver: CacheDriver, options?: CacheOptions,
56
+ /**
57
+ * Whether the host app registered `@basaltkit/tenancy`. `cachePlugin` wires
58
+ * this to the container's `'tenancy:active'` metadata marker — a signal,
59
+ * not an import. Defaults to `false` (single-tenant).
60
+ */
61
+ tenancyActive?: () => boolean);
49
62
  get<T>(key: string): Promise<T | undefined>;
50
63
  get<T>(key: string, fallback: T): Promise<T>;
51
64
  put(key: string, value: unknown, ttl?: DurationInput): Promise<void>;
package/dist/index.js CHANGED
@@ -42,14 +42,22 @@ const defaultScope = () => {
42
42
  };
43
43
  export class Cache {
44
44
  driver;
45
+ tenancyActive;
45
46
  prefix;
46
47
  scope;
47
48
  onMissingScope;
48
49
  now;
49
50
  /** dedupe of in-flight factories — per-process stampede protection (also dedupes SWR revalidation) */
50
51
  pending = new Map();
51
- constructor(driver, options = {}) {
52
+ constructor(driver, options = {},
53
+ /**
54
+ * Whether the host app registered `@basaltkit/tenancy`. `cachePlugin` wires
55
+ * this to the container's `'tenancy:active'` metadata marker — a signal,
56
+ * not an import. Defaults to `false` (single-tenant).
57
+ */
58
+ tenancyActive = () => false) {
52
59
  this.driver = driver;
60
+ this.tenancyActive = tenancyActive;
53
61
  this.prefix = options.prefix ?? 'basalt';
54
62
  this.scope = options.scope === undefined ? defaultScope : options.scope;
55
63
  this.onMissingScope = options.onMissingScope ?? 'global';
@@ -71,10 +79,15 @@ export class Cache {
71
79
  }
72
80
  /** Clears only the keys under this prefix/scope — never the entire Redis. */
73
81
  async flush() {
74
- // Always fail closed: a whole-namespace wipe with an unresolved tenant scope
75
- // would delete EVERY tenant's cache. `scope:null` (deliberate global) is fine.
76
- if (this.scope !== null && this.scope() === undefined)
82
+ // Fail closed where a wipe could cross a boundary: with tenancy registered
83
+ // (or an explicit onMissingScope:'error'), an unresolved scope would delete
84
+ // EVERY tenant's cache. In a single-tenant app the whole prefix IS this
85
+ // app's cache, which is exactly what flush() means — so it proceeds.
86
+ // `scope:null` (deliberate global) is fine either way.
87
+ const failClosed = this.tenancyActive() || this.onMissingScope === 'error';
88
+ if (failClosed && this.scope !== null && this.scope() === undefined) {
77
89
  throw new MissingCacheScopeError('flush');
90
+ }
78
91
  await this.driver.flushPrefix(this.root());
79
92
  }
80
93
  /** Tag-scoped operations: `cache.tags('plans').flush()` invalidates the group. */
@@ -198,11 +211,11 @@ export function cachePlugin(options = {}) {
198
211
  // with no resolvable tenant scope throws instead of silently sharing
199
212
  // one global namespace across tenants. Single-tenant apps (no tenancy)
200
213
  // are untouched, and an explicit `onMissingScope`/custom `scope` wins.
201
- const tenancyActive = ensureMetadata(container).get('tenancy:active').length > 0;
202
- const resolved = options.onMissingScope === undefined && options.scope === undefined && tenancyActive
214
+ const tenancyActive = () => ensureMetadata(container).get('tenancy:active').length > 0;
215
+ const resolved = options.onMissingScope === undefined && options.scope === undefined && tenancyActive()
203
216
  ? { ...options, onMissingScope: 'error' }
204
217
  : options;
205
- return new Cache(driver, resolved);
218
+ return new Cache(driver, resolved, tenancyActive);
206
219
  });
207
220
  },
208
221
  async shutdown() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/cache",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "engines": {
5
5
  "node": ">=22.5.0"
6
6
  },