@shadow-js/realtime 0.0.0-stage → 1.0.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 (128) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +114 -2
  4. package/dist/dev/adapter.d.ts +75 -0
  5. package/dist/dev/client/core.d.ts +24 -0
  6. package/dist/dev/client/core.js +845 -0
  7. package/dist/dev/client/core.js.map +1 -0
  8. package/dist/dev/client/index.d.ts +5 -0
  9. package/dist/dev/client/index.js +3 -0
  10. package/dist/dev/client/presence.js +23 -0
  11. package/dist/dev/client/presence.js.map +1 -0
  12. package/dist/dev/client/sse.js +76 -0
  13. package/dist/dev/client/sse.js.map +1 -0
  14. package/dist/dev/client/types.d.ts +256 -0
  15. package/dist/dev/client/util.js +30 -0
  16. package/dist/dev/client/util.js.map +1 -0
  17. package/dist/dev/client/ws.js +39 -0
  18. package/dist/dev/client/ws.js.map +1 -0
  19. package/dist/dev/cloudflare/adapter.d.ts +80 -0
  20. package/dist/dev/cloudflare/adapter.js +347 -0
  21. package/dist/dev/cloudflare/adapter.js.map +1 -0
  22. package/dist/dev/cloudflare/core.js +701 -0
  23. package/dist/dev/cloudflare/core.js.map +1 -0
  24. package/dist/dev/cloudflare/link.js +397 -0
  25. package/dist/dev/cloudflare/link.js.map +1 -0
  26. package/dist/dev/cloudflare/object.d.ts +82 -0
  27. package/dist/dev/cloudflare/object.js +214 -0
  28. package/dist/dev/cloudflare/object.js.map +1 -0
  29. package/dist/dev/cloudflare/types.d.ts +100 -0
  30. package/dist/dev/cloudflare/wire.js +284 -0
  31. package/dist/dev/cloudflare/wire.js.map +1 -0
  32. package/dist/dev/cloudflare.d.ts +4 -0
  33. package/dist/dev/cloudflare.js +3 -0
  34. package/dist/dev/errors.d.ts +25 -0
  35. package/dist/dev/errors.js +24 -0
  36. package/dist/dev/errors.js.map +1 -0
  37. package/dist/dev/hooks.d.ts +110 -0
  38. package/dist/dev/hooks.js +162 -0
  39. package/dist/dev/hooks.js.map +1 -0
  40. package/dist/dev/index.d.ts +4 -0
  41. package/dist/dev/index.js +3 -0
  42. package/dist/dev/memory.d.ts +33 -0
  43. package/dist/dev/memory.js +255 -0
  44. package/dist/dev/memory.js.map +1 -0
  45. package/dist/dev/protocol.d.ts +211 -0
  46. package/dist/dev/protocol.js +400 -0
  47. package/dist/dev/protocol.js.map +1 -0
  48. package/dist/dev/redis/client.d.ts +139 -0
  49. package/dist/dev/redis/client.js +146 -0
  50. package/dist/dev/redis/client.js.map +1 -0
  51. package/dist/dev/redis/scripts.js +275 -0
  52. package/dist/dev/redis/scripts.js.map +1 -0
  53. package/dist/dev/redis/topics.d.ts +8 -0
  54. package/dist/dev/redis/topics.js +377 -0
  55. package/dist/dev/redis/topics.js.map +1 -0
  56. package/dist/dev/redis.d.ts +68 -0
  57. package/dist/dev/redis.js +422 -0
  58. package/dist/dev/redis.js.map +1 -0
  59. package/dist/dev/server/http.d.ts +48 -0
  60. package/dist/dev/server/http.js +286 -0
  61. package/dist/dev/server/http.js.map +1 -0
  62. package/dist/dev/server/hub.d.ts +114 -0
  63. package/dist/dev/server/hub.js +827 -0
  64. package/dist/dev/server/hub.js.map +1 -0
  65. package/dist/dev/server/index.d.ts +47 -0
  66. package/dist/dev/server/index.js +41 -0
  67. package/dist/dev/server/index.js.map +1 -0
  68. package/dist/dev/server/limits.d.ts +54 -0
  69. package/dist/dev/server/limits.js +85 -0
  70. package/dist/dev/server/limits.js.map +1 -0
  71. package/dist/dev/server/rules.d.ts +43 -0
  72. package/dist/dev/server/rules.js +72 -0
  73. package/dist/dev/server/rules.js.map +1 -0
  74. package/dist/dev/testing.d.ts +115 -0
  75. package/dist/dev/testing.js +427 -0
  76. package/dist/dev/testing.js.map +1 -0
  77. package/dist/prod/client/core.js +2 -0
  78. package/dist/prod/client/core.js.map +1 -0
  79. package/dist/prod/client/index.js +1 -0
  80. package/dist/prod/client/presence.js +2 -0
  81. package/dist/prod/client/presence.js.map +1 -0
  82. package/dist/prod/client/sse.js +5 -0
  83. package/dist/prod/client/sse.js.map +1 -0
  84. package/dist/prod/client/util.js +2 -0
  85. package/dist/prod/client/util.js.map +1 -0
  86. package/dist/prod/client/ws.js +2 -0
  87. package/dist/prod/client/ws.js.map +1 -0
  88. package/dist/prod/cloudflare/adapter.js +3 -0
  89. package/dist/prod/cloudflare/adapter.js.map +1 -0
  90. package/dist/prod/cloudflare/core.js +2 -0
  91. package/dist/prod/cloudflare/core.js.map +1 -0
  92. package/dist/prod/cloudflare/link.js +2 -0
  93. package/dist/prod/cloudflare/link.js.map +1 -0
  94. package/dist/prod/cloudflare/object.js +2 -0
  95. package/dist/prod/cloudflare/object.js.map +1 -0
  96. package/dist/prod/cloudflare/wire.js +2 -0
  97. package/dist/prod/cloudflare/wire.js.map +1 -0
  98. package/dist/prod/cloudflare.js +1 -0
  99. package/dist/prod/errors.js +2 -0
  100. package/dist/prod/errors.js.map +1 -0
  101. package/dist/prod/hooks.js +2 -0
  102. package/dist/prod/hooks.js.map +1 -0
  103. package/dist/prod/index.js +1 -0
  104. package/dist/prod/memory.js +2 -0
  105. package/dist/prod/memory.js.map +1 -0
  106. package/dist/prod/protocol.js +2 -0
  107. package/dist/prod/protocol.js.map +1 -0
  108. package/dist/prod/redis/client.js +2 -0
  109. package/dist/prod/redis/client.js.map +1 -0
  110. package/dist/prod/redis/scripts.js +157 -0
  111. package/dist/prod/redis/scripts.js.map +1 -0
  112. package/dist/prod/redis/topics.js +2 -0
  113. package/dist/prod/redis/topics.js.map +1 -0
  114. package/dist/prod/redis.js +2 -0
  115. package/dist/prod/redis.js.map +1 -0
  116. package/dist/prod/server/http.js +2 -0
  117. package/dist/prod/server/http.js.map +1 -0
  118. package/dist/prod/server/hub.js +2 -0
  119. package/dist/prod/server/hub.js.map +1 -0
  120. package/dist/prod/server/index.js +2 -0
  121. package/dist/prod/server/index.js.map +1 -0
  122. package/dist/prod/server/limits.js +2 -0
  123. package/dist/prod/server/limits.js.map +1 -0
  124. package/dist/prod/server/rules.js +2 -0
  125. package/dist/prod/server/rules.js.map +1 -0
  126. package/dist/prod/testing.js +2 -0
  127. package/dist/prod/testing.js.map +1 -0
  128. package/package.json +105 -4
@@ -0,0 +1,701 @@
1
+ import { RealtimeError } from "../errors.js";
2
+ import { historyKey, seqOfKey } from "./wire.js";
3
+ //#region src/cloudflare/core.ts
4
+ const META = "meta";
5
+ const MESSAGES = "h:";
6
+ const MEMBERS = "p:";
7
+ const KEYS = "k:";
8
+ const KEY_INDEX = "x:";
9
+ /** `x:` + 16 digits + `:`; what follows is the idempotency key. */
10
+ const KEY_INDEX_PREFIX = 19;
11
+ /** The platform accepts at most 128 keys in one `delete`, `put` or `get`. */
12
+ const BATCH = 128;
13
+ /** The most messages one operation deletes; a larger backlog is finished by the next operation or alarm. */
14
+ const TRIM_MAX = 1024;
15
+ /** Waiting this long after the first idempotency key expires lets one alarm sweep many. */
16
+ const SWEEP_SLACK_MS = 5e3;
17
+ /** The same for retained messages. */
18
+ const HISTORY_SLACK_MS = 1e3;
19
+ const PAGE_MESSAGES = 512;
20
+ const PAGE_BYTES = 4194304;
21
+ const REPLAY_MESSAGES = 1e4;
22
+ const REPLAY_BYTES = 8388608;
23
+ function pad(value) {
24
+ return String(value).padStart(16, "0");
25
+ }
26
+ function chunks(items) {
27
+ const out = [];
28
+ for (let at = 0; at < items.length; at += BATCH) out.push(items.slice(at, at + BATCH));
29
+ return out;
30
+ }
31
+ function sizeOf(message) {
32
+ return message.data.length + message.event.length + 96;
33
+ }
34
+ /**
35
+ * The single writer of one channel.
36
+ * @internal
37
+ */
38
+ var ChannelCore = class {
39
+ #storage;
40
+ #now;
41
+ #maxMembers;
42
+ #sockets = /* @__PURE__ */ new Set();
43
+ #tail = Promise.resolve();
44
+ #meta;
45
+ #members;
46
+ /** The alarm time the storage holds: `undefined` unknown, `null` none. */
47
+ #alarmAt;
48
+ constructor(options) {
49
+ this.#storage = options.storage;
50
+ this.#now = options.now ?? Date.now;
51
+ this.#maxMembers = options.maxMembers ?? 1e4;
52
+ }
53
+ /** Registers sockets that were open before this instance was created (the runtime kept them while the object slept). */
54
+ adopt(sockets) {
55
+ for (const socket of sockets) this.#sockets.add(socket);
56
+ }
57
+ /** Forgets a socket that closed. */
58
+ detach(socket) {
59
+ this.#sockets.delete(socket);
60
+ }
61
+ /** The number of sockets attached. */
62
+ get sockets() {
63
+ return this.#sockets.size;
64
+ }
65
+ /** Runs `task` after every earlier task finished. A failure drops the caches, so the next task reads the storage afresh. */
66
+ #run(task) {
67
+ const result = this.#tail.then(task).catch((error) => {
68
+ if (!(error instanceof RealtimeError)) {
69
+ this.#meta = void 0;
70
+ this.#members = void 0;
71
+ this.#alarmAt = void 0;
72
+ }
73
+ throw error;
74
+ });
75
+ this.#tail = result.then(() => void 0, () => void 0);
76
+ return result;
77
+ }
78
+ async #metaFor(channel) {
79
+ const meta = await this.#storedMeta();
80
+ if (meta !== void 0) {
81
+ if (meta.channel !== channel) throw new RealtimeError("conflict", "the object belongs to another channel");
82
+ return meta;
83
+ }
84
+ const fresh = {
85
+ v: 1,
86
+ channel,
87
+ head: 0,
88
+ oldest: 0,
89
+ oldestAt: 0,
90
+ keep: 0,
91
+ ttlMs: 0,
92
+ keysDue: 0,
93
+ presenceDue: 0,
94
+ graceMs: 0,
95
+ idleMs: 0,
96
+ touched: this.#now()
97
+ };
98
+ this.#meta = fresh;
99
+ return fresh;
100
+ }
101
+ async #storedMeta() {
102
+ if (this.#meta === void 0) this.#meta = await this.#storage.get(META);
103
+ return this.#meta;
104
+ }
105
+ async #loadMembers() {
106
+ if (this.#members === void 0) {
107
+ const rows = await this.#storage.list({ prefix: MEMBERS });
108
+ const members = /* @__PURE__ */ new Map();
109
+ for (const [key, record] of rows) members.set(key.slice(2), {
110
+ member: record.member,
111
+ expires: record.expires,
112
+ stored: record.expires
113
+ });
114
+ this.#members = members;
115
+ }
116
+ return this.#members;
117
+ }
118
+ /** Writes `puts` and `deletes` and the metadata in one transaction. */
119
+ async #commit(meta, puts, deletes) {
120
+ await this.#storage.transaction(async (transaction) => {
121
+ await transaction.put({
122
+ ...puts,
123
+ [META]: meta
124
+ });
125
+ for (const batch of chunks(deletes)) await transaction.delete(batch);
126
+ });
127
+ this.#meta = meta;
128
+ }
129
+ #send(socket, text) {
130
+ try {
131
+ socket.send(text);
132
+ return true;
133
+ } catch {
134
+ this.#sockets.delete(socket);
135
+ try {
136
+ socket.close(1011, "write failed");
137
+ } catch {}
138
+ return false;
139
+ }
140
+ }
141
+ #broadcast(frame) {
142
+ if (this.#sockets.size === 0) return;
143
+ const text = JSON.stringify(frame);
144
+ for (const socket of [...this.#sockets]) this.#send(socket, text);
145
+ }
146
+ #emit(op, member) {
147
+ this.#broadcast({
148
+ t: "pres",
149
+ op,
150
+ member
151
+ });
152
+ }
153
+ /**
154
+ * Assigns the next seq, retains the message, remembers the key and sends the message to every attached socket.
155
+ *
156
+ * @param channel The channel name.
157
+ * @param entry The message.
158
+ * @param options Retention and the idempotency key.
159
+ * @param idleMs Forget the channel after this long without activity (0 keeps it).
160
+ * @returns The seq and whether the key was already accepted.
161
+ */
162
+ publish(channel, entry, options, idleMs = 0) {
163
+ return this.#run(async () => {
164
+ const meta = await this.#metaFor(channel);
165
+ const now = this.#now();
166
+ if (options.key !== void 0) {
167
+ const known = await this.#storage.get(KEYS + options.key);
168
+ if (known !== void 0 && known.expires > now) return {
169
+ seq: known.seq,
170
+ duplicate: true
171
+ };
172
+ }
173
+ const seq = meta.head + 1;
174
+ const message = {
175
+ seq,
176
+ event: entry.event,
177
+ data: entry.data,
178
+ at: now,
179
+ ...entry.publisher === void 0 ? {} : { publisher: entry.publisher },
180
+ ...entry.user === void 0 ? {} : { user: entry.user }
181
+ };
182
+ const plan = await this.#planTrim(meta, now, options.history, options.historyTtlMs, 1);
183
+ const next = {
184
+ ...meta,
185
+ head: seq,
186
+ keep: options.history,
187
+ ttlMs: options.historyTtlMs,
188
+ idleMs,
189
+ touched: now,
190
+ oldest: plan.oldest,
191
+ oldestAt: plan.oldestAt
192
+ };
193
+ const puts = {};
194
+ if (options.history > 0) {
195
+ puts[historyKey(seq)] = message;
196
+ if (next.oldest === 0) {
197
+ next.oldest = seq;
198
+ next.oldestAt = now;
199
+ }
200
+ }
201
+ if (options.key !== void 0) {
202
+ const expires = now + options.idempotencyTtlMs;
203
+ puts[KEYS + options.key] = {
204
+ seq,
205
+ expires
206
+ };
207
+ puts[`${KEY_INDEX}${pad(expires)}:${options.key}`] = 1;
208
+ const due = expires + SWEEP_SLACK_MS;
209
+ next.keysDue = next.keysDue === 0 ? due : Math.min(next.keysDue, due);
210
+ }
211
+ await this.#commit(next, puts, plan.deletes);
212
+ this.#broadcast({
213
+ t: "msg",
214
+ m: message
215
+ });
216
+ await this.#schedule(next);
217
+ return {
218
+ seq,
219
+ duplicate: false
220
+ };
221
+ });
222
+ }
223
+ /**
224
+ * Works out which retained messages to delete so that, after `extra` more messages are stored, at most `keep` remain and
225
+ * none is older than `ttlMs`. It reads at most a few pages and deletes at most {@link TRIM_MAX} messages.
226
+ */
227
+ async #planTrim(meta, now, keep, ttlMs, extra) {
228
+ const deletes = [];
229
+ let oldest = meta.oldest;
230
+ let oldestAt = meta.oldestAt;
231
+ if (oldest === 0) return {
232
+ deletes,
233
+ oldest: 0,
234
+ oldestAt: 0
235
+ };
236
+ const target = meta.head + 1 + extra - keep;
237
+ if (oldest < target) {
238
+ const stop = Math.min(target, meta.head + 1, oldest + TRIM_MAX);
239
+ for (let seq = oldest; seq < stop; seq += 1) deletes.push(historyKey(seq));
240
+ oldest = stop;
241
+ if (oldest > meta.head) return {
242
+ deletes,
243
+ oldest: 0,
244
+ oldestAt: 0
245
+ };
246
+ const first = await this.#firstFrom(oldest);
247
+ if (first === void 0) return {
248
+ deletes,
249
+ oldest: 0,
250
+ oldestAt: 0
251
+ };
252
+ oldest = first.seq;
253
+ oldestAt = first.at;
254
+ }
255
+ const cutoff = now - ttlMs;
256
+ while (oldestAt < cutoff && deletes.length < TRIM_MAX) {
257
+ const page = await this.#storage.list({
258
+ prefix: MESSAGES,
259
+ start: historyKey(oldest),
260
+ limit: BATCH
261
+ });
262
+ let survivor;
263
+ for (const message of page.values()) {
264
+ if (message.at >= cutoff || deletes.length >= TRIM_MAX) {
265
+ survivor = message;
266
+ break;
267
+ }
268
+ deletes.push(historyKey(message.seq));
269
+ }
270
+ if (survivor !== void 0) {
271
+ oldest = survivor.seq;
272
+ oldestAt = survivor.at;
273
+ break;
274
+ }
275
+ if (page.size < BATCH) return {
276
+ deletes,
277
+ oldest: 0,
278
+ oldestAt: 0
279
+ };
280
+ const last = deletes.length === 0 ? void 0 : deletes[deletes.length - 1];
281
+ if (last === void 0) break;
282
+ oldest = seqOfKey(last) + 1;
283
+ const next = await this.#firstFrom(oldest);
284
+ if (next === void 0) return {
285
+ deletes,
286
+ oldest: 0,
287
+ oldestAt: 0
288
+ };
289
+ oldest = next.seq;
290
+ oldestAt = next.at;
291
+ }
292
+ return {
293
+ deletes,
294
+ oldest,
295
+ oldestAt
296
+ };
297
+ }
298
+ async #firstFrom(seq) {
299
+ const page = await this.#storage.list({
300
+ prefix: MESSAGES,
301
+ start: historyKey(seq),
302
+ limit: 1
303
+ });
304
+ for (const message of page.values()) return message;
305
+ }
306
+ /** Whether retained messages are past their time or above the count of the last publish. */
307
+ #retentionDue(meta, now) {
308
+ if (meta.oldest === 0) return false;
309
+ return meta.head - meta.oldest + 1 > meta.keep || meta.oldestAt + meta.ttlMs < now;
310
+ }
311
+ async #retain(meta, now) {
312
+ if (!this.#retentionDue(meta, now)) return false;
313
+ const plan = await this.#planTrim(meta, now, meta.keep, meta.ttlMs, 0);
314
+ if (plan.deletes.length === 0 && plan.oldest === meta.oldest) return false;
315
+ await this.#commit({
316
+ ...meta,
317
+ oldest: plan.oldest,
318
+ oldestAt: plan.oldestAt
319
+ }, {}, plan.deletes);
320
+ return true;
321
+ }
322
+ /**
323
+ * Reads retained messages after a seq.
324
+ *
325
+ * @param channel The channel name.
326
+ * @param after The last seq the caller holds.
327
+ * @param limit The most messages to return (0 asks for the head only).
328
+ * @returns A page; `more` says retained messages remain after it.
329
+ */
330
+ history(channel, after, limit) {
331
+ return this.#run(async () => {
332
+ let meta = await this.#metaFor(channel);
333
+ if (await this.#retain(meta, this.#now())) meta = this.#meta ?? meta;
334
+ if (limit <= 0 || after >= meta.head || meta.oldest === 0) return {
335
+ messages: [],
336
+ head: meta.head,
337
+ oldest: meta.oldest,
338
+ more: false
339
+ };
340
+ const cap = Math.min(limit, PAGE_MESSAGES);
341
+ const rows = await this.#storage.list({
342
+ prefix: MESSAGES,
343
+ start: historyKey(Math.max(after + 1, meta.oldest)),
344
+ limit: cap
345
+ });
346
+ const messages = [];
347
+ let bytes = 0;
348
+ for (const message of rows.values()) {
349
+ if (messages.length > 0 && bytes + sizeOf(message) > PAGE_BYTES) break;
350
+ messages.push(message);
351
+ bytes += sizeOf(message);
352
+ }
353
+ const last = messages[messages.length - 1];
354
+ return {
355
+ messages,
356
+ head: meta.head,
357
+ oldest: meta.oldest,
358
+ more: last !== void 0 && last.seq < meta.head
359
+ };
360
+ });
361
+ }
362
+ /**
363
+ * Starts sending a socket the channel's messages and presence events: the head, the live members, the messages after
364
+ * `after` that are retained, then every later event. Nothing can interleave, because it runs in the queue.
365
+ *
366
+ * @param socket The server end of the subscribe socket.
367
+ * @param channel The channel name.
368
+ * @param after The last seq the subscriber holds, when it is resuming.
369
+ */
370
+ attach(socket, channel, after) {
371
+ return this.#run(async () => {
372
+ let meta = await this.#metaFor(channel);
373
+ const now = this.#now();
374
+ if (await this.#retain(meta, now)) meta = this.#meta ?? meta;
375
+ const members = await this.#loadMembers();
376
+ const live = [];
377
+ for (const state of members.values()) if (state.expires > now) live.push(state.member);
378
+ const frames = [JSON.stringify({
379
+ t: "ready",
380
+ head: meta.head,
381
+ oldest: meta.oldest
382
+ }), JSON.stringify({
383
+ t: "snap",
384
+ members: live
385
+ })];
386
+ let truncated = false;
387
+ if (after !== void 0 && after < meta.head && meta.oldest > 0) {
388
+ let cursor = Math.max(after, meta.oldest - 1);
389
+ let count = 0;
390
+ let bytes = 0;
391
+ replay: while (cursor < meta.head) {
392
+ const rows = await this.#storage.list({
393
+ prefix: MESSAGES,
394
+ start: historyKey(cursor + 1),
395
+ limit: BATCH
396
+ });
397
+ if (rows.size === 0) break;
398
+ for (const message of rows.values()) {
399
+ frames.push(JSON.stringify({
400
+ t: "msg",
401
+ m: message
402
+ }));
403
+ cursor = message.seq;
404
+ count += 1;
405
+ bytes += sizeOf(message);
406
+ if (count >= REPLAY_MESSAGES || bytes >= REPLAY_BYTES) {
407
+ truncated = cursor < meta.head;
408
+ break replay;
409
+ }
410
+ }
411
+ if (rows.size < BATCH) break;
412
+ }
413
+ }
414
+ if (truncated) frames.push(JSON.stringify({ t: "more" }));
415
+ for (const frame of frames) try {
416
+ socket.send(frame);
417
+ } catch {
418
+ return;
419
+ }
420
+ this.#sockets.add(socket);
421
+ });
422
+ }
423
+ /**
424
+ * Adds or updates a member. An older version is ignored; the first sighting is a `join`, a newer version a `set`.
425
+ *
426
+ * @param channel The channel name.
427
+ * @param member The member.
428
+ * @param ttlMs Milliseconds until it expires without a touch.
429
+ * @param graceMs Milliseconds after expiry that the alarm waits before it reaps (so the hub's own reap comes first).
430
+ */
431
+ presenceSet(channel, member, ttlMs, graceMs) {
432
+ return this.#run(async () => {
433
+ const meta = await this.#metaFor(channel);
434
+ const members = await this.#loadMembers();
435
+ const known = members.get(member.id);
436
+ if (known !== void 0 && known.member.v > member.v) return;
437
+ if (known === void 0 && members.size >= this.#maxMembers) throw new RealtimeError("limit", "the channel has too many presence members");
438
+ const now = this.#now();
439
+ const expires = now + ttlMs;
440
+ const changed = known === void 0 || known.member.v < member.v;
441
+ const due = expires + graceMs;
442
+ const next = {
443
+ ...meta,
444
+ graceMs,
445
+ touched: now,
446
+ presenceDue: meta.presenceDue === 0 ? due : Math.min(meta.presenceDue, due)
447
+ };
448
+ const persist = changed || known === void 0 || expires - known.stored >= ttlMs / 2;
449
+ if (persist || next.presenceDue !== meta.presenceDue || next.graceMs !== meta.graceMs) await this.#commit(next, persist ? { [MEMBERS + member.id]: {
450
+ member,
451
+ expires
452
+ } } : {}, []);
453
+ members.set(member.id, {
454
+ member,
455
+ expires,
456
+ stored: persist ? expires : known?.stored ?? expires
457
+ });
458
+ if (known === void 0) this.#emit("join", member);
459
+ else if (known.member.v < member.v) this.#emit("set", member);
460
+ await this.#schedule(next);
461
+ });
462
+ }
463
+ /**
464
+ * Removes a member.
465
+ *
466
+ * @param channel The channel name.
467
+ * @param id The member id.
468
+ * @returns True when the member existed (and a `leave` was sent); false otherwise.
469
+ */
470
+ presenceRemove(channel, id) {
471
+ return this.#run(async () => {
472
+ const meta = await this.#metaFor(channel);
473
+ const members = await this.#loadMembers();
474
+ const known = members.get(id);
475
+ if (known === void 0) return false;
476
+ const next = members.size === 1 ? {
477
+ ...meta,
478
+ presenceDue: 0
479
+ } : meta;
480
+ await this.#commit(next, {}, [MEMBERS + id]);
481
+ members.delete(id);
482
+ this.#emit("leave", known.member);
483
+ await this.#schedule(next);
484
+ return true;
485
+ });
486
+ }
487
+ /**
488
+ * The members that have not expired.
489
+ *
490
+ * @param channel The channel name.
491
+ * @returns The members.
492
+ */
493
+ presenceList(channel) {
494
+ return this.#run(async () => {
495
+ await this.#metaFor(channel);
496
+ const members = await this.#loadMembers();
497
+ const now = this.#now();
498
+ const out = [];
499
+ for (const state of members.values()) if (state.expires > now) out.push(state.member);
500
+ return out;
501
+ });
502
+ }
503
+ /**
504
+ * Extends the expiry of members. The storage is written only when the new expiry is at least half a time to live beyond
505
+ * the stored one, so a heartbeat every few seconds costs a write every few beats; a restart in between loses at most
506
+ * that half, which a heartbeat repairs long before the member would expire.
507
+ *
508
+ * @param channel The channel name.
509
+ * @param ids The member ids.
510
+ * @param ttlMs Milliseconds until they expire without another touch.
511
+ * @returns The ids that no longer exist.
512
+ */
513
+ presenceTouch(channel, ids, ttlMs) {
514
+ return this.#run(async () => {
515
+ const meta = await this.#metaFor(channel);
516
+ const members = await this.#loadMembers();
517
+ const expires = this.#now() + ttlMs;
518
+ const missing = [];
519
+ const puts = {};
520
+ const written = [];
521
+ let touched = 0;
522
+ for (const id of ids) {
523
+ const state = members.get(id);
524
+ if (state === void 0) {
525
+ missing.push(id);
526
+ continue;
527
+ }
528
+ state.expires = expires;
529
+ touched += 1;
530
+ if (state.expires - state.stored >= ttlMs / 2) {
531
+ puts[MEMBERS + id] = {
532
+ member: state.member,
533
+ expires: state.expires
534
+ };
535
+ written.push(state);
536
+ }
537
+ }
538
+ const keys = Object.keys(puts);
539
+ for (const batch of chunks(keys)) {
540
+ const part = {};
541
+ for (const key of batch) part[key] = puts[key];
542
+ await this.#storage.put(part);
543
+ }
544
+ for (const state of written) state.stored = state.expires;
545
+ const due = expires + meta.graceMs;
546
+ if (touched > 0 && (meta.presenceDue === 0 || due < meta.presenceDue)) {
547
+ const next = {
548
+ ...meta,
549
+ presenceDue: due
550
+ };
551
+ await this.#commit(next, {}, []);
552
+ await this.#schedule(next);
553
+ }
554
+ return missing;
555
+ });
556
+ }
557
+ /**
558
+ * Removes every expired member and sends one `leave` for each.
559
+ *
560
+ * @param channel The channel name.
561
+ * @returns The members removed.
562
+ */
563
+ presenceReap(channel) {
564
+ return this.#run(async () => {
565
+ const meta = await this.#metaFor(channel);
566
+ return this.#reap(meta, this.#now());
567
+ });
568
+ }
569
+ async #reap(meta, now) {
570
+ const members = await this.#loadMembers();
571
+ const gone = [];
572
+ for (const state of members.values()) if (state.expires <= now) gone.push(state);
573
+ if (gone.length === 0) return [];
574
+ let due = 0;
575
+ for (const state of members.values()) if (state.expires > now) {
576
+ const at = state.expires + meta.graceMs;
577
+ due = due === 0 ? at : Math.min(due, at);
578
+ }
579
+ const next = {
580
+ ...meta,
581
+ presenceDue: due
582
+ };
583
+ await this.#commit(next, {}, gone.map((state) => MEMBERS + state.member.id));
584
+ for (const state of gone) {
585
+ members.delete(state.member.id);
586
+ this.#emit("leave", state.member);
587
+ }
588
+ return gone.map((state) => state.member);
589
+ }
590
+ /** The time the next alarm is due, `undefined` when nothing is pending. */
591
+ #nextDue(meta, now) {
592
+ let due = 0;
593
+ const consider = (at) => {
594
+ if (at > 0 && (due === 0 || at < due)) due = at;
595
+ };
596
+ consider(meta.presenceDue);
597
+ consider(meta.keysDue);
598
+ if (meta.oldest > 0) consider(meta.head - meta.oldest + 1 > meta.keep ? now : meta.oldestAt + meta.ttlMs + HISTORY_SLACK_MS);
599
+ if (meta.idleMs > 0) {
600
+ const idleAt = meta.touched + meta.idleMs;
601
+ consider(this.#sockets.size > 0 && idleAt <= now ? now + meta.idleMs : idleAt);
602
+ }
603
+ return due === 0 ? void 0 : Math.max(due, now);
604
+ }
605
+ /** Makes the storage's alarm fire no later than the next thing that is due. An earlier alarm is left alone: it wakes, finds nothing, and sets the right time. */
606
+ async #schedule(meta) {
607
+ const now = this.#now();
608
+ const due = this.#nextDue(meta, now);
609
+ if (this.#alarmAt === void 0) this.#alarmAt = await this.#storage.getAlarm();
610
+ if (due === void 0) {
611
+ if (this.#alarmAt !== null) {
612
+ await this.#storage.deleteAlarm();
613
+ this.#alarmAt = null;
614
+ }
615
+ return;
616
+ }
617
+ if (this.#alarmAt === null || this.#alarmAt > due) {
618
+ await this.#storage.setAlarm(due);
619
+ this.#alarmAt = due;
620
+ }
621
+ }
622
+ /** Deletes idempotency keys that expired, in batches. */
623
+ async #sweepKeys(meta, now) {
624
+ for (let pass = 0; pass < 32; pass += 1) {
625
+ const rows = await this.#storage.list({
626
+ prefix: KEY_INDEX,
627
+ end: KEY_INDEX + pad(now + 1),
628
+ limit: BATCH
629
+ });
630
+ if (rows.size === 0) break;
631
+ const indexKeys = [...rows.keys()];
632
+ const names = indexKeys.map((key) => key.slice(KEY_INDEX_PREFIX));
633
+ const records = await this.#storage.get(names.map((name) => KEYS + name));
634
+ const deletes = [...indexKeys];
635
+ for (const name of names) {
636
+ const record = records.get(KEYS + name);
637
+ if (record === void 0 || record.expires <= now) deletes.push(KEYS + name);
638
+ }
639
+ await this.#storage.transaction(async (transaction) => {
640
+ for (const batch of chunks(deletes)) await transaction.delete(batch);
641
+ });
642
+ if (rows.size < BATCH) break;
643
+ }
644
+ const first = await this.#storage.list({
645
+ prefix: KEY_INDEX,
646
+ limit: 1
647
+ });
648
+ let keysDue = 0;
649
+ for (const key of first.keys()) keysDue = Number(key.slice(2, 18)) + SWEEP_SLACK_MS;
650
+ return {
651
+ ...meta,
652
+ keysDue
653
+ };
654
+ }
655
+ /**
656
+ * The alarm handler: reaps expired presence members (one `leave` each), sweeps expired idempotency keys, trims retained
657
+ * messages that expired, forgets a channel that has been idle for its `idleMs`, and sets the next alarm. It is safe to run
658
+ * more often than needed, and to run again after a failure.
659
+ */
660
+ alarm() {
661
+ return this.#run(async () => {
662
+ this.#alarmAt = null;
663
+ let meta = await this.#storedMeta();
664
+ if (meta === void 0) return;
665
+ const now = this.#now();
666
+ if (meta.presenceDue > 0 && meta.presenceDue <= now) {
667
+ await this.#reap(meta, now);
668
+ meta = this.#meta ?? meta;
669
+ let due = 0;
670
+ for (const state of (await this.#loadMembers()).values()) {
671
+ const at = state.expires + meta.graceMs;
672
+ due = due === 0 ? at : Math.min(due, at);
673
+ }
674
+ if (due !== meta.presenceDue) {
675
+ meta = {
676
+ ...meta,
677
+ presenceDue: due
678
+ };
679
+ await this.#commit(meta, {}, []);
680
+ }
681
+ }
682
+ if (meta.keysDue > 0 && meta.keysDue <= now) {
683
+ meta = await this.#sweepKeys(meta, now);
684
+ await this.#commit(meta, {}, []);
685
+ }
686
+ if (await this.#retain(meta, now)) meta = this.#meta ?? meta;
687
+ if (meta.idleMs > 0 && meta.touched + meta.idleMs <= now && meta.oldest === 0 && meta.presenceDue === 0 && meta.keysDue === 0 && this.#sockets.size === 0) {
688
+ await this.#storage.deleteAll();
689
+ this.#meta = void 0;
690
+ this.#members = void 0;
691
+ this.#alarmAt = null;
692
+ return;
693
+ }
694
+ await this.#schedule(meta);
695
+ });
696
+ }
697
+ };
698
+ //#endregion
699
+ export { ChannelCore };
700
+
701
+ //# sourceMappingURL=core.js.map