@happyvertical/smrt-core 0.38.2 → 0.38.4
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 -2
- package/dist/__tests__/fixtures/advisor-test-classes.d.ts +9 -0
- package/dist/__tests__/fixtures/advisor-test-classes.d.ts.map +1 -1
- package/dist/change-feed.d.ts +19 -10
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +43 -13
- package/dist/change-feed.js.map +1 -1
- package/dist/change-signals.d.ts +83 -0
- package/dist/change-signals.d.ts.map +1 -0
- package/dist/change-signals.js +272 -0
- package/dist/change-signals.js.map +1 -0
- package/dist/collection-cache.d.ts +21 -0
- package/dist/collection-cache.d.ts.map +1 -1
- package/dist/collection-cache.js +0 -0
- package/dist/collection-cache.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/decorators/index.d.ts +8 -0
- package/dist/decorators/index.d.ts.map +1 -1
- package/dist/decorators/index.js.map +1 -1
- package/dist/errors.js +2 -1
- package/dist/errors.js.map +1 -1
- package/dist/generators/changes-route.d.ts +15 -27
- package/dist/generators/changes-route.d.ts.map +1 -1
- package/dist/generators/changes-route.js +17 -7
- package/dist/generators/changes-route.js.map +1 -1
- package/dist/generators/cli.d.ts +3 -0
- package/dist/generators/cli.d.ts.map +1 -1
- package/dist/generators/cli.js +7 -4
- package/dist/generators/cli.js.map +1 -1
- package/dist/generators/conditional-get.d.ts +17 -6
- package/dist/generators/conditional-get.d.ts.map +1 -1
- package/dist/generators/conditional-get.js +19 -13
- package/dist/generators/conditional-get.js.map +1 -1
- package/dist/generators/events-route.d.ts +72 -0
- package/dist/generators/events-route.d.ts.map +1 -0
- package/dist/generators/events-route.js +234 -0
- package/dist/generators/events-route.js.map +1 -0
- package/dist/generators/index.d.ts +1 -0
- package/dist/generators/index.d.ts.map +1 -1
- package/dist/generators/index.js +2 -1
- package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
- package/dist/generators/mcp-runtime-template.js +11 -5
- package/dist/generators/mcp-runtime-template.js.map +1 -1
- package/dist/generators/mcp.d.ts +3 -0
- package/dist/generators/mcp.d.ts.map +1 -1
- package/dist/generators/mcp.js +18 -9
- package/dist/generators/mcp.js.map +1 -1
- package/dist/generators/rest.d.ts +7 -1
- package/dist/generators/rest.d.ts.map +1 -1
- package/dist/generators/rest.js +77 -13
- package/dist/generators/rest.js.map +1 -1
- package/dist/generators.js +2 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/manifest/generator.js.map +1 -1
- package/dist/manifest/manifest-loader.js.map +1 -1
- package/dist/manifest/static-manifest.d.ts.map +1 -1
- package/dist/manifest/static-manifest.js +7 -3
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/store.js.map +1 -1
- package/dist/manifest/test-manifest-stub.d.ts.map +1 -1
- package/dist/manifest/test-manifest-stub.js +1171 -90
- package/dist/manifest/test-manifest-stub.js.map +1 -1
- package/dist/manifest.json +9 -3
- package/dist/mcp-advisor/tools/preview-api-endpoints.d.ts +1 -0
- package/dist/mcp-advisor/tools/preview-api-endpoints.d.ts.map +1 -1
- package/dist/mcp-advisor/types.d.ts +4 -0
- package/dist/mcp-advisor/types.d.ts.map +1 -1
- package/dist/migrations/tracker.js +5 -4
- package/dist/migrations/tracker.js.map +1 -1
- package/dist/migrations/types.d.ts +1 -1
- package/dist/migrations/types.d.ts.map +1 -1
- package/dist/object.d.ts +17 -8
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +42 -26
- package/dist/object.js.map +1 -1
- package/dist/registry/class-registration.d.ts.map +1 -1
- package/dist/registry/class-registration.js +12 -0
- package/dist/registry/class-registration.js.map +1 -1
- package/dist/registry/shared-state.d.ts +2 -1
- package/dist/registry/shared-state.d.ts.map +1 -1
- package/dist/registry/shared-state.js.map +1 -1
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -1
- package/dist/registry.js.map +1 -1
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js.map +1 -1
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/scanner/types.d.ts +7 -0
- package/dist/scanner/types.d.ts.map +1 -1
- package/dist/schema/ddl/base-strategy.js +1 -1
- package/dist/schema/ddl/base-strategy.js.map +1 -1
- package/dist/scripts/migrate-datetime-to-timestamp.ts +1 -1
- package/dist/signals/sanitizer.d.ts.map +1 -1
- package/dist/signals/sanitizer.js.map +1 -1
- package/dist/smrt-knowledge.json +8 -8
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +2 -1
- package/dist/testing/notifications-stub.d.ts +24 -0
- package/dist/testing/notifications-stub.d.ts.map +1 -0
- package/dist/testing/notifications-stub.js +62 -0
- package/dist/testing/notifications-stub.js.map +1 -0
- package/dist/testing.js +2 -1
- package/dist/tools/tool-generator.js +1 -1
- package/dist/tools/tool-generator.js.map +1 -1
- package/dist/vite-plugin/changes-route.d.ts +14 -0
- package/dist/vite-plugin/changes-route.d.ts.map +1 -1
- package/dist/vite-plugin/changes-route.js +2 -2
- package/dist/vite-plugin/changes-route.js.map +1 -1
- package/dist/vite-plugin/events-route.d.ts +9 -0
- package/dist/vite-plugin/events-route.d.ts.map +1 -0
- package/dist/vite-plugin/events-route.js +155 -0
- package/dist/vite-plugin/events-route.js.map +1 -0
- package/dist/vite-plugin/index.d.ts +8 -0
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +8 -2
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts +8 -0
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +239 -38
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/dist/vite-plugin/web-collections.js +1 -1
- package/dist/vite-plugin/web-collections.js.map +1 -1
- package/package.json +12 -12
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { PROCESS_ID, getNotifications, resolveDbCacheKey } from "./collection-cache.js";
|
|
2
|
+
import { createLogger } from "@happyvertical/logger";
|
|
3
|
+
//#region src/change-signals.ts
|
|
4
|
+
/**
|
|
5
|
+
* Change-signal bus — the live push spine for the generated `_events` SSE
|
|
6
|
+
* route (issue #1763, parent PRD #1755).
|
|
7
|
+
*
|
|
8
|
+
* The change feed (#1758) is a durable, cursor-addressable log; this bus is
|
|
9
|
+
* its ephemeral companion. Every framework `save()`/`delete()` that appends a
|
|
10
|
+
* feed row also publishes a coarse {@link ChangeSignal} here, which fans out
|
|
11
|
+
* synchronously to in-process subscribers (the SSE controllers of connected
|
|
12
|
+
* `_events` clients) and, when the database adapter exposes a notification
|
|
13
|
+
* capability, to peer replicas over the same channel. Absence of that
|
|
14
|
+
* capability degrades gracefully — no cross-replica push, never an error, and
|
|
15
|
+
* it never blocks the write.
|
|
16
|
+
*
|
|
17
|
+
* ## What a signal carries (and deliberately does not)
|
|
18
|
+
*
|
|
19
|
+
* A signal is `{ table, operation, rowId, tenantId, seq }` — never any row
|
|
20
|
+
* payload. Authorization stays entirely on the read path: a subscriber learns
|
|
21
|
+
* *that* something changed and its cursor (`seq`), then re-reads through the
|
|
22
|
+
* authorized collection routes to catch up. This is why the bus can broadcast
|
|
23
|
+
* a tenant's writes to peer replicas without leaking data across a trust
|
|
24
|
+
* boundary. The `seq` is the same monotonic cursor dimension the change feed
|
|
25
|
+
* allocates, so a reconnecting client can resume via `getChangesSince`.
|
|
26
|
+
*
|
|
27
|
+
* ## Delivery model
|
|
28
|
+
*
|
|
29
|
+
* - Local delivery is a synchronous fan-out ({@link deliverLocally}) into each
|
|
30
|
+
* listener callback, wrapped in a try/catch **per listener** so one throwing
|
|
31
|
+
* listener (e.g. a closed SSE controller's `enqueue`) never blocks the
|
|
32
|
+
* others. There is no per-subscriber queue — backpressure is delegated to
|
|
33
|
+
* each platform `ReadableStream`.
|
|
34
|
+
* - Cross-replica delivery reuses the same {@link deliverLocally} helper on
|
|
35
|
+
* receipt, so locally-published and peer-received signals travel one code
|
|
36
|
+
* path. Notifications this process published are skipped by `source` id
|
|
37
|
+
* (echo-avoidance), exactly as the collection cache does.
|
|
38
|
+
*
|
|
39
|
+
* This mirrors `collection-cache.ts`'s notify/listen structure; study that
|
|
40
|
+
* module for the shared cross-process conventions (`resolveDbCacheKey`,
|
|
41
|
+
* `getNotifications`, the lazy listener and finally-retract).
|
|
42
|
+
*
|
|
43
|
+
* ## Known gaps
|
|
44
|
+
*
|
|
45
|
+
* - **No max-connections cap**: the bus imposes no ceiling on concurrent
|
|
46
|
+
* subscribers (SSE connections). A deployment expecting many long-lived
|
|
47
|
+
* `_events` connections should bound them at the edge (reverse proxy /
|
|
48
|
+
* load balancer). A per-process cap is a deliberate follow-up.
|
|
49
|
+
* - **Raw-SQL writes are invisible**: signals originate from the framework
|
|
50
|
+
* write path (same accepted gap as the #1758 feed and #1498 cache). A
|
|
51
|
+
* `bumpChangeFeed` escape-hatch write appends a feed row but does not
|
|
52
|
+
* publish a signal.
|
|
53
|
+
* - **Caller-managed transactions are best-effort**: the append + signal fire
|
|
54
|
+
* from `afterSave`/`afterDelete`, i.e. *before* a caller-wrapped transaction
|
|
55
|
+
* commits (the autocommit default path — save()/delete() as independent
|
|
56
|
+
* statements — is exact). Inside such a transaction, a signal may fire for a
|
|
57
|
+
* change that a later rollback undoes, and the rolled-back seq is then reused
|
|
58
|
+
* by the next append — so a client trusting a pre-commit `Last-Event-ID`
|
|
59
|
+
* could skip the reuse via catch-up. This inherits the change feed's
|
|
60
|
+
* documented transaction caveat (see `change-feed.ts`); no generic
|
|
61
|
+
* post-commit hook exists to close it. Clients reconcile via full catch-up /
|
|
62
|
+
* resync, so convergence still holds — live delivery is just best-effort for
|
|
63
|
+
* transaction-wrapped writes.
|
|
64
|
+
*
|
|
65
|
+
* @see https://github.com/happyvertical/smrt/issues/1763
|
|
66
|
+
* @packageDocumentation
|
|
67
|
+
*/
|
|
68
|
+
var logger = createLogger({ level: "info" });
|
|
69
|
+
/**
|
|
70
|
+
* Notification channel for cross-replica change-signal broadcasts. A distinct
|
|
71
|
+
* channel from the collection cache's — the two buses carry different payloads
|
|
72
|
+
* and evolve independently.
|
|
73
|
+
*/
|
|
74
|
+
var CHANGE_SIGNAL_CHANNEL = "smrt_change_signals";
|
|
75
|
+
/**
|
|
76
|
+
* dbKey → local listeners. Keyed via `resolveDbCacheKey` so `:memory:` and
|
|
77
|
+
* URL-less handles are scoped per instance (never cross-deliver between two
|
|
78
|
+
* independent in-memory databases).
|
|
79
|
+
*/
|
|
80
|
+
var localListeners = /* @__PURE__ */ new Map();
|
|
81
|
+
/** dbKey → background cross-replica listener handle. */
|
|
82
|
+
var crossReplicaListeners = /* @__PURE__ */ new Map();
|
|
83
|
+
/** dbKeys we already warned about for a missing notification capability. */
|
|
84
|
+
var warnedNoNotifications = /* @__PURE__ */ new Set();
|
|
85
|
+
/**
|
|
86
|
+
* Subscribe to change signals for a database.
|
|
87
|
+
*
|
|
88
|
+
* Registers `listener` for the database's signal scope and, on the first
|
|
89
|
+
* subscriber for that scope, lazily starts the cross-replica listener (a no-op
|
|
90
|
+
* when the adapter has no notification capability). Returns an unsubscribe
|
|
91
|
+
* function that removes the listener and, when the scope's last subscriber
|
|
92
|
+
* leaves, retracts the cross-replica listener so its refcount reaches 0.
|
|
93
|
+
*
|
|
94
|
+
* Delivery is synchronous: `listener` is invoked from the write path (or the
|
|
95
|
+
* cross-replica loop) inside a per-listener try/catch, so it must not assume
|
|
96
|
+
* an active request or tenant context — capture what it needs at subscribe
|
|
97
|
+
* time.
|
|
98
|
+
*/
|
|
99
|
+
function subscribeToChangeSignals(db, listener) {
|
|
100
|
+
const dbKey = resolveDbCacheKey(db);
|
|
101
|
+
let set = localListeners.get(dbKey);
|
|
102
|
+
if (!set) {
|
|
103
|
+
set = /* @__PURE__ */ new Set();
|
|
104
|
+
localListeners.set(dbKey, set);
|
|
105
|
+
}
|
|
106
|
+
set.add(listener);
|
|
107
|
+
ensureChangeSignalListener(db);
|
|
108
|
+
let unsubscribed = false;
|
|
109
|
+
return () => {
|
|
110
|
+
if (unsubscribed) return;
|
|
111
|
+
unsubscribed = true;
|
|
112
|
+
const current = localListeners.get(dbKey);
|
|
113
|
+
if (!current) return;
|
|
114
|
+
current.delete(listener);
|
|
115
|
+
if (current.size === 0) {
|
|
116
|
+
localListeners.delete(dbKey);
|
|
117
|
+
retractChangeSignalListener(dbKey);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Publish a change signal: synchronous local fan-out, then fire-and-forget
|
|
123
|
+
* cross-replica broadcast. Never throws to the caller — a signal problem must
|
|
124
|
+
* never fail the user's write.
|
|
125
|
+
*/
|
|
126
|
+
function publishChangeSignal(db, signal) {
|
|
127
|
+
deliverLocally(resolveDbCacheKey(db), signal);
|
|
128
|
+
broadcastChangeSignal(db, signal);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Synchronous local fan-out to every subscriber for a scope. Each listener is
|
|
132
|
+
* wrapped in its own try/catch so one throwing listener (a closed SSE
|
|
133
|
+
* controller) never blocks the rest. Locally-published and peer-received
|
|
134
|
+
* signals both flow through here — the single delivery path.
|
|
135
|
+
*/
|
|
136
|
+
function deliverLocally(dbKey, signal) {
|
|
137
|
+
const set = localListeners.get(dbKey);
|
|
138
|
+
if (!set || set.size === 0) return;
|
|
139
|
+
for (const listener of [...set]) try {
|
|
140
|
+
listener(signal);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
logger.warn("Change signal: a subscriber threw during local delivery", { error: error instanceof Error ? error.message : String(error) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Broadcast a signal to peer replicas over the adapter's notification
|
|
147
|
+
* capability. Resolves normally (never throws) whether or not a capability
|
|
148
|
+
* exists — a missing capability warns once per scope and is not an error.
|
|
149
|
+
*
|
|
150
|
+
* Fire-and-forget from the write path: a broadcast failure must never fail the
|
|
151
|
+
* write that triggered it.
|
|
152
|
+
*/
|
|
153
|
+
async function broadcastChangeSignal(db, signal) {
|
|
154
|
+
const notifications = getNotifications(db);
|
|
155
|
+
const dbKey = resolveDbCacheKey(db);
|
|
156
|
+
if (!notifications) {
|
|
157
|
+
warnOnceNoNotifications(dbKey);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await notifications.notify(CHANGE_SIGNAL_CHANNEL, {
|
|
162
|
+
...signal,
|
|
163
|
+
source: PROCESS_ID
|
|
164
|
+
});
|
|
165
|
+
} catch (error) {
|
|
166
|
+
logger.warn(`Change signal: failed to broadcast a signal for '${signal.table}'`, { error: error instanceof Error ? error.message : String(error) });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Ensure a background listener consumes cross-replica broadcasts for this
|
|
171
|
+
* database and delivers them locally. Started lazily by the first subscriber
|
|
172
|
+
* for a scope; a no-op (with a one-time warning) when the adapter exposes no
|
|
173
|
+
* notification capability. Notifications this process published are skipped by
|
|
174
|
+
* `source` id (echo-avoidance): the local fan-out already delivered them.
|
|
175
|
+
*/
|
|
176
|
+
function ensureChangeSignalListener(db) {
|
|
177
|
+
const dbKey = resolveDbCacheKey(db);
|
|
178
|
+
if (crossReplicaListeners.has(dbKey)) return;
|
|
179
|
+
const notifications = getNotifications(db);
|
|
180
|
+
if (!notifications) {
|
|
181
|
+
warnOnceNoNotifications(dbKey);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const handle = {
|
|
185
|
+
iterator: null,
|
|
186
|
+
stopped: false
|
|
187
|
+
};
|
|
188
|
+
crossReplicaListeners.set(dbKey, handle);
|
|
189
|
+
(async () => {
|
|
190
|
+
try {
|
|
191
|
+
const iterator = notifications.listen(CHANGE_SIGNAL_CHANNEL)[Symbol.asyncIterator]();
|
|
192
|
+
handle.iterator = iterator;
|
|
193
|
+
while (!handle.stopped) {
|
|
194
|
+
const { value, done } = await iterator.next();
|
|
195
|
+
if (done || handle.stopped) break;
|
|
196
|
+
const notification = value;
|
|
197
|
+
const payload = typeof notification?.payload === "string" ? safeParse(notification.payload) : notification?.payload;
|
|
198
|
+
if (!payload || typeof payload !== "object") continue;
|
|
199
|
+
const record = payload;
|
|
200
|
+
if (record.source === PROCESS_ID) continue;
|
|
201
|
+
const signal = toSignal(record);
|
|
202
|
+
if (!signal) continue;
|
|
203
|
+
deliverLocally(dbKey, signal);
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (!handle.stopped) logger.warn("Change signal: cross-replica listener terminated unexpectedly; peer signals are no longer delivered for this database (local delivery and cursor catch-up still work)", { error: error instanceof Error ? error.message : String(error) });
|
|
207
|
+
} finally {
|
|
208
|
+
if (crossReplicaListeners.get(dbKey) === handle) crossReplicaListeners.delete(dbKey);
|
|
209
|
+
}
|
|
210
|
+
})();
|
|
211
|
+
}
|
|
212
|
+
/** Retract the cross-replica listener for a scope (last subscriber left). */
|
|
213
|
+
function retractChangeSignalListener(dbKey) {
|
|
214
|
+
const handle = crossReplicaListeners.get(dbKey);
|
|
215
|
+
if (!handle) return;
|
|
216
|
+
handle.stopped = true;
|
|
217
|
+
handle.iterator?.return?.(void 0);
|
|
218
|
+
crossReplicaListeners.delete(dbKey);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Stop all cross-replica change-signal listeners. Used by tests and during
|
|
222
|
+
* shutdown; safe to call when none are active.
|
|
223
|
+
*/
|
|
224
|
+
function stopChangeSignalListeners() {
|
|
225
|
+
for (const handle of crossReplicaListeners.values()) {
|
|
226
|
+
handle.stopped = true;
|
|
227
|
+
handle.iterator?.return?.(void 0);
|
|
228
|
+
}
|
|
229
|
+
crossReplicaListeners.clear();
|
|
230
|
+
warnedNoNotifications.clear();
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Clear all local subscribers, stop cross-replica listeners, and reset the
|
|
234
|
+
* no-capability warning dedup. Call in test setup for isolation between files.
|
|
235
|
+
*/
|
|
236
|
+
function resetChangeSignals() {
|
|
237
|
+
localListeners.clear();
|
|
238
|
+
stopChangeSignalListeners();
|
|
239
|
+
}
|
|
240
|
+
function warnOnceNoNotifications(dbKey) {
|
|
241
|
+
if (warnedNoNotifications.has(dbKey)) return;
|
|
242
|
+
warnedNoNotifications.add(dbKey);
|
|
243
|
+
logger.warn("Change signal: the database adapter exposes no notification capability, so change signals are delivered in-process only. Cross-replica live updates require an adapter with notifications (e.g. Postgres LISTEN/NOTIFY); subscribers on other replicas fall back to cursor polling.");
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Coerce a received notification payload into a {@link ChangeSignal}, or
|
|
247
|
+
* `undefined` if it is malformed. Guards the cross-replica path against
|
|
248
|
+
* garbage on the channel.
|
|
249
|
+
*/
|
|
250
|
+
function toSignal(record) {
|
|
251
|
+
const { table, operation, rowId, tenantId, seq } = record;
|
|
252
|
+
if (typeof table !== "string" || !table) return void 0;
|
|
253
|
+
if (operation !== "create" && operation !== "update" && operation !== "delete") return;
|
|
254
|
+
return {
|
|
255
|
+
table,
|
|
256
|
+
operation,
|
|
257
|
+
rowId: typeof rowId === "string" ? rowId : null,
|
|
258
|
+
tenantId: typeof tenantId === "string" ? tenantId : null,
|
|
259
|
+
seq: typeof seq === "number" ? seq : Number(seq ?? 0) || 0
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function safeParse(value) {
|
|
263
|
+
try {
|
|
264
|
+
return JSON.parse(value);
|
|
265
|
+
} catch {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
270
|
+
export { CHANGE_SIGNAL_CHANNEL, broadcastChangeSignal, publishChangeSignal, resetChangeSignals, stopChangeSignalListeners, subscribeToChangeSignals };
|
|
271
|
+
|
|
272
|
+
//# sourceMappingURL=change-signals.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"change-signals.js","names":[],"sources":["../src/change-signals.ts"],"sourcesContent":["/**\n * Change-signal bus — the live push spine for the generated `_events` SSE\n * route (issue #1763, parent PRD #1755).\n *\n * The change feed (#1758) is a durable, cursor-addressable log; this bus is\n * its ephemeral companion. Every framework `save()`/`delete()` that appends a\n * feed row also publishes a coarse {@link ChangeSignal} here, which fans out\n * synchronously to in-process subscribers (the SSE controllers of connected\n * `_events` clients) and, when the database adapter exposes a notification\n * capability, to peer replicas over the same channel. Absence of that\n * capability degrades gracefully — no cross-replica push, never an error, and\n * it never blocks the write.\n *\n * ## What a signal carries (and deliberately does not)\n *\n * A signal is `{ table, operation, rowId, tenantId, seq }` — never any row\n * payload. Authorization stays entirely on the read path: a subscriber learns\n * *that* something changed and its cursor (`seq`), then re-reads through the\n * authorized collection routes to catch up. This is why the bus can broadcast\n * a tenant's writes to peer replicas without leaking data across a trust\n * boundary. The `seq` is the same monotonic cursor dimension the change feed\n * allocates, so a reconnecting client can resume via `getChangesSince`.\n *\n * ## Delivery model\n *\n * - Local delivery is a synchronous fan-out ({@link deliverLocally}) into each\n * listener callback, wrapped in a try/catch **per listener** so one throwing\n * listener (e.g. a closed SSE controller's `enqueue`) never blocks the\n * others. There is no per-subscriber queue — backpressure is delegated to\n * each platform `ReadableStream`.\n * - Cross-replica delivery reuses the same {@link deliverLocally} helper on\n * receipt, so locally-published and peer-received signals travel one code\n * path. Notifications this process published are skipped by `source` id\n * (echo-avoidance), exactly as the collection cache does.\n *\n * This mirrors `collection-cache.ts`'s notify/listen structure; study that\n * module for the shared cross-process conventions (`resolveDbCacheKey`,\n * `getNotifications`, the lazy listener and finally-retract).\n *\n * ## Known gaps\n *\n * - **No max-connections cap**: the bus imposes no ceiling on concurrent\n * subscribers (SSE connections). A deployment expecting many long-lived\n * `_events` connections should bound them at the edge (reverse proxy /\n * load balancer). A per-process cap is a deliberate follow-up.\n * - **Raw-SQL writes are invisible**: signals originate from the framework\n * write path (same accepted gap as the #1758 feed and #1498 cache). A\n * `bumpChangeFeed` escape-hatch write appends a feed row but does not\n * publish a signal.\n * - **Caller-managed transactions are best-effort**: the append + signal fire\n * from `afterSave`/`afterDelete`, i.e. *before* a caller-wrapped transaction\n * commits (the autocommit default path — save()/delete() as independent\n * statements — is exact). Inside such a transaction, a signal may fire for a\n * change that a later rollback undoes, and the rolled-back seq is then reused\n * by the next append — so a client trusting a pre-commit `Last-Event-ID`\n * could skip the reuse via catch-up. This inherits the change feed's\n * documented transaction caveat (see `change-feed.ts`); no generic\n * post-commit hook exists to close it. Clients reconcile via full catch-up /\n * resync, so convergence still holds — live delivery is just best-effort for\n * transaction-wrapped writes.\n *\n * @see https://github.com/happyvertical/smrt/issues/1763\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport {\n getNotifications,\n PROCESS_ID,\n resolveDbCacheKey,\n} from './collection-cache.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Operation carried by a change signal. Kept as a local string union — this\n * module deliberately does NOT import `./change-feed.js` (which imports this\n * one via the writer) so there is no import cycle. It must stay in sync with\n * `ChangeOperation` in `change-feed.ts`.\n */\nexport type ChangeSignalOperation = 'create' | 'update' | 'delete';\n\n/**\n * A coarse change notification. Carries no row payload by design.\n */\nexport interface ChangeSignal {\n // NEVER add row-payload fields — #1763 AC: authorization stays on the read path\n /** Physical table the change happened in. */\n table: string;\n /** What happened (`'delete'` doubles as a tombstone signal). */\n operation: ChangeSignalOperation;\n /** Primary key of the changed row, or `null` for table-level changes. */\n rowId: string | null;\n /** Tenant the changed row belongs to, or `null` for global rows. */\n tenantId: string | null;\n /**\n * The change feed sequence allocated for this change — the cursor a\n * reconnecting client resumes from via `getChangesSince`.\n */\n seq: number;\n}\n\n/** A subscriber invoked synchronously for every locally-visible signal. */\nexport type ChangeSignalListener = (signal: ChangeSignal) => void;\n\n/**\n * Notification channel for cross-replica change-signal broadcasts. A distinct\n * channel from the collection cache's — the two buses carry different payloads\n * and evolve independently.\n */\nexport const CHANGE_SIGNAL_CHANNEL = 'smrt_change_signals';\n\n/**\n * dbKey → local listeners. Keyed via `resolveDbCacheKey` so `:memory:` and\n * URL-less handles are scoped per instance (never cross-deliver between two\n * independent in-memory databases).\n */\nconst localListeners = new Map<string, Set<ChangeSignalListener>>();\n\ninterface ListenerHandle {\n iterator: AsyncIterator<unknown> | null;\n stopped: boolean;\n}\n\n/** dbKey → background cross-replica listener handle. */\nconst crossReplicaListeners = new Map<string, ListenerHandle>();\n\n/** dbKeys we already warned about for a missing notification capability. */\nconst warnedNoNotifications = new Set<string>();\n\n/**\n * Subscribe to change signals for a database.\n *\n * Registers `listener` for the database's signal scope and, on the first\n * subscriber for that scope, lazily starts the cross-replica listener (a no-op\n * when the adapter has no notification capability). Returns an unsubscribe\n * function that removes the listener and, when the scope's last subscriber\n * leaves, retracts the cross-replica listener so its refcount reaches 0.\n *\n * Delivery is synchronous: `listener` is invoked from the write path (or the\n * cross-replica loop) inside a per-listener try/catch, so it must not assume\n * an active request or tenant context — capture what it needs at subscribe\n * time.\n */\nexport function subscribeToChangeSignals(\n db: DatabaseInterface,\n listener: ChangeSignalListener,\n): () => void {\n const dbKey = resolveDbCacheKey(db);\n\n let set = localListeners.get(dbKey);\n if (!set) {\n set = new Set();\n localListeners.set(dbKey, set);\n }\n set.add(listener);\n\n // Lazily start the cross-replica listener on first interest for this scope.\n ensureChangeSignalListener(db);\n\n let unsubscribed = false;\n return () => {\n if (unsubscribed) return;\n unsubscribed = true;\n const current = localListeners.get(dbKey);\n if (!current) return;\n current.delete(listener);\n if (current.size === 0) {\n localListeners.delete(dbKey);\n // Last local subscriber gone — retract the cross-replica listener so its\n // refcount reaches 0 (mirrors collection-cache's finally-retract).\n retractChangeSignalListener(dbKey);\n }\n };\n}\n\n/**\n * Publish a change signal: synchronous local fan-out, then fire-and-forget\n * cross-replica broadcast. Never throws to the caller — a signal problem must\n * never fail the user's write.\n */\nexport function publishChangeSignal(\n db: DatabaseInterface,\n signal: ChangeSignal,\n): void {\n const dbKey = resolveDbCacheKey(db);\n deliverLocally(dbKey, signal);\n // Fire-and-forget: broadcast failures are swallowed inside broadcast.\n void broadcastChangeSignal(db, signal);\n}\n\n/**\n * Synchronous local fan-out to every subscriber for a scope. Each listener is\n * wrapped in its own try/catch so one throwing listener (a closed SSE\n * controller) never blocks the rest. Locally-published and peer-received\n * signals both flow through here — the single delivery path.\n */\nfunction deliverLocally(dbKey: string, signal: ChangeSignal): void {\n const set = localListeners.get(dbKey);\n if (!set || set.size === 0) return;\n // Snapshot so a listener that unsubscribes during delivery can't mutate the\n // set mid-iteration.\n for (const listener of [...set]) {\n try {\n listener(signal);\n } catch (error) {\n logger.warn('Change signal: a subscriber threw during local delivery', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n}\n\n/**\n * Broadcast a signal to peer replicas over the adapter's notification\n * capability. Resolves normally (never throws) whether or not a capability\n * exists — a missing capability warns once per scope and is not an error.\n *\n * Fire-and-forget from the write path: a broadcast failure must never fail the\n * write that triggered it.\n */\nexport async function broadcastChangeSignal(\n db: DatabaseInterface,\n signal: ChangeSignal,\n): Promise<void> {\n const notifications = getNotifications(db);\n const dbKey = resolveDbCacheKey(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey);\n return;\n }\n try {\n await notifications.notify(CHANGE_SIGNAL_CHANNEL, {\n ...signal,\n source: PROCESS_ID,\n });\n } catch (error) {\n logger.warn(\n `Change signal: failed to broadcast a signal for '${signal.table}'`,\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n}\n\n/**\n * Ensure a background listener consumes cross-replica broadcasts for this\n * database and delivers them locally. Started lazily by the first subscriber\n * for a scope; a no-op (with a one-time warning) when the adapter exposes no\n * notification capability. Notifications this process published are skipped by\n * `source` id (echo-avoidance): the local fan-out already delivered them.\n */\nfunction ensureChangeSignalListener(db: DatabaseInterface): void {\n const dbKey = resolveDbCacheKey(db);\n if (crossReplicaListeners.has(dbKey)) return;\n\n const notifications = getNotifications(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey);\n return;\n }\n\n const handle: ListenerHandle = { iterator: null, stopped: false };\n crossReplicaListeners.set(dbKey, handle);\n\n void (async () => {\n try {\n const iterable = notifications.listen(CHANGE_SIGNAL_CHANNEL);\n const iterator = iterable[Symbol.asyncIterator]();\n handle.iterator = iterator;\n\n while (!handle.stopped) {\n const { value, done } = await iterator.next();\n if (done || handle.stopped) break;\n\n const notification = value as { payload?: unknown };\n const payload: unknown =\n typeof notification?.payload === 'string'\n ? safeParse(notification.payload)\n : notification?.payload;\n\n if (!payload || typeof payload !== 'object') continue;\n const record = payload as Record<string, unknown>;\n // Echo-avoidance: skip signals this process published (already\n // delivered locally on the write path).\n if (record.source === PROCESS_ID) continue;\n\n const signal = toSignal(record);\n if (!signal) continue;\n deliverLocally(dbKey, signal);\n }\n } catch (error) {\n if (!handle.stopped) {\n logger.warn(\n 'Change signal: cross-replica listener terminated unexpectedly; ' +\n 'peer signals are no longer delivered for this database (local ' +\n 'delivery and cursor catch-up still work)',\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n } finally {\n // Only retract our own handle — a concurrent restart may have installed\n // a replacement; deleting unconditionally would orphan it.\n if (crossReplicaListeners.get(dbKey) === handle) {\n crossReplicaListeners.delete(dbKey);\n }\n }\n })();\n}\n\n/** Retract the cross-replica listener for a scope (last subscriber left). */\nfunction retractChangeSignalListener(dbKey: string): void {\n const handle = crossReplicaListeners.get(dbKey);\n if (!handle) return;\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n crossReplicaListeners.delete(dbKey);\n}\n\n/**\n * Stop all cross-replica change-signal listeners. Used by tests and during\n * shutdown; safe to call when none are active.\n */\nexport function stopChangeSignalListeners(): void {\n for (const handle of crossReplicaListeners.values()) {\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n }\n crossReplicaListeners.clear();\n warnedNoNotifications.clear();\n}\n\n/**\n * Clear all local subscribers, stop cross-replica listeners, and reset the\n * no-capability warning dedup. Call in test setup for isolation between files.\n */\nexport function resetChangeSignals(): void {\n localListeners.clear();\n stopChangeSignalListeners();\n}\n\n/**\n * Number of active local subscribers for a database scope (test helper).\n *\n * Exposed so integration tests can assert teardown — a leaked SSE subscription\n * would keep this above 0 after a client disconnects. Not part of the public\n * API surface (kept internal to core; not re-exported from `index.ts`).\n */\nexport function changeSignalSubscriberCount(db: DatabaseInterface): number {\n return localListeners.get(resolveDbCacheKey(db))?.size ?? 0;\n}\n\nfunction warnOnceNoNotifications(dbKey: string): void {\n if (warnedNoNotifications.has(dbKey)) return;\n warnedNoNotifications.add(dbKey);\n logger.warn(\n 'Change signal: the database adapter exposes no notification capability, ' +\n 'so change signals are delivered in-process only. Cross-replica live ' +\n 'updates require an adapter with notifications (e.g. Postgres ' +\n 'LISTEN/NOTIFY); subscribers on other replicas fall back to cursor ' +\n 'polling.',\n );\n}\n\n/**\n * Coerce a received notification payload into a {@link ChangeSignal}, or\n * `undefined` if it is malformed. Guards the cross-replica path against\n * garbage on the channel.\n */\nfunction toSignal(record: Record<string, unknown>): ChangeSignal | undefined {\n const { table, operation, rowId, tenantId, seq } = record;\n if (typeof table !== 'string' || !table) return undefined;\n if (\n operation !== 'create' &&\n operation !== 'update' &&\n operation !== 'delete'\n ) {\n return undefined;\n }\n return {\n table,\n operation,\n rowId: typeof rowId === 'string' ? rowId : null,\n tenantId: typeof tenantId === 'string' ? tenantId : null,\n seq: typeof seq === 'number' ? seq : Number(seq ?? 0) || 0,\n };\n}\n\nfunction safeParse(value: string): unknown {\n try {\n return JSON.parse(value);\n } catch {\n return undefined;\n }\n}\n"],"mappings":"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"}
|
|
@@ -31,6 +31,14 @@ export interface CollectionCacheConfig {
|
|
|
31
31
|
* Notification channel used for cross-process invalidation broadcasts.
|
|
32
32
|
*/
|
|
33
33
|
export declare const CACHE_INVALIDATION_CHANNEL = "smrt_collection_cache";
|
|
34
|
+
/**
|
|
35
|
+
* Identifies this process in broadcast payloads so a replica can skip
|
|
36
|
+
* notifications it published itself (it already invalidated locally).
|
|
37
|
+
*
|
|
38
|
+
* Exported so sibling cross-replica buses (the #1763 change-signal bus) share
|
|
39
|
+
* one per-process identity, keeping echo-avoidance consistent across channels.
|
|
40
|
+
*/
|
|
41
|
+
export declare const PROCESS_ID: `${string}-${string}-${string}-${string}-${string}`;
|
|
34
42
|
/**
|
|
35
43
|
* Current invalidation generation for a table (0 if never invalidated).
|
|
36
44
|
* Capture this before a DB read and pass it to `setCachedRows`.
|
|
@@ -86,6 +94,19 @@ export declare function registerCrossProcessCacheInterest(dbKey: string, tableNa
|
|
|
86
94
|
* Whether any per-call `crossProcess` cached read has touched this table.
|
|
87
95
|
*/
|
|
88
96
|
export declare function hasCrossProcessCacheInterest(dbKey: string, tableName: string): boolean;
|
|
97
|
+
/**
|
|
98
|
+
* The database adapter's optional notification capability (e.g. Postgres
|
|
99
|
+
* LISTEN/NOTIFY), or `undefined` when the adapter exposes none. Duck-typed so
|
|
100
|
+
* core never depends on a concrete adapter shape. Exported so sibling
|
|
101
|
+
* cross-replica buses (the #1763 change-signal bus) resolve it identically.
|
|
102
|
+
*/
|
|
103
|
+
export declare function getNotifications(db: DatabaseInterface): {
|
|
104
|
+
notify(channel: string, payload: unknown): Promise<number>;
|
|
105
|
+
listen(channel: string, options?: Record<string, unknown>): AsyncIterable<{
|
|
106
|
+
channel: string;
|
|
107
|
+
payload: unknown;
|
|
108
|
+
}>;
|
|
109
|
+
} | undefined;
|
|
89
110
|
/**
|
|
90
111
|
* Broadcast a table invalidation to peer processes.
|
|
91
112
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"collection-cache.d.ts","sourceRoot":"","sources":["../src/collection-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAI5D;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IACpC;;OAEG;IACH,GAAG,EAAE,MAAM,CAAC;IAEZ;;;;;;;;;;;;;;OAcG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAcD;;GAEG;AACH,eAAO,MAAM,0BAA0B,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"collection-cache.d.ts","sourceRoot":"","sources":["../src/collection-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAI5D;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IACpC;;OAEG;IACH,GAAG,EAAE,MAAM,CAAC;IAEZ;;;;;;;;;;;;;;OAcG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAcD;;GAEG;AACH,eAAO,MAAM,0BAA0B,0BAA0B,CAAC;AAElE;;;;;;GAMG;AACH,eAAO,MAAM,UAAU,qDAAsB,CAAC;AAsB9C;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAE3E;AAQD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,iBAAiB,GAAG,MAAM,CAa/D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,CAEzE;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,SAAS,CAQvC;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC/B,GAAG,EAAE,MAAM,EACX,kBAAkB,CAAC,EAAE,MAAM,GAC1B,IAAI,CAqCN;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,GAChB,IAAI,CAMN;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAK3C;AAaD;;GAEG;AACH,wBAAgB,iCAAiC,CAC/C,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,GAChB,IAAI,CAON;AAED;;GAEG;AACH,wBAAgB,4BAA4B,CAC1C,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,GAChB,OAAO,CAET;AAcD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,iBAAiB,GAGhD;IACE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3D,MAAM,CACJ,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,aAAa,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACzD,GACD,SAAS,CACd;AAYD;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAC9C,EAAE,EAAE,iBAAiB,EACrB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAkBf;AAED;;;;;;;GAOG;AACH,wBAAgB,+BAA+B,CAAC,EAAE,EAAE,iBAAiB,GAAG,IAAI,CAsD3E;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,IAAI,IAAI,CAOrD"}
|
package/dist/collection-cache.js
CHANGED
|
Binary file
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"collection-cache.js","names":[],"sources":["../src/collection-cache.ts"],"sourcesContent":["/**\n * Opt-in read-through cache for collection reads (issue #1498).\n *\n * SSR-rendered SMRT apps re-query read-heavy / write-rare collections on\n * every request, and the per-query round-trip dominates wall time when a\n * page needs many collections. This module memoizes `list()`/`get()` row\n * sets keyed by the final SQL + parameters, for an opt-in TTL.\n *\n * Correctness model:\n * - Caching is OFF by default. It is enabled per call\n * (`list({ cache: { ttl } })`) or per model (`@smrt({ cache: { ttl } })`).\n * - SMRT owns every mutation path (`save()`/`delete()` back\n * `collection.create()`, `getOrUpsert()`, junction attach/detach), so all\n * writes invalidate the affected table's entries in-process automatically.\n * - Entries are scoped per database identity (`db.url`) and per table, so\n * multi-DB processes and STI siblings (which share a table) stay coherent.\n * - Caches are per-process. With multiple replicas, a local invalidation\n * leaves peers stale until TTL unless cross-process invalidation is opted\n * into (`crossProcess: true`), which broadcasts over the database\n * adapter's notification capability (e.g. Postgres LISTEN/NOTIFY) when\n * the adapter provides one.\n *\n * Cached values are raw result rows, not hydrated instances — hydration and\n * read interceptors (tenancy, audit) run on every call, cached or not, and\n * each caller receives isolated row copies.\n *\n * Known limitations:\n * - Invalidation fires when a mutation's SQL executes, not when its\n * surrounding transaction commits. A write inside an uncommitted\n * transaction invalidates (and may broadcast) immediately; a concurrent\n * reader could repopulate the cache from the pre-commit snapshot, and a\n * rollback leaves the cache invalidated for a write that never landed.\n * Caching targets read-heavy / write-rare data where this is rare; for\n * models mutated inside multi-statement transactions, coherence is still\n * bounded by TTL. Per-call `cache: false` forces a fresh read where it\n * matters.\n * - Writes that bypass the framework's mutation paths (raw `db.query`\n * issued outside `collection.query()`, external processes without\n * `crossProcess`) are only bounded by TTL.\n *\n * @see https://github.com/happyvertical/smrt/issues/1498\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Read-through cache configuration for collection reads.\n *\n * Used both per call (`collection.list({ cache: { ttl: 60_000 } })`) and per\n * model (`@smrt({ cache: { ttl: 60_000 } })`).\n */\nexport interface CollectionCacheConfig {\n /**\n * Time-to-live for cached query results, in milliseconds. Must be > 0.\n */\n ttl: number;\n\n /**\n * Broadcast invalidations to peer processes through the database\n * adapter's notification capability (`db.notifications`, e.g. Postgres\n * LISTEN/NOTIFY). Without this, peer replicas serve stale rows until TTL.\n *\n * Model-level config (`@smrt({ cache })`) is the reliable opt-in: every\n * process that writes the model knows to broadcast. As a per-call option,\n * writes broadcast only from processes that have already performed a\n * `crossProcess` cached read of the same table (typical for homogeneous\n * replicas running the same routes); a process that only writes never\n * learns about the per-call opt-in, so its peers fall back to TTL expiry.\n *\n * Ignored (with a one-time warning) when the adapter does not expose\n * notifications.\n */\n crossProcess?: boolean;\n}\n\ninterface CacheEntry {\n expiresAt: number;\n rows: Record<string, unknown>[];\n}\n\n/**\n * Per-table entry cap. A table accumulating more distinct query shapes than\n * this within one TTL window evicts its oldest entries (insertion order).\n * Guards against unbounded growth from high-cardinality WHERE values.\n */\nconst MAX_ENTRIES_PER_TABLE = 500;\n\n/**\n * Notification channel used for cross-process invalidation broadcasts.\n */\nexport const CACHE_INVALIDATION_CHANNEL = 'smrt_collection_cache';\n\n/**\n * Identifies this process in broadcast payloads so a replica can skip\n * notifications it published itself (it already invalidated locally).\n */\nconst PROCESS_ID = crypto.randomUUID();\n\n/**\n * dbKey → tableName → (queryKey → entry)\n */\nconst store = new Map<string, Map<string, Map<string, CacheEntry>>>();\n\n/**\n * Monotonic invalidation generation per `dbKey\\0tableName`, bumped on every\n * invalidation. A read captures the generation *before* its DB round-trip and\n * passes it to `setCachedRows`; if an invalidating write landed during the\n * round-trip, the generation no longer matches and the (now-stale) result is\n * dropped instead of cached. This closes the read-miss/concurrent-write race\n * where an in-flight SELECT would otherwise repopulate the cache with\n * pre-write rows for the full TTL.\n */\nconst generations = new Map<string, number>();\n\nfunction generationKey(dbKey: string, tableName: string): string {\n return `${dbKey}\u0000${tableName}`;\n}\n\n/**\n * Current invalidation generation for a table (0 if never invalidated).\n * Capture this before a DB read and pass it to `setCachedRows`.\n */\nexport function getCacheGeneration(dbKey: string, tableName: string): number {\n return generations.get(generationKey(dbKey, tableName)) ?? 0;\n}\n\n/**\n * Fallback identities for database instances that expose no URL\n * (each such instance gets its own scope, never shared).\n */\nconst fallbackDbKeys = new WeakMap<object, string>();\n\n/**\n * Resolve a stable cache scope for a database instance.\n *\n * Mirrors table-verifier's identity resolution (`db.url || config.url`).\n * `:memory:` databases share that URL string while being entirely separate\n * databases, so they (and URL-less instances) are scoped per instance —\n * serving one in-memory database's rows for another would be a correctness\n * bug, not just a stale read.\n */\nexport function resolveDbCacheKey(db: DatabaseInterface): string {\n const dbWithConfig = db as DatabaseInterface & {\n config?: { url?: string };\n };\n const url = db.url || dbWithConfig.config?.url;\n if (url && url !== ':memory:') return url;\n\n let key = fallbackDbKeys.get(db);\n if (!key) {\n key = `smrt-db:${crypto.randomUUID()}`;\n fallbackDbKeys.set(db, key);\n }\n return key;\n}\n\n/**\n * Build the cache key for a query. The final SQL and bound parameters fully\n * normalize the query shape — they already include STI discriminator\n * filters, interceptor-injected tenant filters, ORDER BY, LIMIT and OFFSET.\n */\nexport function buildQueryCacheKey(sql: string, params: unknown[]): string {\n return `${sql}\u0000${JSON.stringify(params)}`;\n}\n\n/**\n * Read cached rows for a query, or undefined on miss/expiry.\n *\n * Returns a structured clone so callers can never mutate the cached copy\n * (hydration writes into row objects).\n */\nexport function getCachedRows(\n dbKey: string,\n tableName: string,\n queryKey: string,\n): Record<string, unknown>[] | undefined {\n const entry = store.get(dbKey)?.get(tableName)?.get(queryKey);\n if (!entry) return undefined;\n if (entry.expiresAt <= Date.now()) {\n store.get(dbKey)?.get(tableName)?.delete(queryKey);\n return undefined;\n }\n return structuredClone(entry.rows);\n}\n\n/**\n * Store rows for a query under the table's cache scope.\n *\n * `expectedGeneration` is the value {@link getCacheGeneration} returned before\n * the DB read. If an invalidation bumped the table's generation while the read\n * was in flight, the result is stale and is dropped rather than cached.\n */\nexport function setCachedRows(\n dbKey: string,\n tableName: string,\n queryKey: string,\n rows: Record<string, unknown>[],\n ttl: number,\n expectedGeneration?: number,\n): void {\n if (!(ttl > 0)) return;\n\n // Drop a result that a concurrent write invalidated mid-flight.\n if (\n expectedGeneration !== undefined &&\n getCacheGeneration(dbKey, tableName) !== expectedGeneration\n ) {\n return;\n }\n\n let tables = store.get(dbKey);\n if (!tables) {\n tables = new Map();\n store.set(dbKey, tables);\n }\n let entries = tables.get(tableName);\n if (!entries) {\n entries = new Map();\n tables.set(tableName, entries);\n }\n\n // Evict oldest entries (Map preserves insertion order) at the cap. Skip when\n // refreshing an existing key — `set` updates it in place without growing the\n // map, so evicting first would drop a live entry and shrink the effective cap.\n if (!entries.has(queryKey)) {\n while (entries.size >= MAX_ENTRIES_PER_TABLE) {\n const oldest = entries.keys().next().value;\n if (oldest === undefined) break;\n entries.delete(oldest);\n }\n }\n\n entries.set(queryKey, {\n expiresAt: Date.now() + ttl,\n rows: structuredClone(rows),\n });\n}\n\n/**\n * Drop every cached entry for a table. Called by the framework after any\n * successful mutation against that table — this is the write-invalidation\n * guarantee an app-side cache can't make.\n */\nexport function invalidateCollectionCache(\n dbKey: string,\n tableName: string,\n): void {\n store.get(dbKey)?.delete(tableName);\n // Bump the generation so any read whose DB round-trip is still in flight\n // discards its (now-stale) result instead of repopulating the table.\n const gkey = generationKey(dbKey, tableName);\n generations.set(gkey, (generations.get(gkey) ?? 0) + 1);\n}\n\n/**\n * Clear all cached collection reads and stop cross-process listeners.\n * Call in test setup to ensure isolation between test files.\n */\nexport function resetCollectionCache(): void {\n store.clear();\n generations.clear();\n crossProcessInterest.clear();\n stopCacheInvalidationListeners();\n}\n\n// ============================================================================\n// Per-call crossProcess interest (write-side broadcast decision)\n// ============================================================================\n\n/**\n * dbKey → tables this process has cached with a per-call `crossProcess`\n * opt-in. Writes consult this so per-call usage broadcasts too — model-level\n * config can't be the only trigger when the opt-in lives at the call site.\n */\nconst crossProcessInterest = new Map<string, Set<string>>();\n\n/**\n * Record that a per-call `crossProcess` cached read happened for a table.\n */\nexport function registerCrossProcessCacheInterest(\n dbKey: string,\n tableName: string,\n): void {\n let tables = crossProcessInterest.get(dbKey);\n if (!tables) {\n tables = new Set();\n crossProcessInterest.set(dbKey, tables);\n }\n tables.add(tableName);\n}\n\n/**\n * Whether any per-call `crossProcess` cached read has touched this table.\n */\nexport function hasCrossProcessCacheInterest(\n dbKey: string,\n tableName: string,\n): boolean {\n return crossProcessInterest.get(dbKey)?.has(tableName) ?? false;\n}\n\n// ============================================================================\n// Cross-process invalidation (opt-in, via db.notifications)\n// ============================================================================\n\ninterface ListenerHandle {\n iterator: AsyncIterator<unknown> | null;\n stopped: boolean;\n}\n\nconst listeners = new Map<string, ListenerHandle>();\nconst warnedNoNotifications = new Set<string>();\n\nfunction getNotifications(db: DatabaseInterface) {\n return (db as DatabaseInterface & { notifications?: unknown })\n .notifications as\n | {\n notify(channel: string, payload: unknown): Promise<number>;\n listen(\n channel: string,\n options?: Record<string, unknown>,\n ): AsyncIterable<{ channel: string; payload: unknown }>;\n }\n | undefined;\n}\n\nfunction warnOnceNoNotifications(dbKey: string, context: string): void {\n if (warnedNoNotifications.has(dbKey)) return;\n warnedNoNotifications.add(dbKey);\n logger.warn(\n `Collection cache: crossProcess invalidation requested but the database ` +\n `adapter exposes no notification capability (${context}). Peer ` +\n `replicas will serve stale rows until TTL.`,\n );\n}\n\n/**\n * Broadcast a table invalidation to peer processes.\n *\n * Fire-and-forget from mutation paths: a broadcast failure must never fail\n * the write that triggered it.\n */\nexport async function broadcastCacheInvalidation(\n db: DatabaseInterface,\n tableName: string,\n): Promise<void> {\n const notifications = getNotifications(db);\n const dbKey = resolveDbCacheKey(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey, 'broadcast');\n return;\n }\n try {\n await notifications.notify(CACHE_INVALIDATION_CHANNEL, {\n table: tableName,\n source: PROCESS_ID,\n });\n } catch (error) {\n logger.warn(\n `Collection cache: failed to broadcast invalidation for ${tableName}`,\n { error: error instanceof Error ? error.message : error },\n );\n }\n}\n\n/**\n * Ensure a background listener consumes invalidation broadcasts for this\n * database and drops matching local cache entries.\n *\n * Started lazily by the first cached read that opted into `crossProcess`.\n * Notifications published by this process are skipped — the local\n * invalidation already happened synchronously on the write path.\n */\nexport function ensureCacheInvalidationListener(db: DatabaseInterface): void {\n const dbKey = resolveDbCacheKey(db);\n if (listeners.has(dbKey)) return;\n\n const notifications = getNotifications(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey, 'listen');\n return;\n }\n\n const handle: ListenerHandle = { iterator: null, stopped: false };\n listeners.set(dbKey, handle);\n\n void (async () => {\n try {\n const iterable = notifications.listen(CACHE_INVALIDATION_CHANNEL);\n const iterator = iterable[Symbol.asyncIterator]();\n handle.iterator = iterator;\n\n while (!handle.stopped) {\n const { value, done } = await iterator.next();\n if (done || handle.stopped) break;\n\n const notification = value as { payload?: unknown };\n const payload: unknown =\n typeof notification?.payload === 'string'\n ? safeParse(notification.payload)\n : notification?.payload;\n\n if (!payload || typeof payload !== 'object') continue;\n const record = payload as { table?: unknown; source?: unknown };\n if (typeof record.table !== 'string') continue;\n if (record.source === PROCESS_ID) continue;\n\n invalidateCollectionCache(dbKey, record.table);\n }\n } catch (error) {\n if (!handle.stopped) {\n logger.warn(\n 'Collection cache: invalidation listener terminated unexpectedly; ' +\n 'cross-process invalidation is inactive for this database ' +\n '(local TTL still bounds staleness)',\n { error: error instanceof Error ? error.message : error },\n );\n }\n } finally {\n // Only retract our own handle. A concurrent stop+restart for the same\n // dbKey may have already installed a replacement; deleting unconditionally\n // would orphan it and leak/duplicate the live listener.\n if (listeners.get(dbKey) === handle) {\n listeners.delete(dbKey);\n }\n }\n })();\n}\n\n/**\n * Stop all cross-process invalidation listeners. Used by tests and during\n * shutdown; safe to call when none are active.\n */\nexport function stopCacheInvalidationListeners(): void {\n for (const handle of listeners.values()) {\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n }\n listeners.clear();\n warnedNoNotifications.clear();\n}\n\nfunction safeParse(value: string): unknown {\n try {\n return JSON.parse(value);\n } catch {\n return undefined;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"collection-cache.js","names":[],"sources":["../src/collection-cache.ts"],"sourcesContent":["/**\n * Opt-in read-through cache for collection reads (issue #1498).\n *\n * SSR-rendered SMRT apps re-query read-heavy / write-rare collections on\n * every request, and the per-query round-trip dominates wall time when a\n * page needs many collections. This module memoizes `list()`/`get()` row\n * sets keyed by the final SQL + parameters, for an opt-in TTL.\n *\n * Correctness model:\n * - Caching is OFF by default. It is enabled per call\n * (`list({ cache: { ttl } })`) or per model (`@smrt({ cache: { ttl } })`).\n * - SMRT owns every mutation path (`save()`/`delete()` back\n * `collection.create()`, `getOrUpsert()`, junction attach/detach), so all\n * writes invalidate the affected table's entries in-process automatically.\n * - Entries are scoped per database identity (`db.url`) and per table, so\n * multi-DB processes and STI siblings (which share a table) stay coherent.\n * - Caches are per-process. With multiple replicas, a local invalidation\n * leaves peers stale until TTL unless cross-process invalidation is opted\n * into (`crossProcess: true`), which broadcasts over the database\n * adapter's notification capability (e.g. Postgres LISTEN/NOTIFY) when\n * the adapter provides one.\n *\n * Cached values are raw result rows, not hydrated instances — hydration and\n * read interceptors (tenancy, audit) run on every call, cached or not, and\n * each caller receives isolated row copies.\n *\n * Known limitations:\n * - Invalidation fires when a mutation's SQL executes, not when its\n * surrounding transaction commits. A write inside an uncommitted\n * transaction invalidates (and may broadcast) immediately; a concurrent\n * reader could repopulate the cache from the pre-commit snapshot, and a\n * rollback leaves the cache invalidated for a write that never landed.\n * Caching targets read-heavy / write-rare data where this is rare; for\n * models mutated inside multi-statement transactions, coherence is still\n * bounded by TTL. Per-call `cache: false` forces a fresh read where it\n * matters.\n * - Writes that bypass the framework's mutation paths (raw `db.query`\n * issued outside `collection.query()`, external processes without\n * `crossProcess`) are only bounded by TTL.\n *\n * @see https://github.com/happyvertical/smrt/issues/1498\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Read-through cache configuration for collection reads.\n *\n * Used both per call (`collection.list({ cache: { ttl: 60_000 } })`) and per\n * model (`@smrt({ cache: { ttl: 60_000 } })`).\n */\nexport interface CollectionCacheConfig {\n /**\n * Time-to-live for cached query results, in milliseconds. Must be > 0.\n */\n ttl: number;\n\n /**\n * Broadcast invalidations to peer processes through the database\n * adapter's notification capability (`db.notifications`, e.g. Postgres\n * LISTEN/NOTIFY). Without this, peer replicas serve stale rows until TTL.\n *\n * Model-level config (`@smrt({ cache })`) is the reliable opt-in: every\n * process that writes the model knows to broadcast. As a per-call option,\n * writes broadcast only from processes that have already performed a\n * `crossProcess` cached read of the same table (typical for homogeneous\n * replicas running the same routes); a process that only writes never\n * learns about the per-call opt-in, so its peers fall back to TTL expiry.\n *\n * Ignored (with a one-time warning) when the adapter does not expose\n * notifications.\n */\n crossProcess?: boolean;\n}\n\ninterface CacheEntry {\n expiresAt: number;\n rows: Record<string, unknown>[];\n}\n\n/**\n * Per-table entry cap. A table accumulating more distinct query shapes than\n * this within one TTL window evicts its oldest entries (insertion order).\n * Guards against unbounded growth from high-cardinality WHERE values.\n */\nconst MAX_ENTRIES_PER_TABLE = 500;\n\n/**\n * Notification channel used for cross-process invalidation broadcasts.\n */\nexport const CACHE_INVALIDATION_CHANNEL = 'smrt_collection_cache';\n\n/**\n * Identifies this process in broadcast payloads so a replica can skip\n * notifications it published itself (it already invalidated locally).\n *\n * Exported so sibling cross-replica buses (the #1763 change-signal bus) share\n * one per-process identity, keeping echo-avoidance consistent across channels.\n */\nexport const PROCESS_ID = crypto.randomUUID();\n\n/**\n * dbKey → tableName → (queryKey → entry)\n */\nconst store = new Map<string, Map<string, Map<string, CacheEntry>>>();\n\n/**\n * Monotonic invalidation generation per `dbKey\\0tableName`, bumped on every\n * invalidation. A read captures the generation *before* its DB round-trip and\n * passes it to `setCachedRows`; if an invalidating write landed during the\n * round-trip, the generation no longer matches and the (now-stale) result is\n * dropped instead of cached. This closes the read-miss/concurrent-write race\n * where an in-flight SELECT would otherwise repopulate the cache with\n * pre-write rows for the full TTL.\n */\nconst generations = new Map<string, number>();\n\nfunction generationKey(dbKey: string, tableName: string): string {\n return `${dbKey}\u0000${tableName}`;\n}\n\n/**\n * Current invalidation generation for a table (0 if never invalidated).\n * Capture this before a DB read and pass it to `setCachedRows`.\n */\nexport function getCacheGeneration(dbKey: string, tableName: string): number {\n return generations.get(generationKey(dbKey, tableName)) ?? 0;\n}\n\n/**\n * Fallback identities for database instances that expose no URL\n * (each such instance gets its own scope, never shared).\n */\nconst fallbackDbKeys = new WeakMap<object, string>();\n\n/**\n * Resolve a stable cache scope for a database instance.\n *\n * Mirrors table-verifier's identity resolution (`db.url || config.url`).\n * `:memory:` databases share that URL string while being entirely separate\n * databases, so they (and URL-less instances) are scoped per instance —\n * serving one in-memory database's rows for another would be a correctness\n * bug, not just a stale read.\n */\nexport function resolveDbCacheKey(db: DatabaseInterface): string {\n const dbWithConfig = db as DatabaseInterface & {\n config?: { url?: string };\n };\n const url = db.url || dbWithConfig.config?.url;\n if (url && url !== ':memory:') return url;\n\n let key = fallbackDbKeys.get(db);\n if (!key) {\n key = `smrt-db:${crypto.randomUUID()}`;\n fallbackDbKeys.set(db, key);\n }\n return key;\n}\n\n/**\n * Build the cache key for a query. The final SQL and bound parameters fully\n * normalize the query shape — they already include STI discriminator\n * filters, interceptor-injected tenant filters, ORDER BY, LIMIT and OFFSET.\n */\nexport function buildQueryCacheKey(sql: string, params: unknown[]): string {\n return `${sql}\u0000${JSON.stringify(params)}`;\n}\n\n/**\n * Read cached rows for a query, or undefined on miss/expiry.\n *\n * Returns a structured clone so callers can never mutate the cached copy\n * (hydration writes into row objects).\n */\nexport function getCachedRows(\n dbKey: string,\n tableName: string,\n queryKey: string,\n): Record<string, unknown>[] | undefined {\n const entry = store.get(dbKey)?.get(tableName)?.get(queryKey);\n if (!entry) return undefined;\n if (entry.expiresAt <= Date.now()) {\n store.get(dbKey)?.get(tableName)?.delete(queryKey);\n return undefined;\n }\n return structuredClone(entry.rows);\n}\n\n/**\n * Store rows for a query under the table's cache scope.\n *\n * `expectedGeneration` is the value {@link getCacheGeneration} returned before\n * the DB read. If an invalidation bumped the table's generation while the read\n * was in flight, the result is stale and is dropped rather than cached.\n */\nexport function setCachedRows(\n dbKey: string,\n tableName: string,\n queryKey: string,\n rows: Record<string, unknown>[],\n ttl: number,\n expectedGeneration?: number,\n): void {\n if (!(ttl > 0)) return;\n\n // Drop a result that a concurrent write invalidated mid-flight.\n if (\n expectedGeneration !== undefined &&\n getCacheGeneration(dbKey, tableName) !== expectedGeneration\n ) {\n return;\n }\n\n let tables = store.get(dbKey);\n if (!tables) {\n tables = new Map();\n store.set(dbKey, tables);\n }\n let entries = tables.get(tableName);\n if (!entries) {\n entries = new Map();\n tables.set(tableName, entries);\n }\n\n // Evict oldest entries (Map preserves insertion order) at the cap. Skip when\n // refreshing an existing key — `set` updates it in place without growing the\n // map, so evicting first would drop a live entry and shrink the effective cap.\n if (!entries.has(queryKey)) {\n while (entries.size >= MAX_ENTRIES_PER_TABLE) {\n const oldest = entries.keys().next().value;\n if (oldest === undefined) break;\n entries.delete(oldest);\n }\n }\n\n entries.set(queryKey, {\n expiresAt: Date.now() + ttl,\n rows: structuredClone(rows),\n });\n}\n\n/**\n * Drop every cached entry for a table. Called by the framework after any\n * successful mutation against that table — this is the write-invalidation\n * guarantee an app-side cache can't make.\n */\nexport function invalidateCollectionCache(\n dbKey: string,\n tableName: string,\n): void {\n store.get(dbKey)?.delete(tableName);\n // Bump the generation so any read whose DB round-trip is still in flight\n // discards its (now-stale) result instead of repopulating the table.\n const gkey = generationKey(dbKey, tableName);\n generations.set(gkey, (generations.get(gkey) ?? 0) + 1);\n}\n\n/**\n * Clear all cached collection reads and stop cross-process listeners.\n * Call in test setup to ensure isolation between test files.\n */\nexport function resetCollectionCache(): void {\n store.clear();\n generations.clear();\n crossProcessInterest.clear();\n stopCacheInvalidationListeners();\n}\n\n// ============================================================================\n// Per-call crossProcess interest (write-side broadcast decision)\n// ============================================================================\n\n/**\n * dbKey → tables this process has cached with a per-call `crossProcess`\n * opt-in. Writes consult this so per-call usage broadcasts too — model-level\n * config can't be the only trigger when the opt-in lives at the call site.\n */\nconst crossProcessInterest = new Map<string, Set<string>>();\n\n/**\n * Record that a per-call `crossProcess` cached read happened for a table.\n */\nexport function registerCrossProcessCacheInterest(\n dbKey: string,\n tableName: string,\n): void {\n let tables = crossProcessInterest.get(dbKey);\n if (!tables) {\n tables = new Set();\n crossProcessInterest.set(dbKey, tables);\n }\n tables.add(tableName);\n}\n\n/**\n * Whether any per-call `crossProcess` cached read has touched this table.\n */\nexport function hasCrossProcessCacheInterest(\n dbKey: string,\n tableName: string,\n): boolean {\n return crossProcessInterest.get(dbKey)?.has(tableName) ?? false;\n}\n\n// ============================================================================\n// Cross-process invalidation (opt-in, via db.notifications)\n// ============================================================================\n\ninterface ListenerHandle {\n iterator: AsyncIterator<unknown> | null;\n stopped: boolean;\n}\n\nconst listeners = new Map<string, ListenerHandle>();\nconst warnedNoNotifications = new Set<string>();\n\n/**\n * The database adapter's optional notification capability (e.g. Postgres\n * LISTEN/NOTIFY), or `undefined` when the adapter exposes none. Duck-typed so\n * core never depends on a concrete adapter shape. Exported so sibling\n * cross-replica buses (the #1763 change-signal bus) resolve it identically.\n */\nexport function getNotifications(db: DatabaseInterface) {\n return (db as DatabaseInterface & { notifications?: unknown })\n .notifications as\n | {\n notify(channel: string, payload: unknown): Promise<number>;\n listen(\n channel: string,\n options?: Record<string, unknown>,\n ): AsyncIterable<{ channel: string; payload: unknown }>;\n }\n | undefined;\n}\n\nfunction warnOnceNoNotifications(dbKey: string, context: string): void {\n if (warnedNoNotifications.has(dbKey)) return;\n warnedNoNotifications.add(dbKey);\n logger.warn(\n `Collection cache: crossProcess invalidation requested but the database ` +\n `adapter exposes no notification capability (${context}). Peer ` +\n `replicas will serve stale rows until TTL.`,\n );\n}\n\n/**\n * Broadcast a table invalidation to peer processes.\n *\n * Fire-and-forget from mutation paths: a broadcast failure must never fail\n * the write that triggered it.\n */\nexport async function broadcastCacheInvalidation(\n db: DatabaseInterface,\n tableName: string,\n): Promise<void> {\n const notifications = getNotifications(db);\n const dbKey = resolveDbCacheKey(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey, 'broadcast');\n return;\n }\n try {\n await notifications.notify(CACHE_INVALIDATION_CHANNEL, {\n table: tableName,\n source: PROCESS_ID,\n });\n } catch (error) {\n logger.warn(\n `Collection cache: failed to broadcast invalidation for ${tableName}`,\n { error: error instanceof Error ? error.message : error },\n );\n }\n}\n\n/**\n * Ensure a background listener consumes invalidation broadcasts for this\n * database and drops matching local cache entries.\n *\n * Started lazily by the first cached read that opted into `crossProcess`.\n * Notifications published by this process are skipped — the local\n * invalidation already happened synchronously on the write path.\n */\nexport function ensureCacheInvalidationListener(db: DatabaseInterface): void {\n const dbKey = resolveDbCacheKey(db);\n if (listeners.has(dbKey)) return;\n\n const notifications = getNotifications(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey, 'listen');\n return;\n }\n\n const handle: ListenerHandle = { iterator: null, stopped: false };\n listeners.set(dbKey, handle);\n\n void (async () => {\n try {\n const iterable = notifications.listen(CACHE_INVALIDATION_CHANNEL);\n const iterator = iterable[Symbol.asyncIterator]();\n handle.iterator = iterator;\n\n while (!handle.stopped) {\n const { value, done } = await iterator.next();\n if (done || handle.stopped) break;\n\n const notification = value as { payload?: unknown };\n const payload: unknown =\n typeof notification?.payload === 'string'\n ? safeParse(notification.payload)\n : notification?.payload;\n\n if (!payload || typeof payload !== 'object') continue;\n const record = payload as { table?: unknown; source?: unknown };\n if (typeof record.table !== 'string') continue;\n if (record.source === PROCESS_ID) continue;\n\n invalidateCollectionCache(dbKey, record.table);\n }\n } catch (error) {\n if (!handle.stopped) {\n logger.warn(\n 'Collection cache: invalidation listener terminated unexpectedly; ' +\n 'cross-process invalidation is inactive for this database ' +\n '(local TTL still bounds staleness)',\n { error: error instanceof Error ? error.message : error },\n );\n }\n } finally {\n // Only retract our own handle. A concurrent stop+restart for the same\n // dbKey may have already installed a replacement; deleting unconditionally\n // would orphan it and leak/duplicate the live listener.\n if (listeners.get(dbKey) === handle) {\n listeners.delete(dbKey);\n }\n }\n })();\n}\n\n/**\n * Stop all cross-process invalidation listeners. Used by tests and during\n * shutdown; safe to call when none are active.\n */\nexport function stopCacheInvalidationListeners(): void {\n for (const handle of listeners.values()) {\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n }\n listeners.clear();\n warnedNoNotifications.clear();\n}\n\nfunction safeParse(value: string): unknown {\n try {\n return JSON.parse(value);\n } catch {\n return undefined;\n }\n}\n"],"mappings":"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"}
|
package/dist/config.d.ts
CHANGED
|
@@ -140,6 +140,6 @@ declare namespace config {
|
|
|
140
140
|
var toJSON: () => GlobalSignalConfig;
|
|
141
141
|
var toString: () => string;
|
|
142
142
|
}
|
|
143
|
-
export { config };
|
|
144
143
|
export { type ConfigOptions, convertType, loadEnvConfig, toCamelCase, toScreamingSnakeCase, } from '@happyvertical/utils';
|
|
144
|
+
export { config };
|
|
145
145
|
//# sourceMappingURL=config.d.ts.map
|
package/dist/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAEjE;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,8BAA8B;IAC9B,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,kCAAkC;IAClC,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;;GAKG;AACH,MAAM,WAAW,QAAQ;IACvB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;OAEG;IACH,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,0DAA0D;IAC1D,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB,yDAAyD;IACzD,aAAa,CAAC,EAAE,OAAO,CAAC;IAExB,2CAA2C;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAE9D,gCAAgC;IAChC,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAC;CAC7B;AAED;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,qEAAqE;IACrE,OAAO,CAAC,EAAE,YAAY,CAAC;IAEvB,0DAA0D;IAC1D,OAAO,CAAC,EAAE,aAAa,CAAC;IAExB,0DAA0D;IAC1D,MAAM,CAAC,EAAE,YAAY,CAAC;IAEtB;;;OAGG;IACH,EAAE,CAAC,EAAE,QAAQ,CAAC;IAEd;;OAEG;IACH,KAAK,CAAC,EAAE,aAAa,CAAC;IAEtB;;;OAGG;IACH,YAAY,CAAC,EAAE,kBAAkB,GAAG,KAAK,CAAC;IAE1C,kCAAkC;IAClC,OAAO,CAAC,EAAE;QACR,iCAAiC;QACjC,GAAG,CAAC,EAAE,SAAS,CAAC;QAChB,iCAAiC;QACjC,QAAQ,CAAC,EAAE,aAAa,EAAE,CAAC;KAC5B,CAAC;CACH;AAmDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,iBAAS,MAAM,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAEjD;kBAFQ,MAAM;qBAOI,IAAI;sBAUH,kBAAkB;wBAQhB,MAAM;;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAEjE;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,8BAA8B;IAC9B,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,kCAAkC;IAClC,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;;GAKG;AACH,MAAM,WAAW,QAAQ;IACvB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;OAEG;IACH,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,0DAA0D;IAC1D,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB,yDAAyD;IACzD,aAAa,CAAC,EAAE,OAAO,CAAC;IAExB,2CAA2C;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAE9D,gCAAgC;IAChC,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAC;CAC7B;AAED;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,qEAAqE;IACrE,OAAO,CAAC,EAAE,YAAY,CAAC;IAEvB,0DAA0D;IAC1D,OAAO,CAAC,EAAE,aAAa,CAAC;IAExB,0DAA0D;IAC1D,MAAM,CAAC,EAAE,YAAY,CAAC;IAEtB;;;OAGG;IACH,EAAE,CAAC,EAAE,QAAQ,CAAC;IAEd;;OAEG;IACH,KAAK,CAAC,EAAE,aAAa,CAAC;IAEtB;;;OAGG;IACH,YAAY,CAAC,EAAE,kBAAkB,GAAG,KAAK,CAAC;IAE1C,kCAAkC;IAClC,OAAO,CAAC,EAAE;QACR,iCAAiC;QACjC,GAAG,CAAC,EAAE,SAAS,CAAC;QAChB,iCAAiC;QACjC,QAAQ,CAAC,EAAE,aAAa,EAAE,CAAC;KAC5B,CAAC;CACH;AAmDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,iBAAS,MAAM,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAEjD;kBAFQ,MAAM;qBAOI,IAAI;sBAUH,kBAAkB;wBAQhB,MAAM;;AAI5B,OAAO,EACL,KAAK,aAAa,EAClB,WAAW,EACX,aAAa,EACb,WAAW,EACX,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,MAAM,EAAE,CAAC"}
|
package/dist/config.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.js","names":[],"sources":["../src/config.ts"],"sourcesContent":["/**\n * Global SMRT configuration system\n *\n * Provides application-level defaults for signal adapters.\n * Configuration follows a three-tier pattern:\n * 1. Global defaults (via smrt.configure())\n * 2. Per-instance overrides (via SmrtClassOptions)\n * 3. Runtime behavior (from merged config)\n */\n\nimport type { LoggerConfig } from '@happyvertical/logger';\nimport type { AiUsageHandler, SignalAdapter } from '@happyvertical/smrt-types';\nimport type { SignalBus } from './signals/bus.js';\nimport type { SanitizationConfig } from './signals/sanitizer.js';\n\n/**\n * Metrics adapter configuration\n */\nexport interface MetricsConfig {\n /** Enable metrics tracking */\n enabled: boolean;\n}\n\n/**\n * Pub/Sub adapter configuration\n */\nexport interface PubSubConfig {\n /** Enable pub/sub broadcasting */\n enabled: boolean;\n}\n\n/**\n * AI provider configuration\n *\n * Global defaults for AI client initialization.\n * Provides fallback values when AI options are not specified per-instance.\n */\nexport interface AIConfig {\n /**\n * Default AI provider to use\n * Examples: 'openai', 'anthropic', 'claude-cli', 'gemini', etc.\n */\n provider?: string;\n\n /**\n * Default model to use with the provider\n * Examples: 'gpt-4', 'claude-3-opus', 'sonnet', etc.\n */\n model?: string;\n\n /**\n * Default API key for the provider\n * Can be overridden by environment variables or instance options\n */\n apiKey?: string;\n\n /**\n * Additional provider-specific options\n */\n [key: string]: unknown;\n}\n\n/**\n * AI usage tracking configuration\n */\nexport interface AiUsageConfig {\n /** Enable normalized AI usage tracking (default: true) */\n enabled?: boolean;\n\n /** Enable persistence to _smrt_ai_usage when a DB exists (default: true) */\n persist?: boolean;\n\n /** Enable best-effort cost estimation (default: true) */\n estimateCosts?: boolean;\n\n /** Override USD-per-1K-token cost rates */\n costRates?: Record<string, { input: number; output: number }>;\n\n /** Additional usage handlers */\n handlers?: AiUsageHandler[];\n}\n\n/**\n * Global signal configuration\n *\n * Application-level defaults for signal adapters.\n * These can be overridden per-instance via SmrtClassOptions.\n */\nexport interface GlobalSignalConfig {\n /** Logging configuration (default: true with console, info level) */\n logging?: LoggerConfig;\n\n /** Metrics configuration (default: undefined/disabled) */\n metrics?: MetricsConfig;\n\n /** Pub/Sub configuration (default: undefined/disabled) */\n pubsub?: PubSubConfig;\n\n /**\n * AI provider configuration (default: undefined)\n * Provides global defaults for AI client initialization\n */\n ai?: AIConfig;\n\n /**\n * AI usage tracking configuration (default: enabled)\n */\n usage?: AiUsageConfig;\n\n /**\n * Signal sanitization configuration (default: enabled with standard redactions)\n * Set to false to disable sanitization\n */\n sanitization?: SanitizationConfig | false;\n\n /** Custom signal configuration */\n signals?: {\n /** Shared signal bus instance */\n bus?: SignalBus;\n /** Additional custom adapters */\n adapters?: SignalAdapter[];\n };\n}\n\n/**\n * Singleton configuration manager\n *\n * Manages global SMRT configuration with sensible defaults.\n */\nclass SmrtConfig {\n private static instance: SmrtConfig;\n private config: GlobalSignalConfig = {\n logging: true, // Default: console logging at info level\n };\n\n private constructor() {}\n\n /**\n * Get singleton instance\n */\n static getInstance(): SmrtConfig {\n if (!SmrtConfig.instance) {\n SmrtConfig.instance = new SmrtConfig();\n }\n return SmrtConfig.instance;\n }\n\n /**\n * Configure global defaults\n *\n * @param config - Configuration to apply\n */\n configure(config: GlobalSignalConfig): void {\n this.config = { ...this.config, ...config };\n }\n\n /**\n * Get current configuration\n *\n * @returns Current global configuration\n */\n getConfig(): GlobalSignalConfig {\n return { ...this.config };\n }\n\n /**\n * Reset to default configuration\n */\n reset(): void {\n this.config = { logging: true };\n }\n}\n\n/**\n * Global configuration API\n *\n * Callable function with attached methods for managing SMRT configuration.\n *\n * @example\n * ```typescript\n * import { config } from '@happyvertical/smrt-core';\n *\n * // Set application-level defaults\n * config({\n * logging: { level: 'debug' },\n * metrics: { enabled: true },\n * pubsub: { enabled: false },\n * ai: {\n * provider: 'claude-cli',\n * model: 'sonnet'\n * }\n * });\n *\n * // Reset to defaults\n * config.reset();\n *\n * // Get current configuration\n * const current = config.toJSON();\n *\n * // Auto-convert to string\n * console.log(`Config: ${config}`);\n *\n * // Auto-convert to JSON\n * JSON.stringify(config);\n *\n * // All SmrtClass instances now use these defaults\n * const product = new Product({ name: 'Widget' });\n * await product.initialize();\n * // product has logging at debug level, metrics enabled, and uses claude-cli by default\n * ```\n */\nfunction config(options: GlobalSignalConfig): void {\n SmrtConfig.getInstance().configure(options);\n}\n\n/**\n * Reset configuration to defaults\n */\nconfig.reset = (): void => {\n SmrtConfig.getInstance().reset();\n};\n\n/**\n * Get current configuration as object\n * Called automatically by JSON.stringify()\n *\n * @returns Current global configuration\n */\nconfig.toJSON = (): GlobalSignalConfig => SmrtConfig.getInstance().getConfig();\n\n/**\n * Convert configuration to string\n * Called automatically in string contexts\n *\n * @returns JSON string representation of configuration\n */\nconfig.toString = (): string =>\n JSON.stringify(SmrtConfig.getInstance().getConfig(), null, 2);\n\
|
|
1
|
+
{"version":3,"file":"config.js","names":[],"sources":["../src/config.ts"],"sourcesContent":["/**\n * Global SMRT configuration system\n *\n * Provides application-level defaults for signal adapters.\n * Configuration follows a three-tier pattern:\n * 1. Global defaults (via smrt.configure())\n * 2. Per-instance overrides (via SmrtClassOptions)\n * 3. Runtime behavior (from merged config)\n */\n\nimport type { LoggerConfig } from '@happyvertical/logger';\nimport type { AiUsageHandler, SignalAdapter } from '@happyvertical/smrt-types';\nimport type { SignalBus } from './signals/bus.js';\nimport type { SanitizationConfig } from './signals/sanitizer.js';\n\n/**\n * Metrics adapter configuration\n */\nexport interface MetricsConfig {\n /** Enable metrics tracking */\n enabled: boolean;\n}\n\n/**\n * Pub/Sub adapter configuration\n */\nexport interface PubSubConfig {\n /** Enable pub/sub broadcasting */\n enabled: boolean;\n}\n\n/**\n * AI provider configuration\n *\n * Global defaults for AI client initialization.\n * Provides fallback values when AI options are not specified per-instance.\n */\nexport interface AIConfig {\n /**\n * Default AI provider to use\n * Examples: 'openai', 'anthropic', 'claude-cli', 'gemini', etc.\n */\n provider?: string;\n\n /**\n * Default model to use with the provider\n * Examples: 'gpt-4', 'claude-3-opus', 'sonnet', etc.\n */\n model?: string;\n\n /**\n * Default API key for the provider\n * Can be overridden by environment variables or instance options\n */\n apiKey?: string;\n\n /**\n * Additional provider-specific options\n */\n [key: string]: unknown;\n}\n\n/**\n * AI usage tracking configuration\n */\nexport interface AiUsageConfig {\n /** Enable normalized AI usage tracking (default: true) */\n enabled?: boolean;\n\n /** Enable persistence to _smrt_ai_usage when a DB exists (default: true) */\n persist?: boolean;\n\n /** Enable best-effort cost estimation (default: true) */\n estimateCosts?: boolean;\n\n /** Override USD-per-1K-token cost rates */\n costRates?: Record<string, { input: number; output: number }>;\n\n /** Additional usage handlers */\n handlers?: AiUsageHandler[];\n}\n\n/**\n * Global signal configuration\n *\n * Application-level defaults for signal adapters.\n * These can be overridden per-instance via SmrtClassOptions.\n */\nexport interface GlobalSignalConfig {\n /** Logging configuration (default: true with console, info level) */\n logging?: LoggerConfig;\n\n /** Metrics configuration (default: undefined/disabled) */\n metrics?: MetricsConfig;\n\n /** Pub/Sub configuration (default: undefined/disabled) */\n pubsub?: PubSubConfig;\n\n /**\n * AI provider configuration (default: undefined)\n * Provides global defaults for AI client initialization\n */\n ai?: AIConfig;\n\n /**\n * AI usage tracking configuration (default: enabled)\n */\n usage?: AiUsageConfig;\n\n /**\n * Signal sanitization configuration (default: enabled with standard redactions)\n * Set to false to disable sanitization\n */\n sanitization?: SanitizationConfig | false;\n\n /** Custom signal configuration */\n signals?: {\n /** Shared signal bus instance */\n bus?: SignalBus;\n /** Additional custom adapters */\n adapters?: SignalAdapter[];\n };\n}\n\n/**\n * Singleton configuration manager\n *\n * Manages global SMRT configuration with sensible defaults.\n */\nclass SmrtConfig {\n private static instance: SmrtConfig;\n private config: GlobalSignalConfig = {\n logging: true, // Default: console logging at info level\n };\n\n private constructor() {}\n\n /**\n * Get singleton instance\n */\n static getInstance(): SmrtConfig {\n if (!SmrtConfig.instance) {\n SmrtConfig.instance = new SmrtConfig();\n }\n return SmrtConfig.instance;\n }\n\n /**\n * Configure global defaults\n *\n * @param config - Configuration to apply\n */\n configure(config: GlobalSignalConfig): void {\n this.config = { ...this.config, ...config };\n }\n\n /**\n * Get current configuration\n *\n * @returns Current global configuration\n */\n getConfig(): GlobalSignalConfig {\n return { ...this.config };\n }\n\n /**\n * Reset to default configuration\n */\n reset(): void {\n this.config = { logging: true };\n }\n}\n\n/**\n * Global configuration API\n *\n * Callable function with attached methods for managing SMRT configuration.\n *\n * @example\n * ```typescript\n * import { config } from '@happyvertical/smrt-core';\n *\n * // Set application-level defaults\n * config({\n * logging: { level: 'debug' },\n * metrics: { enabled: true },\n * pubsub: { enabled: false },\n * ai: {\n * provider: 'claude-cli',\n * model: 'sonnet'\n * }\n * });\n *\n * // Reset to defaults\n * config.reset();\n *\n * // Get current configuration\n * const current = config.toJSON();\n *\n * // Auto-convert to string\n * console.log(`Config: ${config}`);\n *\n * // Auto-convert to JSON\n * JSON.stringify(config);\n *\n * // All SmrtClass instances now use these defaults\n * const product = new Product({ name: 'Widget' });\n * await product.initialize();\n * // product has logging at debug level, metrics enabled, and uses claude-cli by default\n * ```\n */\nfunction config(options: GlobalSignalConfig): void {\n SmrtConfig.getInstance().configure(options);\n}\n\n/**\n * Reset configuration to defaults\n */\nconfig.reset = (): void => {\n SmrtConfig.getInstance().reset();\n};\n\n/**\n * Get current configuration as object\n * Called automatically by JSON.stringify()\n *\n * @returns Current global configuration\n */\nconfig.toJSON = (): GlobalSignalConfig => SmrtConfig.getInstance().getConfig();\n\n/**\n * Convert configuration to string\n * Called automatically in string contexts\n *\n * @returns JSON string representation of configuration\n */\nconfig.toString = (): string =>\n JSON.stringify(SmrtConfig.getInstance().getConfig(), null, 2);\n\n// Re-export env-config utilities from @happyvertical/utils\nexport {\n type ConfigOptions,\n convertType,\n loadEnvConfig,\n toCamelCase,\n toScreamingSnakeCase,\n} from '@happyvertical/utils';\nexport { config };\n"],"mappings":";;;;;;;AAiIA,IAAM,aAAN,MAAM,WAAW;CACf,OAAe;CACf,SAAqC,EACnC,SAAS,KACX;CAEA,cAAsB,CAAC;;;;CAKvB,OAAO,cAA0B;EAC/B,IAAI,CAAC,WAAW,UACd,WAAW,WAAW,IAAI,WAAW;EAEvC,OAAO,WAAW;CACpB;;;;;;CAOA,UAAU,QAAkC;EAC1C,KAAK,SAAS;GAAE,GAAG,KAAK;GAAQ,GAAG;EAAO;CAC5C;;;;;;CAOA,YAAgC;EAC9B,OAAO,EAAE,GAAG,KAAK,OAAO;CAC1B;;;;CAKA,QAAc;EACZ,KAAK,SAAS,EAAE,SAAS,KAAK;CAChC;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,SAAS,OAAO,SAAmC;CACjD,WAAW,YAAY,CAAC,CAAC,UAAU,OAAO;AAC5C;;;;AAKA,OAAO,cAAoB;CACzB,WAAW,YAAY,CAAC,CAAC,MAAM;AACjC;;;;;;;AAQA,OAAO,eAAmC,WAAW,YAAY,CAAC,CAAC,UAAU;;;;;;;AAQ7E,OAAO,iBACL,KAAK,UAAU,WAAW,YAAY,CAAC,CAAC,UAAU,GAAG,MAAM,CAAC"}
|
|
@@ -74,6 +74,14 @@ export interface FieldOptions {
|
|
|
74
74
|
* cannot mass-assign them. Server-side code can still set them directly.
|
|
75
75
|
*/
|
|
76
76
|
readonly?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Permission slug required to include this field in public/read responses.
|
|
79
|
+
*
|
|
80
|
+
* Fields with a read permission are fail-closed: generated serializers omit
|
|
81
|
+
* them unless the caller's resolved permission set contains this slug.
|
|
82
|
+
* `sensitive: true` still wins and omits the field for every caller.
|
|
83
|
+
*/
|
|
84
|
+
readPermission?: string;
|
|
77
85
|
/** Field description */
|
|
78
86
|
description?: string;
|
|
79
87
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/decorators/index.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/decorators/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EACL,KAAK,2BAA2B,EAIjC,MAAM,oBAAoB,CAAC;AAE5B;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,MAAM,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC;AAExB;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAC1B,MAAM,GACN,SAAS,GACT,SAAS,GACT,SAAS,GACT,UAAU,GACV,MAAM,CAAC;AAEX,MAAM,MAAM,SAAS,GACjB,kBAAkB,GAClB,MAAM,GACN,YAAY,GACZ,iBAAiB,GACjB,WAAW,GACX,YAAY,CAAC;AAEjB,MAAM,WAAW,YAAY;IAC3B,8DAA8D;IAC9D,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,8EAA8E;IAC9E,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,oCAAoC;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,kCAAkC;IAClC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,kCAAkC;IAClC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,oCAAoC;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,yDAAyD;IACzD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;;;;;;;OAWG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,wBAAwB;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,gBAAiB,SAAQ,YAAY;IACpD,qCAAqC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qCAAqC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mCAAmC;IACnC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC3B;AAED;;GAEG;AACH,MAAM,WAAW,mBAAoB,SAAQ,YAAY;IACvD,oBAAoB;IACpB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,oBAAoB;IACpB,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;GAEG;AACH,MAAM,WAAW,wBAAyB,SAAQ,YAAY;IAC5D,yBAAyB;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6BAA6B;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qCAAqC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wBAAwB;IACxB,IAAI,CAAC,EAAE,YAAY,GAAG,iBAAiB,GAAG,WAAW,GAAG,YAAY,CAAC;CACtE;AAED;;GAEG;AACH,MAAM,WAAW,sBACf,SAAQ,IAAI,CAAC,wBAAwB,EAAE,SAAS,GAAG,MAAM,CAAC;IAC1D;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAEzB;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,wBAAgB,KAAK,CACnB,OAAO,GAAE,YAAY,GAAG,mBAAmB,GAAG,gBAAqB,GAa7D,2BAA2B,CAClC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,UAAU,CACxB,YAAY,EAAE,MAAM,GAAG,QAAQ,EAC/B,OAAO,GAAE,IAAI,CAAC,wBAAwB,EAAE,SAAS,CAAM,GAoBjD,2BAA2B,CAClC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,wBAAgB,eAAe,CAC7B,aAAa,EAAE,MAAM,EACrB,OAAO,GAAE,sBAA2B,GAiB9B,2BAA2B,CAClC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AACH,wBAAgB,SAAS,CACvB,YAAY,EAAE,MAAM,GAAG,QAAQ,EAC/B,OAAO,GAAE,IAAI,CAAC,wBAAwB,EAAE,SAAS,CAAM,GAqBjD,2BAA2B,CAClC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,UAAU,CACxB,YAAY,EAAE,MAAM,GAAG,QAAQ,EAC/B,OAAO,GAAE,IAAI,CAAC,wBAAwB,EAAE,SAAS,CAAM,GAqBjD,2BAA2B,CAClC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,IAAI,CAAC,OAAO,GAAE,YAAiB,GAevC,2BAA2B,CAClC"}
|