@toapi/cache 0.3.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/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @toapi/cache
2
+
3
+ Reference cache implementation for Toapi.
4
+
5
+ Usually used to add caching to [@toapi/server](https://www.npmjs.com/package/@toapi/server).
@@ -0,0 +1,18 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import type { CacheEntry, Cache, Subscription, Json } from "./index";
3
+ export declare class FilesystemCache implements Cache {
4
+ db: DatabaseSync;
5
+ gcTimeout: number;
6
+ subscribers: Set<Subscription>;
7
+ constructor(filename: string);
8
+ get(key: string): Promise<CacheEntry | null>;
9
+ set({ key, data, attachment, ttl, tags, }: CacheEntry & {
10
+ key: string;
11
+ ttl: number;
12
+ tags: string[];
13
+ }): Promise<void>;
14
+ delete(tags: string[], meta?: Json): Promise<void>;
15
+ gc(): void;
16
+ subscribe(callback: Subscription): () => void;
17
+ }
18
+ //# sourceMappingURL=filesystem-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filesystem-cache.d.ts","sourceRoot":"","sources":["../src/filesystem-cache.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAOrE,qBAAa,eAAgB,YAAW,KAAK;IAC3C,EAAE,EAAE,YAAY,CAAC;IACjB,SAAS,SAAkB;IAC3B,WAAW,oBAA2B;gBAE1B,QAAQ,EAAE,MAAM;IAgCtB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAqBlD,GAAG,CAAC,EACF,GAAG,EACH,IAAI,EACJ,UAAU,EACV,GAAG,EACH,IAAI,GACL,EAAE,UAAU,GAAG;QACd,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,MAAM,EAAE,CAAC;KAChB,GAAG,OAAO,CAAC,IAAI,CAAC;IAoCjB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IA6BlD,EAAE;IAuCF,SAAS,CAAC,QAAQ,EAAE,YAAY,GAAG,MAAM,IAAI;CAO9C"}
@@ -0,0 +1,143 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { existsSync } from "node:fs";
3
+ const MIN_GC_TIMEOUT = 5 * 1000;
4
+ const MAX_GC_TIMEOUT = 5 * 60 * 1000;
5
+ const GC_TIMEOUT_STEP = 0.1;
6
+ export class FilesystemCache {
7
+ db;
8
+ gcTimeout = MIN_GC_TIMEOUT;
9
+ subscribers = new Set();
10
+ constructor(filename) {
11
+ if (existsSync(filename)) {
12
+ this.db = new DatabaseSync(filename);
13
+ setTimeout(() => {
14
+ this.gc();
15
+ }, this.gcTimeout);
16
+ return;
17
+ }
18
+ this.db = new DatabaseSync(filename);
19
+ this.db.exec(`
20
+ PRAGMA journal_mode = WAL;
21
+ BEGIN;
22
+ CREATE TABLE entries (
23
+ key TEXT PRIMARY KEY,
24
+ data TEXT,
25
+ attachment BLOB,
26
+ added_at INTEGER,
27
+ expires_at INTEGER
28
+ );
29
+ CREATE TABLE tags (
30
+ key TEXT,
31
+ tag TEXT,
32
+ UNIQUE(key, tag),
33
+ FOREIGN KEY (key) REFERENCES entries(key) ON DELETE CASCADE
34
+ );
35
+ COMMIT;
36
+ `);
37
+ this.gc();
38
+ }
39
+ async get(key) {
40
+ const result = this.db
41
+ .prepare(`
42
+ SELECT data, attachment
43
+ FROM entries
44
+ WHERE key = ? AND expires_at >= ?
45
+ `)
46
+ .get(key, Date.now());
47
+ return Promise.resolve(result
48
+ ? {
49
+ data: result.data ? JSON.parse(result.data) : null,
50
+ attachment: result.attachment,
51
+ }
52
+ : null);
53
+ }
54
+ set({ key, data, attachment, ttl, tags, }) {
55
+ this.db.exec("BEGIN;");
56
+ try {
57
+ this.db
58
+ .prepare(`
59
+ INSERT OR REPLACE INTO entries (key, data, attachment, added_at, expires_at)
60
+ VALUES (?, ?, ?, ?, ?);
61
+ `)
62
+ .run(key, data ? JSON.stringify(data) : null, attachment ?? null, Date.now(), Date.now() + ttl * 1000);
63
+ const stmt = this.db.prepare(`
64
+ INSERT OR IGNORE INTO tags (key, tag) VALUES (?, ?)
65
+ `);
66
+ for (const tag of tags) {
67
+ stmt.run(key, tag);
68
+ }
69
+ this.db.exec("COMMIT;");
70
+ }
71
+ catch (e) {
72
+ try {
73
+ this.db.exec("ROLLBACK;");
74
+ }
75
+ catch { }
76
+ throw e;
77
+ }
78
+ return Promise.resolve();
79
+ }
80
+ delete(tags, meta) {
81
+ this.db.exec("BEGIN;");
82
+ try {
83
+ const stmt = this.db.prepare(`
84
+ DELETE FROM entries WHERE key IN
85
+ (SELECT key FROM tags WHERE tag = ?);
86
+ `);
87
+ for (const tag of tags) {
88
+ stmt.run(tag);
89
+ }
90
+ this.db.exec("COMMIT;");
91
+ }
92
+ catch (e) {
93
+ try {
94
+ this.db.exec("ROLLBACK;");
95
+ }
96
+ catch { }
97
+ throw e;
98
+ }
99
+ for (const callback of this.subscribers) {
100
+ callback(tags, meta);
101
+ }
102
+ return Promise.resolve();
103
+ }
104
+ gc() {
105
+ try {
106
+ this.db.exec("BEGIN;");
107
+ let changes = 0;
108
+ try {
109
+ const result = this.db
110
+ .prepare(`
111
+ DELETE FROM entries WHERE expires_at < ?;
112
+ `)
113
+ .run(Date.now());
114
+ changes = result.changes;
115
+ this.db.exec("COMMIT;");
116
+ }
117
+ catch (e) {
118
+ try {
119
+ this.db.exec("ROLLBACK;");
120
+ }
121
+ catch { }
122
+ throw e;
123
+ }
124
+ if (changes > 100) {
125
+ this.gcTimeout = Math.max(Math.floor(this.gcTimeout * (1 - GC_TIMEOUT_STEP)), MIN_GC_TIMEOUT);
126
+ }
127
+ else if (changes < 10) {
128
+ this.gcTimeout = Math.min(Math.floor(this.gcTimeout * (1 + GC_TIMEOUT_STEP)), MAX_GC_TIMEOUT);
129
+ }
130
+ }
131
+ finally {
132
+ setTimeout(() => {
133
+ this.gc();
134
+ }, this.gcTimeout);
135
+ }
136
+ }
137
+ subscribe(callback) {
138
+ this.subscribers.add(callback);
139
+ return () => {
140
+ this.subscribers.delete(callback);
141
+ };
142
+ }
143
+ }
@@ -0,0 +1,18 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import type { CacheEntry, Cache, Subscription, Json } from "./index";
3
+ export declare class InMemoryCache implements Cache {
4
+ db: DatabaseSync;
5
+ gcTimeout: number;
6
+ subscribers: Set<Subscription>;
7
+ constructor();
8
+ get(key: string): Promise<CacheEntry | null>;
9
+ set({ key, data, attachment, ttl, tags, }: CacheEntry & {
10
+ key: string;
11
+ ttl: number;
12
+ tags: string[];
13
+ }): Promise<void>;
14
+ delete(tags: string[], meta?: Json): Promise<void>;
15
+ gc(): void;
16
+ subscribe(callback: Subscription): () => void;
17
+ }
18
+ //# sourceMappingURL=in-memory-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"in-memory-cache.d.ts","sourceRoot":"","sources":["../src/in-memory-cache.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAMrE,qBAAa,aAAc,YAAW,KAAK;IACzC,EAAE,eAAgC;IAClC,SAAS,SAAkB;IAC3B,WAAW,oBAA2B;;IA2BhC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAqBlD,GAAG,CAAC,EACF,GAAG,EACH,IAAI,EACJ,UAAU,EACV,GAAG,EACH,IAAI,GACL,EAAE,UAAU,GAAG;QACd,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,MAAM,EAAE,CAAC;KAChB,GAAG,OAAO,CAAC,IAAI,CAAC;IAoCjB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IA6BlD,EAAE;IAuCF,SAAS,CAAC,QAAQ,EAAE,YAAY,GAAG,MAAM,IAAI;CAO9C"}
@@ -0,0 +1,136 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ const MIN_GC_TIMEOUT = 5 * 1000;
3
+ const MAX_GC_TIMEOUT = 5 * 60 * 1000;
4
+ const GC_TIMEOUT_STEP = 0.1;
5
+ export class InMemoryCache {
6
+ db = new DatabaseSync(":memory:");
7
+ gcTimeout = MIN_GC_TIMEOUT;
8
+ subscribers = new Set();
9
+ constructor() {
10
+ this.db.exec(`
11
+ PRAGMA journal_mode = WAL;
12
+ BEGIN;
13
+ CREATE TABLE entries (
14
+ key TEXT PRIMARY KEY,
15
+ data TEXT,
16
+ attachment BLOB,
17
+ added_at INTEGER,
18
+ expires_at INTEGER
19
+ );
20
+ CREATE TABLE tags (
21
+ key TEXT,
22
+ tag TEXT,
23
+ UNIQUE(key, tag),
24
+ FOREIGN KEY (key) REFERENCES entries(key) ON DELETE CASCADE
25
+ );
26
+ COMMIT;
27
+ `);
28
+ setTimeout(() => {
29
+ this.gc();
30
+ }, this.gcTimeout);
31
+ }
32
+ async get(key) {
33
+ const result = this.db
34
+ .prepare(`
35
+ SELECT data, attachment
36
+ FROM entries
37
+ WHERE key = ? AND expires_at >= ?
38
+ `)
39
+ .get(key, Date.now());
40
+ return Promise.resolve(result
41
+ ? {
42
+ data: result.data ? JSON.parse(result.data) : null,
43
+ attachment: result.attachment,
44
+ }
45
+ : null);
46
+ }
47
+ set({ key, data, attachment, ttl, tags, }) {
48
+ this.db.exec("BEGIN;");
49
+ try {
50
+ this.db
51
+ .prepare(`
52
+ INSERT OR REPLACE INTO entries (key, data, attachment, added_at, expires_at)
53
+ VALUES (?, ?, ?, ?, ?);
54
+ `)
55
+ .run(key, data ? JSON.stringify(data) : null, attachment ?? null, Date.now(), Date.now() + ttl * 1000);
56
+ const stmt = this.db.prepare(`
57
+ INSERT OR IGNORE INTO tags (key, tag) VALUES (?, ?)
58
+ `);
59
+ for (const tag of tags) {
60
+ stmt.run(key, tag);
61
+ }
62
+ this.db.exec("COMMIT;");
63
+ }
64
+ catch (e) {
65
+ try {
66
+ this.db.exec("ROLLBACK;");
67
+ }
68
+ catch { }
69
+ throw e;
70
+ }
71
+ return Promise.resolve();
72
+ }
73
+ delete(tags, meta) {
74
+ this.db.exec("BEGIN;");
75
+ try {
76
+ const stmt = this.db.prepare(`
77
+ DELETE FROM entries WHERE key IN
78
+ (SELECT key FROM tags WHERE tag = ?);
79
+ `);
80
+ for (const tag of tags) {
81
+ stmt.run(tag);
82
+ }
83
+ this.db.exec("COMMIT;");
84
+ }
85
+ catch (e) {
86
+ try {
87
+ this.db.exec("ROLLBACK;");
88
+ }
89
+ catch { }
90
+ throw e;
91
+ }
92
+ for (const callback of this.subscribers) {
93
+ callback(tags, meta);
94
+ }
95
+ return Promise.resolve();
96
+ }
97
+ gc() {
98
+ try {
99
+ this.db.exec("BEGIN;");
100
+ let changes = 0;
101
+ try {
102
+ const result = this.db
103
+ .prepare(`
104
+ DELETE FROM entries WHERE expires_at < ?;
105
+ `)
106
+ .run(Date.now());
107
+ changes = result.changes;
108
+ this.db.exec("COMMIT;");
109
+ }
110
+ catch (e) {
111
+ try {
112
+ this.db.exec("ROLLBACK;");
113
+ }
114
+ catch { }
115
+ throw e;
116
+ }
117
+ if (changes > 100) {
118
+ this.gcTimeout = Math.max(Math.floor(this.gcTimeout * (1 - GC_TIMEOUT_STEP)), MIN_GC_TIMEOUT);
119
+ }
120
+ else if (changes < 10) {
121
+ this.gcTimeout = Math.min(Math.floor(this.gcTimeout * (1 + GC_TIMEOUT_STEP)), MAX_GC_TIMEOUT);
122
+ }
123
+ }
124
+ finally {
125
+ setTimeout(() => {
126
+ this.gc();
127
+ }, this.gcTimeout);
128
+ }
129
+ }
130
+ subscribe(callback) {
131
+ this.subscribers.add(callback);
132
+ return () => {
133
+ this.subscribers.delete(callback);
134
+ };
135
+ }
136
+ }
@@ -0,0 +1,23 @@
1
+ export type Json = string | number | boolean | null | Json[] | {
2
+ [key: string]: Json;
3
+ };
4
+ export interface CacheEntry {
5
+ data?: Json | null;
6
+ attachment?: Uint8Array | null;
7
+ }
8
+ export interface Subscription {
9
+ (tags: string[], meta?: Json): void;
10
+ }
11
+ export interface Cache {
12
+ get(key: string): Promise<CacheEntry | null>;
13
+ set(input: CacheEntry & {
14
+ key: string;
15
+ ttl: number;
16
+ tags: string[];
17
+ }): Promise<void>;
18
+ delete(tags: string[], meta?: {
19
+ clientId?: string;
20
+ }): Promise<void>;
21
+ subscribe(callback: Subscription): () => void;
22
+ }
23
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,IAAI,GACZ,MAAM,GACN,MAAM,GACN,OAAO,GACP,IAAI,GACJ,IAAI,EAAE,GACN;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC;AAE5B,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACnB,UAAU,CAAC,EAAE,UAAU,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,YAAY;IAC3B,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;CACrC;AAED,MAAM,WAAW,KAAK;IACpB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IAC7C,GAAG,CACD,KAAK,EAAE,UAAU,GAAG;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,EAAE,CAAA;KAAE,GAC/D,OAAO,CAAC,IAAI,CAAC,CAAC;IACjB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE,SAAS,CAAC,QAAQ,EAAE,YAAY,GAAG,MAAM,IAAI,CAAC;CAC/C"}
package/dist/index.js ADDED
File without changes
@@ -0,0 +1,13 @@
1
+ import type { Cache, CacheEntry, Subscription } from "./index";
2
+ export declare class NoCache implements Cache {
3
+ subscribers: Set<Subscription>;
4
+ get(_key: string): Promise<CacheEntry | null>;
5
+ set(_: CacheEntry & {
6
+ key: string;
7
+ ttl: number;
8
+ tags: string[];
9
+ }): Promise<void>;
10
+ delete(tags: string[]): Promise<void>;
11
+ subscribe(callback: Subscription): () => void;
12
+ }
13
+ //# sourceMappingURL=no-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"no-cache.d.ts","sourceRoot":"","sources":["../src/no-cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE/D,qBAAa,OAAQ,YAAW,KAAK;IACnC,WAAW,oBAA2B;IAEhC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAInD,GAAG,CACD,CAAC,EAAE,UAAU,GAAG;QACd,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,MAAM,EAAE,CAAC;KAChB,GACA,OAAO,CAAC,IAAI,CAAC;IAIhB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAQrC,SAAS,CAAC,QAAQ,EAAE,YAAY,GAAG,MAAM,IAAI;CAO9C"}
@@ -0,0 +1,21 @@
1
+ export class NoCache {
2
+ subscribers = new Set();
3
+ async get(_key) {
4
+ return null;
5
+ }
6
+ set(_) {
7
+ return Promise.resolve();
8
+ }
9
+ delete(tags) {
10
+ for (const callback of this.subscribers) {
11
+ callback(tags);
12
+ }
13
+ return Promise.resolve();
14
+ }
15
+ subscribe(callback) {
16
+ this.subscribers.add(callback);
17
+ return () => {
18
+ this.subscribers.delete(callback);
19
+ };
20
+ }
21
+ }
@@ -0,0 +1,54 @@
1
+ import type { Pool, PoolClient } from "pg";
2
+ import type { Cache, CacheEntry, Json, Subscription } from "./index";
3
+ export interface PostgresCacheOptions {
4
+ /**
5
+ * When `true` (the default) the required tables and indexes are created
6
+ * lazily on the first operation via `CREATE TABLE IF NOT EXISTS`. Set to
7
+ * `false` if you manage the schema yourself (e.g. via migrations).
8
+ */
9
+ createSchema?: boolean;
10
+ /**
11
+ * The Postgres schema to hold the cache tables. Defaults to whatever the
12
+ * connection's `search_path` resolves to (usually `public`). When set, the
13
+ * tables are fully qualified as `<schema>.cache_entries` /
14
+ * `<schema>.cache_tags` and — with `createSchema` enabled — the schema is
15
+ * created if it does not already exist. Each schema also gets its own
16
+ * invalidation channel so caches on different schemas do not cross-notify.
17
+ */
18
+ schema?: string;
19
+ }
20
+ export declare class PostgresCache implements Cache {
21
+ private pool;
22
+ private options;
23
+ subscriptions: Set<Subscription>;
24
+ listener?: PoolClient;
25
+ gcTimeout: number;
26
+ private ready;
27
+ private gcTimer?;
28
+ private stopped;
29
+ /** Fully-qualified (schema-prefixed when applicable) table identifiers. */
30
+ private readonly entriesTable;
31
+ private readonly tagsTable;
32
+ /** Invalidation channel, namespaced by schema so schemas stay isolated. */
33
+ private readonly channel;
34
+ constructor(pool: Pool, options?: PostgresCacheOptions);
35
+ private setup;
36
+ get(key: string): Promise<CacheEntry | null>;
37
+ set({ key, data, attachment, ttl, tags, }: CacheEntry & {
38
+ key: string;
39
+ ttl: number;
40
+ tags: string[];
41
+ }): Promise<void>;
42
+ delete(tags: string[], meta?: Json): Promise<void>;
43
+ private setupListener;
44
+ subscribe(callback: Subscription): () => void;
45
+ private gc;
46
+ /**
47
+ * Stops the background garbage collector and releases the dedicated
48
+ * invalidation listener connection back to the pool. Call this before
49
+ * shutting down so no timers or connections are left dangling. The
50
+ * underlying pool is left untouched — the caller owns its lifecycle.
51
+ */
52
+ close(): Promise<void>;
53
+ }
54
+ //# sourceMappingURL=postgres-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres-cache.d.ts","sourceRoot":"","sources":["../src/postgres-cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAgB,MAAM,IAAI,CAAC;AACzD,OAAO,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAkBrE,MAAM,WAAW,oBAAoB;IACnC;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,aAAc,YAAW,KAAK;IAgBvC,OAAO,CAAC,IAAI;IACZ,OAAO,CAAC,OAAO;IAhBjB,aAAa,oBAA2B;IACxC,QAAQ,CAAC,EAAE,UAAU,CAAC;IACtB,SAAS,SAAkB;IAE3B,OAAO,CAAC,KAAK,CAAgB;IAC7B,OAAO,CAAC,OAAO,CAAC,CAAgC;IAChD,OAAO,CAAC,OAAO,CAAS;IAExB,2EAA2E;IAC3E,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,2EAA2E;IAC3E,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;gBAGvB,IAAI,EAAE,IAAI,EACV,OAAO,GAAE,oBAAyB;YAW9B,KAAK;IA8Bb,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAqB5C,GAAG,CAAC,EACR,GAAG,EACH,IAAI,EACJ,UAAU,EACV,GAAG,EACH,IAAI,GACL,EAAE,UAAU,GAAG;QACd,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,MAAM,EAAE,CAAC;KAChB,GAAG,OAAO,CAAC,IAAI,CAAC;IAkDX,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;YAkB1C,aAAa;IAiB3B,SAAS,CAAC,QAAQ,EAAE,YAAY,GAAG,MAAM,IAAI;YAgB/B,EAAE;IA6BhB;;;;;OAKG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAW7B"}
@@ -0,0 +1,193 @@
1
+ const CHANNEL = "tag_based_cache_invalidate";
2
+ const MIN_GC_TIMEOUT = 5 * 1000;
3
+ const MAX_GC_TIMEOUT = 5 * 60 * 1000;
4
+ const GC_TIMEOUT_STEP = 0.1;
5
+ /** Quote a Postgres identifier so it can be safely interpolated into SQL. */
6
+ function quoteIdentifier(name) {
7
+ return `"${name.replace(/"/g, '""')}"`;
8
+ }
9
+ export class PostgresCache {
10
+ pool;
11
+ options;
12
+ subscriptions = new Set();
13
+ listener;
14
+ gcTimeout = MIN_GC_TIMEOUT;
15
+ ready;
16
+ gcTimer;
17
+ stopped = false;
18
+ /** Fully-qualified (schema-prefixed when applicable) table identifiers. */
19
+ entriesTable;
20
+ tagsTable;
21
+ /** Invalidation channel, namespaced by schema so schemas stay isolated. */
22
+ channel;
23
+ constructor(pool, options = {}) {
24
+ this.pool = pool;
25
+ this.options = options;
26
+ const { schema } = options;
27
+ const prefix = schema ? `${quoteIdentifier(schema)}.` : "";
28
+ this.entriesTable = `${prefix}cache_entries`;
29
+ this.tagsTable = `${prefix}cache_tags`;
30
+ this.channel = schema ? `${CHANNEL}:${schema}` : CHANNEL;
31
+ this.ready = this.setup();
32
+ }
33
+ async setup() {
34
+ if (this.options.createSchema !== false) {
35
+ if (this.options.schema) {
36
+ await this.pool.query(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(this.options.schema)}`);
37
+ }
38
+ await this.pool.query(`
39
+ CREATE TABLE IF NOT EXISTS ${this.entriesTable} (
40
+ key TEXT PRIMARY KEY,
41
+ data JSONB,
42
+ attachment BYTEA,
43
+ added_at BIGINT NOT NULL,
44
+ expires_at BIGINT NOT NULL
45
+ );
46
+ CREATE TABLE IF NOT EXISTS ${this.tagsTable} (
47
+ key TEXT NOT NULL REFERENCES ${this.entriesTable}(key) ON DELETE CASCADE,
48
+ tag TEXT NOT NULL,
49
+ PRIMARY KEY (key, tag)
50
+ );
51
+ CREATE INDEX IF NOT EXISTS cache_tags_tag_idx ON ${this.tagsTable} (tag);
52
+ CREATE INDEX IF NOT EXISTS cache_entries_expires_at_idx
53
+ ON ${this.entriesTable} (expires_at);
54
+ `);
55
+ }
56
+ this.gcTimer = setTimeout(() => this.gc(), this.gcTimeout);
57
+ }
58
+ async get(key) {
59
+ await this.ready;
60
+ const { rows } = await this.pool.query(`
61
+ SELECT data, attachment
62
+ FROM ${this.entriesTable}
63
+ WHERE key = $1 AND expires_at >= $2
64
+ `, [key, Date.now()]);
65
+ const row = rows[0];
66
+ if (!row)
67
+ return null;
68
+ return {
69
+ data: row.data ?? null,
70
+ attachment: row.attachment ? new Uint8Array(row.attachment) : null,
71
+ };
72
+ }
73
+ async set({ key, data, attachment, ttl, tags, }) {
74
+ await this.ready;
75
+ const client = await this.pool.connect();
76
+ try {
77
+ await client.query("BEGIN");
78
+ await client.query(`
79
+ INSERT INTO ${this.entriesTable} (key, data, attachment, added_at, expires_at)
80
+ VALUES ($1, $2, $3, $4, $5)
81
+ ON CONFLICT (key) DO UPDATE SET
82
+ data = EXCLUDED.data,
83
+ attachment = EXCLUDED.attachment,
84
+ added_at = EXCLUDED.added_at,
85
+ expires_at = EXCLUDED.expires_at
86
+ `, [
87
+ key,
88
+ data ? JSON.stringify(data) : null,
89
+ attachment ? Buffer.from(attachment) : null,
90
+ Date.now(),
91
+ Date.now() + ttl * 1000,
92
+ ]);
93
+ // Replace the full tag set so tags removed since the last `set` are
94
+ // dropped, matching the `INSERT OR REPLACE` semantics of the SQLite
95
+ // implementations.
96
+ await client.query(`DELETE FROM ${this.tagsTable} WHERE key = $1`, [key]);
97
+ for (const tag of tags) {
98
+ await client.query(`INSERT INTO ${this.tagsTable} (key, tag) VALUES ($1, $2)
99
+ ON CONFLICT DO NOTHING`, [key, tag]);
100
+ }
101
+ await client.query("COMMIT");
102
+ }
103
+ catch (e) {
104
+ try {
105
+ await client.query("ROLLBACK");
106
+ }
107
+ catch { }
108
+ throw e;
109
+ }
110
+ finally {
111
+ client.release();
112
+ }
113
+ }
114
+ async delete(tags, meta) {
115
+ await this.ready;
116
+ // `cache_tags` rows are removed automatically via `ON DELETE CASCADE`.
117
+ await this.pool.query(`
118
+ DELETE FROM ${this.entriesTable}
119
+ WHERE key IN (SELECT key FROM ${this.tagsTable} WHERE tag = ANY($1))
120
+ `, [tags]);
121
+ await this.pool.query(`SELECT pg_notify($1, $2)`, [
122
+ this.channel,
123
+ JSON.stringify({ tags, meta }),
124
+ ]);
125
+ }
126
+ async setupListener() {
127
+ const listener = await this.pool.connect();
128
+ this.listener = listener;
129
+ listener.on("notification", (msg) => {
130
+ if (msg.channel !== this.channel || !msg.payload)
131
+ return;
132
+ const { tags, meta } = JSON.parse(msg.payload);
133
+ for (const callback of this.subscriptions) {
134
+ callback(tags, meta);
135
+ }
136
+ });
137
+ // LISTEN does not accept a bound parameter; the channel name is quoted so
138
+ // an arbitrary (schema-derived) identifier is interpolated safely.
139
+ await listener.query(`LISTEN ${quoteIdentifier(this.channel)}`);
140
+ }
141
+ subscribe(callback) {
142
+ if (this.subscriptions.size === 0) {
143
+ this.setupListener();
144
+ }
145
+ this.subscriptions.add(callback);
146
+ return () => {
147
+ this.subscriptions.delete(callback);
148
+ if (this.subscriptions.size === 0) {
149
+ this.listener?.removeAllListeners("notification");
150
+ this.listener?.release();
151
+ this.listener = undefined;
152
+ }
153
+ };
154
+ }
155
+ async gc() {
156
+ try {
157
+ const result = await this.pool.query(`DELETE FROM ${this.entriesTable} WHERE expires_at < $1`, [Date.now()]);
158
+ const changes = result.rowCount ?? 0;
159
+ if (changes > 100) {
160
+ this.gcTimeout = Math.max(Math.floor(this.gcTimeout * (1 - GC_TIMEOUT_STEP)), MIN_GC_TIMEOUT);
161
+ }
162
+ else if (changes < 10) {
163
+ this.gcTimeout = Math.min(Math.floor(this.gcTimeout * (1 + GC_TIMEOUT_STEP)), MAX_GC_TIMEOUT);
164
+ }
165
+ }
166
+ catch {
167
+ // Ignore transient errors (e.g. the pool being drained on shutdown);
168
+ // the next tick will retry.
169
+ }
170
+ finally {
171
+ if (!this.stopped) {
172
+ this.gcTimer = setTimeout(() => this.gc(), this.gcTimeout);
173
+ }
174
+ }
175
+ }
176
+ /**
177
+ * Stops the background garbage collector and releases the dedicated
178
+ * invalidation listener connection back to the pool. Call this before
179
+ * shutting down so no timers or connections are left dangling. The
180
+ * underlying pool is left untouched — the caller owns its lifecycle.
181
+ */
182
+ async close() {
183
+ this.stopped = true;
184
+ if (this.gcTimer) {
185
+ clearTimeout(this.gcTimer);
186
+ this.gcTimer = undefined;
187
+ }
188
+ this.listener?.removeAllListeners("notification");
189
+ this.listener?.release();
190
+ this.listener = undefined;
191
+ this.subscriptions.clear();
192
+ }
193
+ }
@@ -0,0 +1,18 @@
1
+ import type { RedisClientType } from "@redis/client";
2
+ import type { Cache, CacheEntry, Json, Subscription } from "./index";
3
+ export declare class RedisCache implements Cache {
4
+ private redis;
5
+ subscriptions: Set<Subscription>;
6
+ subscriber?: RedisClientType;
7
+ constructor(redis: RedisClientType);
8
+ get(key: string): Promise<CacheEntry | null>;
9
+ set({ key, data, attachment, ttl, tags, }: CacheEntry & {
10
+ key: string;
11
+ ttl: number;
12
+ tags: string[];
13
+ }): Promise<void>;
14
+ delete(tags: string[], meta?: Json): Promise<void>;
15
+ setupSubscription(): Promise<void>;
16
+ subscribe(callback: Subscription): () => void;
17
+ }
18
+ //# sourceMappingURL=redis-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-cache.d.ts","sourceRoot":"","sources":["../src/redis-cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AASrE,qBAAa,UAAW,YAAW,KAAK;IAI1B,OAAO,CAAC,KAAK;IAHzB,aAAa,oBAA2B;IACxC,UAAU,CAAC,EAAE,eAAe,CAAC;gBAET,KAAK,EAAE,eAAe;IAEpC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAkB5C,GAAG,CAAC,EACR,GAAG,EACH,IAAI,EACJ,UAAU,EACV,GAAG,EACH,IAAI,GACL,EAAE,UAAU,GAAG;QACd,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,MAAM,EAAE,CAAC;KAChB,GAAG,OAAO,CAAC,IAAI,CAAC;IAsBX,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAuBlD,iBAAiB;IAWvB,SAAS,CAAC,QAAQ,EAAE,YAAY,GAAG,MAAM,IAAI;CAc9C"}
@@ -0,0 +1,76 @@
1
+ const BLOB_STRING_TYPE = 36;
2
+ export class RedisCache {
3
+ redis;
4
+ subscriptions = new Set();
5
+ subscriber;
6
+ constructor(redis) {
7
+ this.redis = redis;
8
+ }
9
+ async get(key) {
10
+ const data = await this.redis.get(`data:${key}`);
11
+ const attachment = (await this.redis
12
+ .withCommandOptions({
13
+ typeMapping: {
14
+ [BLOB_STRING_TYPE]: Buffer,
15
+ },
16
+ })
17
+ .get(`attachment:${key}`));
18
+ return data || attachment
19
+ ? {
20
+ data: data ? JSON.parse(data) : null,
21
+ attachment: attachment ? new Uint8Array(attachment) : null,
22
+ }
23
+ : null;
24
+ }
25
+ async set({ key, data, attachment, ttl, tags, }) {
26
+ let promises = [];
27
+ if (data) {
28
+ promises.push(this.redis.set(`data:${key}`, JSON.stringify(data), { EX: ttl }));
29
+ }
30
+ if (attachment) {
31
+ promises.push(this.redis.set(`attachment:${key}`, Buffer.from(attachment), {
32
+ EX: ttl,
33
+ }));
34
+ }
35
+ for (const tag of tags) {
36
+ promises.push(this.redis.sAdd(`tag:${tag}`, key));
37
+ }
38
+ await Promise.all(promises);
39
+ }
40
+ async delete(tags, meta) {
41
+ const keys = await Promise.all(tags.map((tag) => this.redis.sMembers(`tag:${tag}`)));
42
+ const keySet = new Set(keys.flat());
43
+ const keysToDelete = tags.map((tag) => `tag:${tag}`);
44
+ for (const key of keySet) {
45
+ keysToDelete.push(`data:${key}`, `attachment:${key}`);
46
+ }
47
+ // Delete all the keys if there are any
48
+ if (keysToDelete.length > 0) {
49
+ await this.redis.del(keysToDelete);
50
+ }
51
+ await this.redis.publish("invalidate", JSON.stringify({ tags, meta }));
52
+ }
53
+ async setupSubscription() {
54
+ this.subscriber = this.redis.duplicate();
55
+ await this.subscriber.connect();
56
+ await this.subscriber.subscribe("invalidate", (message) => {
57
+ const { tags, meta } = JSON.parse(message);
58
+ for (const callback of this.subscriptions) {
59
+ callback(tags, meta);
60
+ }
61
+ });
62
+ }
63
+ subscribe(callback) {
64
+ if (this.subscriptions.size === 0) {
65
+ this.setupSubscription();
66
+ }
67
+ this.subscriptions.add(callback);
68
+ return () => {
69
+ this.subscriptions.delete(callback);
70
+ if (this.subscriptions.size === 0) {
71
+ this.subscriber?.destroy();
72
+ this.subscriber = undefined;
73
+ }
74
+ };
75
+ }
76
+ }
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@toapi/cache",
3
+ "version": "0.3.0",
4
+ "author": {
5
+ "name": "Michel Smola",
6
+ "email": "michel.smola@farbenmeer.de"
7
+ },
8
+ "type": "module",
9
+ "module": "dist/index.js",
10
+ "main": "dist/index.js",
11
+ "private": false,
12
+ "license": "MIT",
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "default": "./dist/index.js",
19
+ "types": "./dist/index.d.ts"
20
+ },
21
+ "./filesystem-cache": {
22
+ "default": "./dist/filesystem-cache.js",
23
+ "types": "./dist/filesystem-cache.d.ts"
24
+ },
25
+ "./redis-cache": {
26
+ "default": "./dist/redis-cache.js",
27
+ "types": "./dist/redis-cache.d.ts"
28
+ },
29
+ "./postgres-cache": {
30
+ "default": "./dist/postgres-cache.js",
31
+ "types": "./dist/postgres-cache.d.ts"
32
+ },
33
+ "./in-memory-cache": {
34
+ "default": "./dist/in-memory-cache.js",
35
+ "types": "./dist/in-memory-cache.d.ts"
36
+ }
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^25.0.3",
40
+ "@types/pg": "^8.0.0",
41
+ "vitest": "^4.0.16",
42
+ "typescript": "^6.0.0",
43
+ "@redis/client": "^6.0.0",
44
+ "pg": "^8.0.0"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/farbenmeer/tapi.git"
49
+ },
50
+ "peerDependencies": {
51
+ "@redis/client": "^5.10.0 || ^6.0.0",
52
+ "pg": "^8.0.0"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "@redis/client": {
56
+ "optional": true
57
+ },
58
+ "pg": {
59
+ "optional": true
60
+ }
61
+ },
62
+ "scripts": {
63
+ "build": "tsc --noEmit false",
64
+ "release": "pnpm build && pnpm publish --no-git-checks",
65
+ "test": "vitest"
66
+ }
67
+ }