@velajs/vela 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.js +1 -1
  3. package/dist/live/index.d.ts +12 -0
  4. package/dist/live/index.js +16 -0
  5. package/dist/live/live.cursor.d.ts +22 -0
  6. package/dist/live/live.cursor.js +56 -0
  7. package/dist/live/live.decorators.d.ts +32 -0
  8. package/dist/live/live.decorators.js +46 -0
  9. package/dist/live/live.delta.d.ts +15 -0
  10. package/dist/live/live.delta.js +51 -0
  11. package/dist/live/live.engine.d.ts +81 -0
  12. package/dist/live/live.engine.js +448 -0
  13. package/dist/live/live.invalidation.d.ts +27 -0
  14. package/dist/live/live.invalidation.js +56 -0
  15. package/dist/live/live.module.d.ts +28 -0
  16. package/dist/live/live.module.js +83 -0
  17. package/dist/live/live.tokens.d.ts +9 -0
  18. package/dist/live/live.tokens.js +8 -0
  19. package/dist/live/live.types.d.ts +138 -0
  20. package/dist/live/live.types.js +1 -0
  21. package/dist/live/presence.d.ts +44 -0
  22. package/dist/live/presence.js +130 -0
  23. package/dist/websocket/index.d.ts +3 -3
  24. package/dist/websocket/index.js +2 -2
  25. package/dist/websocket/websocket.decorators.d.ts +14 -0
  26. package/dist/websocket/websocket.decorators.js +23 -1
  27. package/dist/websocket/websocket.tokens.d.ts +7 -0
  28. package/dist/websocket/websocket.tokens.js +6 -0
  29. package/dist/websocket/websocket.types.d.ts +16 -0
  30. package/dist/websocket/ws-dispatcher.d.ts +9 -0
  31. package/dist/websocket/ws-dispatcher.js +64 -1
  32. package/dist/websocket-node/index.d.ts +2 -0
  33. package/dist/websocket-node/index.js +1 -0
  34. package/dist/websocket-node/redis-live.d.ts +24 -0
  35. package/dist/websocket-node/redis-live.js +57 -0
  36. package/package.json +6 -1
@@ -0,0 +1,448 @@
1
+ function _ts_decorate(decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ }
7
+ function _ts_metadata(k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ }
10
+ function _ts_param(paramIndex, decorator) {
11
+ return function(target, key) {
12
+ decorator(target, key, paramIndex);
13
+ };
14
+ }
15
+ import { LIVE_ERROR_CODES, LIVE_PROTOCOL, encodeLiveEnvelope, isClientLiveFrame } from "@velajs/live-protocol";
16
+ import { Container, DiscoveryService, Inject, Injectable, Optional, PipelineRunner, ReservedWsEvent, buildEntrypointExecutionContext, resolveScopedComponents, runInEntrypointScope } from "../index.js";
17
+ import { getLiveQueries } from "./live.decorators.js";
18
+ import { encodeSubscriptionUpdate } from "./live.delta.js";
19
+ import { LIVE_CURSOR_LOG, LIVE_DRIVER, LIVE_MODULE_OPTIONS, LIVE_RESOLVER_METADATA } from "./live.tokens.js";
20
+ import { PresenceService } from "./presence.js";
21
+ /** How many subscriptions refresh concurrently per flush (lunora's socket-pool default). */ const REFRESH_POOL_SIZE = 8;
22
+ /**
23
+ * Where the engine persists a connection's subscription records inside
24
+ * `client.data` (committed through `WsClient.commit()`). On Cloudflare that
25
+ * lands in the hibernation attachment, so subscriptions survive DO eviction;
26
+ * the transport replays them via `restoreSubscription` on wake. On node,
27
+ * `commit()` is a no-op and this is just in-memory bookkeeping.
28
+ */ export const LIVE_SUBS_DATA_KEY = '__velaLiveSubs';
29
+ /** Read the subscription records a transport persisted for a connection (wake/restore path). */ export function readPersistedLiveSubscriptions(client) {
30
+ const raw = client.data?.[LIVE_SUBS_DATA_KEY];
31
+ if (!Array.isArray(raw)) return [];
32
+ return raw.filter((record)=>typeof record === 'object' && record !== null && typeof record.sub === 'string' && typeof record.query === 'string' && Array.isArray(record.tags));
33
+ }
34
+ const defaultIdentity = (client)=>{
35
+ const data = client.data;
36
+ if (!data || Object.keys(data).length === 0) return undefined;
37
+ return {
38
+ ...data
39
+ };
40
+ };
41
+ async function runPool(items, size, run) {
42
+ const queue = [
43
+ ...items
44
+ ];
45
+ const workers = Array.from({
46
+ length: Math.min(size, queue.length)
47
+ }, async ()=>{
48
+ for(let item = queue.shift(); item !== undefined; item = queue.shift()){
49
+ await run(item);
50
+ }
51
+ });
52
+ await Promise.all(workers);
53
+ }
54
+ export class LiveEngine {
55
+ container;
56
+ discovery;
57
+ log;
58
+ options;
59
+ presence;
60
+ queries = new Map();
61
+ connections = new Map();
62
+ pendingTags = new Set();
63
+ drainChain = Promise.resolve();
64
+ draining = false;
65
+ constructor(container, discovery, log, driver, options, presence){
66
+ this.container = container;
67
+ this.discovery = discovery;
68
+ this.log = log;
69
+ this.options = options;
70
+ this.presence = presence;
71
+ driver.bind(this);
72
+ this.presence?.bindInvalidator((tags)=>{
73
+ void driver.dispatch({
74
+ tags
75
+ });
76
+ });
77
+ }
78
+ async onApplicationBootstrap() {
79
+ for (const found of this.discovery.providersWithMeta(LIVE_RESOLVER_METADATA)){
80
+ if (!found.instance) continue;
81
+ for (const declared of getLiveQueries(found.metatype)){
82
+ if (this.queries.has(declared.name)) {
83
+ const msg = `[vela] duplicate @LiveQuery('${declared.name}') ` + `(${found.metatype.name}); keeping the first.`;
84
+ if (this.container.getDiagnostics() === 'throw') throw new Error(msg);
85
+ console.warn(msg);
86
+ continue;
87
+ }
88
+ this.queries.set(declared.name, {
89
+ token: found.metatype,
90
+ methodName: declared.methodName,
91
+ options: declared.options
92
+ });
93
+ }
94
+ }
95
+ }
96
+ /** The `'live'` entrypoint — how transports (node registrar, CF DO bootstrap) find the engine. */ collectEntrypoints() {
97
+ return [
98
+ {
99
+ kind: 'live',
100
+ token: LiveEngine,
101
+ instance: this,
102
+ meta: {
103
+ engine: this
104
+ }
105
+ }
106
+ ];
107
+ }
108
+ // ---- ReservedWsEventHandler ----
109
+ async handleReservedEvent(path, client, message) {
110
+ const frame = message.data;
111
+ // Forward-compat: unknown frame shapes are ignored, not errors.
112
+ if (!isClientLiveFrame(frame)) return;
113
+ switch(frame.t){
114
+ case 'sub':
115
+ await this.onSubscribe(path, client, frame);
116
+ return;
117
+ case 'unsub':
118
+ {
119
+ const conn = this.connections.get(client.id);
120
+ if (!conn) return;
121
+ conn.subs.delete(frame.sub);
122
+ if (conn.subs.size === 0) this.connections.delete(client.id);
123
+ await this.persistSubscriptions(conn);
124
+ return;
125
+ }
126
+ case 'presence':
127
+ if (this.presence?.enabled) {
128
+ this.presence.beat(frame.room, client.id, frame.meta);
129
+ }
130
+ return;
131
+ }
132
+ }
133
+ async handleSocketClose(_path, client) {
134
+ this.connections.delete(client.id);
135
+ this.presence?.reap(client.id);
136
+ }
137
+ // ---- LiveInvalidationSink ----
138
+ async applyInvalidation(cmd) {
139
+ const stamp = await this.log.append(cmd.tags);
140
+ for (const tag of cmd.tags)this.pendingTags.add(tag);
141
+ this.scheduleDrain();
142
+ return stamp;
143
+ }
144
+ /** Settles when every pending invalidation has been flushed (tests, graceful transports). */ async whenIdle() {
145
+ do {
146
+ await this.drainChain;
147
+ }while (this.draining || this.pendingTags.size > 0)
148
+ }
149
+ /**
150
+ * Re-attach a persisted subscription without the subscribe handshake — the
151
+ * Cloudflare transport replays hibernation-attachment records through this
152
+ * on wake. No frames are sent; the next relevant invalidation pushes.
153
+ */ restoreSubscription(path, client, record) {
154
+ this.ensureConnection(path, client).subs.set(record.sub, record);
155
+ }
156
+ // ---- subscribe path ----
157
+ async onSubscribe(path, client, frame) {
158
+ if (frame.v !== undefined && frame.v > LIVE_PROTOCOL) {
159
+ this.sendFrame(client, {
160
+ t: 'error',
161
+ sub: frame.sub,
162
+ code: LIVE_ERROR_CODES.UNSUPPORTED_PROTOCOL,
163
+ message: `server speaks live protocol v${LIVE_PROTOCOL}, client asked for v${frame.v}`,
164
+ fatal: true
165
+ });
166
+ return;
167
+ }
168
+ const conn = this.ensureConnection(path, client);
169
+ if (conn.subs.has(frame.sub)) {
170
+ this.sendFrame(client, {
171
+ t: 'error',
172
+ sub: frame.sub,
173
+ code: LIVE_ERROR_CODES.DUPLICATE_SUB,
174
+ message: `subscription id '${frame.sub}' is already active on this socket`,
175
+ fatal: false
176
+ });
177
+ return;
178
+ }
179
+ const registered = this.queries.get(frame.query);
180
+ if (!registered) {
181
+ this.sendFrame(client, {
182
+ t: 'error',
183
+ sub: frame.sub,
184
+ code: LIVE_ERROR_CODES.UNKNOWN_QUERY,
185
+ message: `no @LiveQuery('${frame.query}') is registered`,
186
+ fatal: true
187
+ });
188
+ return;
189
+ }
190
+ let args = frame.args;
191
+ if (registered.options.parse) {
192
+ try {
193
+ args = registered.options.parse(frame.args);
194
+ } catch (err) {
195
+ this.sendFrame(client, {
196
+ t: 'error',
197
+ sub: frame.sub,
198
+ code: LIVE_ERROR_CODES.BAD_ARGS,
199
+ message: err instanceof Error ? err.message : 'invalid subscription args',
200
+ fatal: true
201
+ });
202
+ return;
203
+ }
204
+ }
205
+ // Resolver-tier guards run ONCE, here (app-wide guards already ran in the
206
+ // dispatcher's reserved-event path). Re-runs are server-initiated and rely
207
+ // on the identity captured below instead of re-authorizing.
208
+ if (!await this.runSubscribeGuards(registered, client, frame.query, args)) {
209
+ this.sendFrame(client, {
210
+ t: 'error',
211
+ sub: frame.sub,
212
+ code: LIVE_ERROR_CODES.FORBIDDEN,
213
+ message: `subscription to '${frame.query}' was rejected`,
214
+ fatal: true
215
+ });
216
+ return;
217
+ }
218
+ const identity = (this.options.identity ?? defaultIdentity)(client);
219
+ const tags = typeof registered.options.tags === 'function' ? registered.options.tags(args) : registered.options.tags;
220
+ const record = {
221
+ sub: frame.sub,
222
+ query: frame.query,
223
+ args,
224
+ tags,
225
+ key: frame.key ?? registered.options.key,
226
+ identity
227
+ };
228
+ conn.subs.set(frame.sub, record);
229
+ await this.persistSubscriptions(conn);
230
+ this.sendFrame(client, {
231
+ t: 'ack',
232
+ sub: frame.sub
233
+ });
234
+ // Resume: when the client's cursor is still explainable and none of the
235
+ // subscription's tags were invalidated in the gap, a tiny `resume` frame
236
+ // keeps its cached value — no re-run at all. The diff baseline stays
237
+ // undefined, so the next relevant change sends a full snapshot.
238
+ if (frame.sinceCursor !== undefined && frame.sinceEpoch !== undefined) {
239
+ const verdict = await this.log.evaluateResume(frame.sinceCursor, frame.sinceEpoch, tags);
240
+ if (verdict === 'resume') {
241
+ const stamp = await this.log.current();
242
+ if (this.sendFrame(client, {
243
+ t: 'resume',
244
+ sub: frame.sub,
245
+ cursor: stamp.cursor,
246
+ epoch: stamp.epoch
247
+ })) {
248
+ record.lastCursor = stamp.cursor;
249
+ }
250
+ return;
251
+ }
252
+ }
253
+ await this.push(conn, record, await this.log.current(), {
254
+ initial: true
255
+ });
256
+ }
257
+ async runSubscribeGuards(registered, client, query, args) {
258
+ const guards = resolveScopedComponents('guard', registered.token, registered.methodName, this.container);
259
+ if (guards.length === 0) return true;
260
+ const context = buildEntrypointExecutionContext('live', registered.token, registered.methodName, {
261
+ query,
262
+ args,
263
+ client
264
+ });
265
+ try {
266
+ for (const guard of guards){
267
+ if (!await guard.canActivate(context)) return false;
268
+ }
269
+ return true;
270
+ } catch {
271
+ return false;
272
+ }
273
+ }
274
+ // ---- refresh path ----
275
+ scheduleDrain() {
276
+ if (this.draining) return;
277
+ this.draining = true;
278
+ this.drainChain = this.drainChain.then(()=>this.drainLoop()).catch((err)=>{
279
+ if (this.container.getDiagnostics() !== 'silent') {
280
+ console.warn('[vela] live refresh flush failed:', err);
281
+ }
282
+ }).finally(()=>{
283
+ this.draining = false;
284
+ if (this.pendingTags.size > 0) this.scheduleDrain();
285
+ });
286
+ }
287
+ async drainLoop() {
288
+ while(this.pendingTags.size > 0){
289
+ const changed = new Set(this.pendingTags);
290
+ this.pendingTags.clear();
291
+ // The stamp is resolved once per pass, BEFORE the re-runs (lunora
292
+ // resolves its CDC cut the same way): results can only be newer than
293
+ // the cursor they carry.
294
+ const stamp = await this.log.current();
295
+ const work = [];
296
+ for (const conn of this.connections.values()){
297
+ for (const record of conn.subs.values()){
298
+ if (record.tags.some((tag)=>changed.has(tag))) work.push({
299
+ conn,
300
+ record
301
+ });
302
+ }
303
+ }
304
+ await runPool(work, REFRESH_POOL_SIZE, async ({ conn, record })=>{
305
+ await this.push(conn, record, stamp, {
306
+ initial: false
307
+ });
308
+ });
309
+ }
310
+ }
311
+ async push(conn, record, stamp, { initial }) {
312
+ // Outbound expiry enforcement — the only place expiry CAN be enforced for
313
+ // a passive subscriber.
314
+ const expiresAt = record.identity?.expiresAt;
315
+ if (typeof expiresAt === 'number' && expiresAt <= Date.now()) {
316
+ this.connections.delete(conn.client.id);
317
+ try {
318
+ conn.client.close(1008, 'identity expired');
319
+ } catch {
320
+ // already gone
321
+ }
322
+ return;
323
+ }
324
+ let json;
325
+ let result;
326
+ try {
327
+ result = await this.runQuery(record, conn.client);
328
+ // `undefined` has no JSON form — normalize so the frame always carries
329
+ // an explicit `snapshot` and the baseline stays a string.
330
+ if (result === undefined) result = null;
331
+ json = JSON.stringify(result);
332
+ } catch (err) {
333
+ if (initial) {
334
+ conn.subs.delete(record.sub);
335
+ this.sendFrame(conn.client, {
336
+ t: 'error',
337
+ sub: record.sub,
338
+ code: LIVE_ERROR_CODES.INTERNAL,
339
+ message: err instanceof Error ? err.message : 'live query failed',
340
+ fatal: true
341
+ });
342
+ } else if (this.container.getDiagnostics() !== 'silent') {
343
+ // Transient: baseline untouched, the subscription retries next flush.
344
+ console.warn(`[vela] live query '${record.query}' re-run failed (will retry):`, err);
345
+ }
346
+ return;
347
+ }
348
+ const frame = encodeSubscriptionUpdate(record, json, result, stamp, initial);
349
+ if (this.sendFrame(conn.client, frame)) {
350
+ record.lastJson = json;
351
+ record.lastCursor = stamp.cursor;
352
+ }
353
+ }
354
+ async runQuery(record, client) {
355
+ const registered = this.queries.get(record.query);
356
+ if (!registered) throw new Error(`live query '${record.query}' disappeared from the registry`);
357
+ return runInEntrypointScope(this.container, async (scope)=>{
358
+ // Async seam: lazy resolver modules materialize, request-scoped
359
+ // resolvers rebuild per run (mirrors queue dispatch).
360
+ const instance = await scope.resolveAsync(registered.token);
361
+ const liveContext = {
362
+ identity: record.identity,
363
+ clientId: client.id,
364
+ rooms: [
365
+ ...client.rooms
366
+ ]
367
+ };
368
+ const context = buildEntrypointExecutionContext('live', registered.token, registered.methodName, {
369
+ query: record.query,
370
+ args: record.args,
371
+ client
372
+ });
373
+ const interceptors = resolveScopedComponents('interceptor', registered.token, registered.methodName, scope);
374
+ return PipelineRunner.run({
375
+ context,
376
+ guards: [],
377
+ interceptors,
378
+ resolveArgs: async ()=>[
379
+ record.args,
380
+ liveContext
381
+ ],
382
+ invoke: async (args)=>{
383
+ const method = instance[registered.methodName];
384
+ return method.apply(instance, args);
385
+ }
386
+ });
387
+ });
388
+ }
389
+ /**
390
+ * Mirror the connection's records into `client.data` + `commit()` so
391
+ * hibernating transports can replay them on wake. The volatile diff
392
+ * baseline (`lastJson`/`lastCursor`) is deliberately stripped: it can be
393
+ * large (the whole last result vs the 16 KiB attachment cap) and a lost
394
+ * baseline just means the next relevant change sends a full snapshot.
395
+ */ async persistSubscriptions(conn) {
396
+ const records = [
397
+ ...conn.subs.values()
398
+ ].map(({ lastJson: _lastJson, lastCursor: _lastCursor, ...persisted })=>persisted);
399
+ try {
400
+ conn.client.data[LIVE_SUBS_DATA_KEY] = records;
401
+ await conn.client.commit();
402
+ } catch (err) {
403
+ if (this.container.getDiagnostics() !== 'silent') {
404
+ console.warn('[vela] live subscription persistence failed (resume across eviction disabled):', err);
405
+ }
406
+ }
407
+ }
408
+ ensureConnection(path, client) {
409
+ let conn = this.connections.get(client.id);
410
+ if (!conn) {
411
+ conn = {
412
+ client,
413
+ path,
414
+ subs: new Map()
415
+ };
416
+ this.connections.set(client.id, conn);
417
+ }
418
+ return conn;
419
+ }
420
+ sendFrame(client, frame) {
421
+ try {
422
+ client.sendRaw(encodeLiveEnvelope(frame));
423
+ return true;
424
+ } catch {
425
+ return false;
426
+ }
427
+ }
428
+ }
429
+ LiveEngine = _ts_decorate([
430
+ ReservedWsEvent('$live'),
431
+ Injectable(),
432
+ _ts_param(0, Inject(Container)),
433
+ _ts_param(1, Inject(DiscoveryService)),
434
+ _ts_param(2, Inject(LIVE_CURSOR_LOG)),
435
+ _ts_param(3, Inject(LIVE_DRIVER)),
436
+ _ts_param(4, Inject(LIVE_MODULE_OPTIONS)),
437
+ _ts_param(5, Optional()),
438
+ _ts_param(5, Inject(PresenceService)),
439
+ _ts_metadata("design:type", Function),
440
+ _ts_metadata("design:paramtypes", [
441
+ typeof Container === "undefined" ? Object : Container,
442
+ typeof DiscoveryService === "undefined" ? Object : DiscoveryService,
443
+ typeof CursorLog === "undefined" ? Object : CursorLog,
444
+ typeof LiveDriver === "undefined" ? Object : LiveDriver,
445
+ typeof LiveModuleOptions === "undefined" ? Object : LiveModuleOptions,
446
+ typeof PresenceService === "undefined" ? Object : PresenceService
447
+ ])
448
+ ], LiveEngine);
@@ -0,0 +1,27 @@
1
+ import type { CommitStamp, InvalidationCommand, LiveDriver } from './live.types';
2
+ /** Single-scope default driver: deliver straight to this process's engine. */
3
+ export declare function localLive(): LiveDriver;
4
+ /**
5
+ * The injectable invalidation surface for custom (non-CRUD) write paths:
6
+ *
7
+ * ```ts
8
+ * await this.live.invalidate({ tags: ['todos:' + listId] });
9
+ * ```
10
+ *
11
+ * Every affected live query re-runs and pushes. Returns the commit stamp of
12
+ * the targeted log scope so mutation endpoints can expose it to the client
13
+ * (`Vela-Commit-Cursor` / `Vela-Commit-Epoch`) — the cursor optimistic
14
+ * updates gate on. When ambient request access is enabled
15
+ * (`VelaFactory.create(m, { ambientContainer: true })`) the headers are
16
+ * stamped onto the current HTTP response automatically; otherwise stamp them
17
+ * yourself via {@link stampCommitHeaders}.
18
+ */
19
+ export declare class LiveInvalidation {
20
+ private readonly driver;
21
+ constructor(driver: LiveDriver);
22
+ invalidate(cmd: InvalidationCommand): Promise<CommitStamp | undefined>;
23
+ }
24
+ /** Write the commit headers onto a Hono context (a mutation route's response). */
25
+ export declare function stampCommitHeaders(c: {
26
+ header: (name: string, value: string) => unknown;
27
+ }, stamp: CommitStamp): void;
@@ -0,0 +1,56 @@
1
+ import { COMMIT_CURSOR_HEADER, COMMIT_EPOCH_HEADER } from "@velajs/live-protocol";
2
+ import { getContext } from "hono/context-storage";
3
+ /** Single-scope default driver: deliver straight to this process's engine. */ export function localLive() {
4
+ let sink;
5
+ return {
6
+ kind: 'local',
7
+ bind (s) {
8
+ sink = s;
9
+ },
10
+ dispatch (cmd) {
11
+ return sink?.applyInvalidation(cmd);
12
+ }
13
+ };
14
+ }
15
+ /**
16
+ * The injectable invalidation surface for custom (non-CRUD) write paths:
17
+ *
18
+ * ```ts
19
+ * await this.live.invalidate({ tags: ['todos:' + listId] });
20
+ * ```
21
+ *
22
+ * Every affected live query re-runs and pushes. Returns the commit stamp of
23
+ * the targeted log scope so mutation endpoints can expose it to the client
24
+ * (`Vela-Commit-Cursor` / `Vela-Commit-Epoch`) — the cursor optimistic
25
+ * updates gate on. When ambient request access is enabled
26
+ * (`VelaFactory.create(m, { ambientContainer: true })`) the headers are
27
+ * stamped onto the current HTTP response automatically; otherwise stamp them
28
+ * yourself via {@link stampCommitHeaders}.
29
+ */ export class LiveInvalidation {
30
+ driver;
31
+ constructor(driver){
32
+ this.driver = driver;
33
+ }
34
+ async invalidate(cmd) {
35
+ const stamp = await this.driver.dispatch(cmd);
36
+ if (stamp) stampAmbientCommitHeaders(stamp);
37
+ return stamp;
38
+ }
39
+ }
40
+ /** Write the commit headers onto a Hono context (a mutation route's response). */ export function stampCommitHeaders(c, stamp) {
41
+ c.header(COMMIT_CURSOR_HEADER, String(stamp.cursor));
42
+ c.header(COMMIT_EPOCH_HEADER, stamp.epoch);
43
+ }
44
+ /**
45
+ * Best-effort automatic header stamping through Hono's ambient context
46
+ * storage. A no-op outside a request or when `contextStorage()` isn't
47
+ * registered — ALS keeps this per-request-correct under concurrency (a
48
+ * mutable singleton slot would race interleaved awaits).
49
+ */ function stampAmbientCommitHeaders(stamp) {
50
+ try {
51
+ stampCommitHeaders(getContext(), stamp);
52
+ } catch {
53
+ // Ambient access not enabled, or invalidate() ran outside a request
54
+ // (queue job, cron tick) — there is no HTTP response to stamp.
55
+ }
56
+ }
@@ -0,0 +1,28 @@
1
+ import type { LiveModuleOptions } from './live.types';
2
+ /**
3
+ * First-party live-query module (tag-based realtime reactivity) — authored,
4
+ * like the queue module, entirely on vela's public API (`live-openness.test.ts`
5
+ * machine-verifies it; the only extra dependency is the shared wire package
6
+ * `@velajs/live-protocol`).
7
+ *
8
+ * ```ts
9
+ * imports: [WebSocketModule.forRoot({}), LiveModule.forRoot({})]
10
+ * ```
11
+ *
12
+ * App code declares `@LiveResolver` classes with `@LiveQuery(name, { tags })`
13
+ * methods; clients subscribe over the `$live` reserved WebSocket event; writes
14
+ * invalidate tags via `LiveInvalidation` (the `@velajs/crud` bridge does it
15
+ * automatically per table). See `LIVE.md` for the wire protocol, delivery
16
+ * guarantees, and the resume story.
17
+ *
18
+ * Deliberately EAGER (like WebSocketModule): the engine self-drives — it
19
+ * claims the `$live` event at bootstrap and receives invalidations from
20
+ * writes that never touch a live token — so the lazy-module contract
21
+ * ("nothing self-drives") rules laziness out.
22
+ */
23
+ declare const ConfigurableModuleClass: import("../module").ConfigurableModuleClassType<LiveModuleOptions, "forRoot", "create", {
24
+ isGlobal?: boolean;
25
+ }>;
26
+ export declare class LiveModule extends ConfigurableModuleClass {
27
+ }
28
+ export {};
@@ -0,0 +1,83 @@
1
+ import { defineModule } from "../index.js";
2
+ import { InMemoryCursorLog } from "./live.cursor.js";
3
+ import { LiveEngine } from "./live.engine.js";
4
+ import { LiveInvalidation, localLive } from "./live.invalidation.js";
5
+ import { LIVE_CURSOR_LOG, LIVE_DRIVER, LIVE_MODULE_OPTIONS } from "./live.tokens.js";
6
+ import { PresenceResolver, PresenceService } from "./presence.js";
7
+ /**
8
+ * First-party live-query module (tag-based realtime reactivity) — authored,
9
+ * like the queue module, entirely on vela's public API (`live-openness.test.ts`
10
+ * machine-verifies it; the only extra dependency is the shared wire package
11
+ * `@velajs/live-protocol`).
12
+ *
13
+ * ```ts
14
+ * imports: [WebSocketModule.forRoot({}), LiveModule.forRoot({})]
15
+ * ```
16
+ *
17
+ * App code declares `@LiveResolver` classes with `@LiveQuery(name, { tags })`
18
+ * methods; clients subscribe over the `$live` reserved WebSocket event; writes
19
+ * invalidate tags via `LiveInvalidation` (the `@velajs/crud` bridge does it
20
+ * automatically per table). See `LIVE.md` for the wire protocol, delivery
21
+ * guarantees, and the resume story.
22
+ *
23
+ * Deliberately EAGER (like WebSocketModule): the engine self-drives — it
24
+ * claims the `$live` event at bootstrap and receives invalidations from
25
+ * writes that never touch a live token — so the lazy-module contract
26
+ * ("nothing self-drives") rules laziness out.
27
+ */ const { ConfigurableModuleClass } = defineModule({
28
+ name: 'Live',
29
+ optionsToken: LIVE_MODULE_OPTIONS,
30
+ key: (options)=>`live#${options?.driver?.kind ?? 'local'}`,
31
+ setup: ({ OPTIONS, options })=>({
32
+ providers: [
33
+ {
34
+ provide: LIVE_CURSOR_LOG,
35
+ useFactory: (o)=>o.log ?? new InMemoryCursorLog(),
36
+ inject: [
37
+ OPTIONS
38
+ ]
39
+ },
40
+ {
41
+ provide: LIVE_DRIVER,
42
+ useFactory: (o)=>o.driver ?? localLive(),
43
+ inject: [
44
+ OPTIONS
45
+ ]
46
+ },
47
+ {
48
+ provide: PresenceService,
49
+ useFactory: (o)=>{
50
+ const presence = o.presence;
51
+ if (presence === false) return new PresenceService(undefined, false);
52
+ return new PresenceService(presence?.ttlMs, true);
53
+ },
54
+ inject: [
55
+ OPTIONS
56
+ ]
57
+ },
58
+ {
59
+ provide: LiveInvalidation,
60
+ useFactory: (driver)=>new LiveInvalidation(driver),
61
+ inject: [
62
+ LIVE_DRIVER
63
+ ]
64
+ },
65
+ LiveEngine,
66
+ // The built-in `$presence.roster` resolver. Skipping it is STRUCTURAL
67
+ // (`presence: false` must be visible at forRoot/forRootAsync call time,
68
+ // like queue's `queues`); a disabled-at-runtime service still no-ops.
69
+ ...options?.presence === false ? [] : [
70
+ PresenceResolver
71
+ ]
72
+ ],
73
+ exports: [
74
+ LiveEngine,
75
+ LiveInvalidation,
76
+ LIVE_DRIVER,
77
+ LIVE_CURSOR_LOG,
78
+ PresenceService
79
+ ]
80
+ })
81
+ });
82
+ export class LiveModule extends ConfigurableModuleClass {
83
+ }
@@ -0,0 +1,9 @@
1
+ import { InjectionToken } from '../index';
2
+ import type { CursorLog, LiveDriver, LiveModuleOptions } from './live.types';
3
+ export declare const LIVE_RESOLVER_METADATA = "vela:live:resolver";
4
+ export declare const LIVE_QUERY_METADATA = "vela:live:query";
5
+ /** The invalidation driver in effect for a `LiveModule` instance (`localLive()` by default). */
6
+ export declare const LIVE_DRIVER: InjectionToken<LiveDriver>;
7
+ /** The ordered tag-invalidation log backing cursors/epochs for this log scope. */
8
+ export declare const LIVE_CURSOR_LOG: InjectionToken<CursorLog>;
9
+ export declare const LIVE_MODULE_OPTIONS: InjectionToken<LiveModuleOptions>;
@@ -0,0 +1,8 @@
1
+ import { InjectionToken } from "../index.js";
2
+ // Free-form metadata keys — same string-token convention as the queue module.
3
+ export const LIVE_RESOLVER_METADATA = 'vela:live:resolver';
4
+ export const LIVE_QUERY_METADATA = 'vela:live:query';
5
+ /** The invalidation driver in effect for a `LiveModule` instance (`localLive()` by default). */ export const LIVE_DRIVER = new InjectionToken('vela:live:driver');
6
+ /** The ordered tag-invalidation log backing cursors/epochs for this log scope. */ export const LIVE_CURSOR_LOG = new InjectionToken('vela:live:cursor-log');
7
+ // forRoot() options carrier.
8
+ export const LIVE_MODULE_OPTIONS = new InjectionToken('LIVE_MODULE_OPTIONS');