@evolu/common 8.7.0 → 8.9.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 (302) hide show
  1. package/dist/src/Array.d.ts +21 -21
  2. package/dist/src/Array.js +5 -5
  3. package/dist/src/Assert.d.ts +498 -141
  4. package/dist/src/Assert.d.ts.map +1 -1
  5. package/dist/src/Assert.js +648 -99
  6. package/dist/src/{Buffer.d.ts → Binary.d.ts} +85 -12
  7. package/dist/src/Binary.d.ts.map +1 -0
  8. package/dist/src/{Buffer.js → Binary.js} +206 -24
  9. package/dist/src/Console.d.ts +2 -10
  10. package/dist/src/Console.d.ts.map +1 -1
  11. package/dist/src/Console.js +6 -12
  12. package/dist/src/Crypto.d.ts +5 -5
  13. package/dist/src/Crypto.js +1 -1
  14. package/dist/src/Eq.d.ts +35 -40
  15. package/dist/src/Eq.d.ts.map +1 -1
  16. package/dist/src/Eq.js +51 -87
  17. package/dist/src/Function.d.ts +9 -14
  18. package/dist/src/Function.d.ts.map +1 -1
  19. package/dist/src/Function.js +7 -9
  20. package/dist/src/Http.d.ts +6 -7
  21. package/dist/src/Http.d.ts.map +1 -1
  22. package/dist/src/LeakDetector.d.ts.map +1 -1
  23. package/dist/src/LeakDetector.js +1 -0
  24. package/dist/src/LockManager.d.ts +1 -1
  25. package/dist/src/LockManager.js +1 -1
  26. package/dist/src/Lookup.d.ts +3 -2
  27. package/dist/src/Lookup.d.ts.map +1 -1
  28. package/dist/src/Lookup.js +4 -3
  29. package/dist/src/Number.d.ts +1 -1
  30. package/dist/src/Number.d.ts.map +1 -1
  31. package/dist/src/Number.js +1 -1
  32. package/dist/src/Object.d.ts +4 -4
  33. package/dist/src/Object.d.ts.map +1 -1
  34. package/dist/src/Object.js +5 -4
  35. package/dist/src/Option.d.ts +1 -2
  36. package/dist/src/Option.d.ts.map +1 -1
  37. package/dist/src/Option.js +1 -2
  38. package/dist/src/Platform.js +2 -0
  39. package/dist/src/Polyfills.d.ts +6 -0
  40. package/dist/src/Polyfills.d.ts.map +1 -1
  41. package/dist/src/Polyfills.js +10 -1
  42. package/dist/src/Random.d.ts +1 -1
  43. package/dist/src/Result.d.ts +49 -54
  44. package/dist/src/Result.d.ts.map +1 -1
  45. package/dist/src/Result.js +17 -22
  46. package/dist/src/Set.d.ts +6 -6
  47. package/dist/src/Set.js +2 -2
  48. package/dist/src/Sqlite.js +1 -1
  49. package/dist/src/String.js +3 -2
  50. package/dist/src/Task.d.ts +32 -32
  51. package/dist/src/Task.d.ts.map +1 -1
  52. package/dist/src/Task.js +20 -22
  53. package/dist/src/Test.d.ts +31 -3
  54. package/dist/src/Test.d.ts.map +1 -1
  55. package/dist/src/Test.js +46 -3
  56. package/dist/src/Time.d.ts +3 -11
  57. package/dist/src/Time.d.ts.map +1 -1
  58. package/dist/src/Time.js +10 -18
  59. package/dist/src/Type.d.ts +87 -85
  60. package/dist/src/Type.d.ts.map +1 -1
  61. package/dist/src/Type.js +70 -63
  62. package/dist/src/Types.d.ts +12 -12
  63. package/dist/src/Types.js +1 -1
  64. package/dist/src/WebSocket.d.ts +4 -7
  65. package/dist/src/WebSocket.d.ts.map +1 -1
  66. package/dist/src/Worker.d.ts +1 -1
  67. package/dist/src/Worker.js +7 -7
  68. package/dist/src/index.d.ts +1 -1
  69. package/dist/src/index.js +1 -1
  70. package/dist/src/intl/_en.d.ts.map +1 -1
  71. package/dist/src/intl/_en.js +1 -1
  72. package/dist/src/intl/ar.d.ts.map +1 -1
  73. package/dist/src/intl/ar.js +1 -1
  74. package/dist/src/intl/bn.d.ts.map +1 -1
  75. package/dist/src/intl/bn.js +1 -1
  76. package/dist/src/intl/ca.d.ts.map +1 -1
  77. package/dist/src/intl/ca.js +1 -1
  78. package/dist/src/intl/cs.d.ts.map +1 -1
  79. package/dist/src/intl/cs.js +1 -1
  80. package/dist/src/intl/da.d.ts.map +1 -1
  81. package/dist/src/intl/da.js +1 -1
  82. package/dist/src/intl/de.d.ts.map +1 -1
  83. package/dist/src/intl/de.js +1 -1
  84. package/dist/src/intl/el.d.ts.map +1 -1
  85. package/dist/src/intl/el.js +1 -1
  86. package/dist/src/intl/es.d.ts.map +1 -1
  87. package/dist/src/intl/es.js +1 -1
  88. package/dist/src/intl/fa.d.ts.map +1 -1
  89. package/dist/src/intl/fa.js +1 -1
  90. package/dist/src/intl/fi.d.ts.map +1 -1
  91. package/dist/src/intl/fi.js +1 -1
  92. package/dist/src/intl/fil.d.ts.map +1 -1
  93. package/dist/src/intl/fil.js +1 -1
  94. package/dist/src/intl/fr.d.ts.map +1 -1
  95. package/dist/src/intl/fr.js +1 -1
  96. package/dist/src/intl/he.d.ts.map +1 -1
  97. package/dist/src/intl/he.js +1 -1
  98. package/dist/src/intl/hi.d.ts.map +1 -1
  99. package/dist/src/intl/hi.js +1 -1
  100. package/dist/src/intl/hr.d.ts.map +1 -1
  101. package/dist/src/intl/hr.js +1 -1
  102. package/dist/src/intl/hu.d.ts.map +1 -1
  103. package/dist/src/intl/hu.js +1 -1
  104. package/dist/src/intl/id.d.ts.map +1 -1
  105. package/dist/src/intl/id.js +1 -1
  106. package/dist/src/intl/it.d.ts.map +1 -1
  107. package/dist/src/intl/it.js +1 -1
  108. package/dist/src/intl/ja.d.ts.map +1 -1
  109. package/dist/src/intl/ja.js +1 -1
  110. package/dist/src/intl/ko.d.ts.map +1 -1
  111. package/dist/src/intl/ko.js +1 -1
  112. package/dist/src/intl/ml.d.ts.map +1 -1
  113. package/dist/src/intl/ml.js +1 -1
  114. package/dist/src/intl/mr.d.ts.map +1 -1
  115. package/dist/src/intl/mr.js +1 -1
  116. package/dist/src/intl/ms.d.ts.map +1 -1
  117. package/dist/src/intl/ms.js +1 -1
  118. package/dist/src/intl/nb.d.ts.map +1 -1
  119. package/dist/src/intl/nb.js +1 -1
  120. package/dist/src/intl/nl.d.ts.map +1 -1
  121. package/dist/src/intl/nl.js +1 -1
  122. package/dist/src/intl/pa.d.ts.map +1 -1
  123. package/dist/src/intl/pa.js +1 -1
  124. package/dist/src/intl/pl.d.ts.map +1 -1
  125. package/dist/src/intl/pl.js +1 -1
  126. package/dist/src/intl/pt-BR.d.ts.map +1 -1
  127. package/dist/src/intl/pt-BR.js +1 -1
  128. package/dist/src/intl/pt.d.ts.map +1 -1
  129. package/dist/src/intl/pt.js +1 -1
  130. package/dist/src/intl/ro.d.ts.map +1 -1
  131. package/dist/src/intl/ro.js +1 -1
  132. package/dist/src/intl/sk.d.ts.map +1 -1
  133. package/dist/src/intl/sk.js +1 -1
  134. package/dist/src/intl/sl.d.ts.map +1 -1
  135. package/dist/src/intl/sl.js +1 -1
  136. package/dist/src/intl/sv.d.ts.map +1 -1
  137. package/dist/src/intl/sv.js +1 -1
  138. package/dist/src/intl/sw.d.ts.map +1 -1
  139. package/dist/src/intl/sw.js +1 -1
  140. package/dist/src/intl/ta.d.ts.map +1 -1
  141. package/dist/src/intl/ta.js +1 -1
  142. package/dist/src/intl/te.d.ts.map +1 -1
  143. package/dist/src/intl/te.js +1 -1
  144. package/dist/src/intl/th.d.ts.map +1 -1
  145. package/dist/src/intl/th.js +1 -1
  146. package/dist/src/intl/tr.d.ts.map +1 -1
  147. package/dist/src/intl/tr.js +1 -1
  148. package/dist/src/intl/uk.d.ts.map +1 -1
  149. package/dist/src/intl/uk.js +1 -1
  150. package/dist/src/intl/ur.d.ts.map +1 -1
  151. package/dist/src/intl/ur.js +1 -1
  152. package/dist/src/intl/vi.d.ts.map +1 -1
  153. package/dist/src/intl/vi.js +1 -1
  154. package/dist/src/intl/zh-CN.d.ts.map +1 -1
  155. package/dist/src/intl/zh-CN.js +1 -1
  156. package/dist/src/intl/zh-TW.d.ts.map +1 -1
  157. package/dist/src/intl/zh-TW.js +1 -1
  158. package/dist/src/local-first/Evolu.d.ts +12 -12
  159. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  160. package/dist/src/local-first/Evolu.js +2 -2
  161. package/dist/src/local-first/Protocol.d.ts +14 -76
  162. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  163. package/dist/src/local-first/Protocol.js +29 -206
  164. package/dist/src/local-first/Query.d.ts +11 -11
  165. package/dist/src/local-first/Query.js +6 -6
  166. package/dist/src/local-first/Relay.d.ts +2 -2
  167. package/dist/src/local-first/Schema.d.ts +3 -5
  168. package/dist/src/local-first/Schema.d.ts.map +1 -1
  169. package/dist/src/local-first/Schema.js +2 -4
  170. package/dist/src/local-first/Shared.d.ts.map +1 -1
  171. package/dist/src/local-first/Shared.js +5 -5
  172. package/dist/src/local-first/Storage.js +4 -4
  173. package/dist/src/local-first/Timestamp.d.ts +9 -0
  174. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  175. package/dist/src/local-first/Timestamp.js +13 -10
  176. package/package.json +7 -4
  177. package/src/Array.test.ts +809 -0
  178. package/src/Array.ts +21 -21
  179. package/src/Assert.test.ts +1232 -0
  180. package/src/Assert.ts +1023 -227
  181. package/src/BigInt.test.ts +28 -0
  182. package/src/Binary.test.ts +1128 -0
  183. package/src/{Buffer.ts → Binary.ts} +273 -46
  184. package/src/Brand.test.ts +90 -0
  185. package/src/Cache.test.ts +183 -0
  186. package/src/Callbacks.test.ts +66 -0
  187. package/src/Console.test.ts +809 -0
  188. package/src/Console.ts +9 -17
  189. package/src/Crypto.test.ts +138 -0
  190. package/src/Crypto.ts +5 -5
  191. package/src/Eq.test.ts +333 -0
  192. package/src/Eq.ts +110 -176
  193. package/src/Error.test.ts +118 -0
  194. package/src/Function.test.ts +204 -0
  195. package/src/Function.ts +10 -15
  196. package/src/Http.test.ts +816 -0
  197. package/src/Http.ts +6 -7
  198. package/src/LeakDetector.test.ts +210 -0
  199. package/src/LeakDetector.ts +1 -0
  200. package/src/LockManager.ts +2 -2
  201. package/src/Lookup.test.ts +418 -0
  202. package/src/Lookup.ts +4 -3
  203. package/src/Microtask.test.ts +140 -0
  204. package/src/Number.test.ts +218 -0
  205. package/src/Number.ts +2 -2
  206. package/src/Object.test.ts +293 -0
  207. package/src/Object.ts +5 -4
  208. package/src/Option.test.ts +104 -0
  209. package/src/Option.ts +1 -2
  210. package/src/Order.test.ts +56 -0
  211. package/src/Platform.ts +2 -0
  212. package/src/Polyfills.ts +9 -4
  213. package/src/Random.test.ts +89 -0
  214. package/src/Random.ts +1 -1
  215. package/src/Ref.test.ts +139 -0
  216. package/src/RefCount.test.ts +184 -0
  217. package/src/Relation.test.ts +505 -0
  218. package/src/Resource.test.ts +4623 -0
  219. package/src/Result.test.ts +1829 -0
  220. package/src/Result.ts +49 -54
  221. package/src/Schedule.test.ts +1554 -0
  222. package/src/Set.test.ts +279 -0
  223. package/src/Set.ts +6 -6
  224. package/src/Sqlite.ts +1 -1
  225. package/src/Store.test.ts +288 -0
  226. package/src/String.test.ts +54 -0
  227. package/src/String.ts +3 -3
  228. package/src/Task.test.ts +10820 -0
  229. package/src/Task.ts +38 -43
  230. package/src/Test.test.ts +88 -0
  231. package/src/Test.ts +49 -3
  232. package/src/Time.test.ts +926 -0
  233. package/src/Time.ts +13 -21
  234. package/src/Type.base64.test.ts +88 -0
  235. package/src/Type.test.ts +20465 -0
  236. package/src/Type.ts +114 -111
  237. package/src/Types.test.ts +324 -0
  238. package/src/Types.ts +13 -13
  239. package/src/WebSocket.ts +4 -7
  240. package/src/Worker.test.ts +755 -0
  241. package/src/Worker.ts +13 -18
  242. package/src/index.ts +1 -1
  243. package/src/intl/_en.ts +1 -1
  244. package/src/intl/ar.ts +1 -1
  245. package/src/intl/bn.ts +1 -1
  246. package/src/intl/ca.ts +1 -1
  247. package/src/intl/cs.ts +1 -1
  248. package/src/intl/da.ts +1 -1
  249. package/src/intl/de.ts +1 -1
  250. package/src/intl/el.ts +1 -1
  251. package/src/intl/es.ts +1 -1
  252. package/src/intl/fa.ts +1 -1
  253. package/src/intl/fi.ts +1 -1
  254. package/src/intl/fil.ts +1 -1
  255. package/src/intl/fr.ts +1 -1
  256. package/src/intl/he.ts +1 -1
  257. package/src/intl/hi.ts +1 -1
  258. package/src/intl/hr.ts +1 -1
  259. package/src/intl/hu.ts +1 -1
  260. package/src/intl/id.ts +1 -1
  261. package/src/intl/intl.test.ts +3106 -0
  262. package/src/intl/it.ts +1 -1
  263. package/src/intl/ja.ts +1 -1
  264. package/src/intl/ko.ts +1 -1
  265. package/src/intl/ml.ts +1 -1
  266. package/src/intl/mr.ts +1 -1
  267. package/src/intl/ms.ts +1 -1
  268. package/src/intl/nb.ts +1 -1
  269. package/src/intl/nl.ts +1 -1
  270. package/src/intl/pa.ts +1 -1
  271. package/src/intl/pl.ts +1 -1
  272. package/src/intl/pt-BR.ts +1 -1
  273. package/src/intl/pt.ts +1 -1
  274. package/src/intl/ro.ts +1 -1
  275. package/src/intl/sk.ts +1 -1
  276. package/src/intl/sl.ts +1 -1
  277. package/src/intl/sv.ts +1 -1
  278. package/src/intl/sw.ts +1 -1
  279. package/src/intl/ta.ts +1 -1
  280. package/src/intl/te.ts +1 -1
  281. package/src/intl/th.ts +1 -1
  282. package/src/intl/tr.ts +1 -1
  283. package/src/intl/uk.ts +1 -1
  284. package/src/intl/ur.ts +1 -1
  285. package/src/intl/vi.ts +1 -1
  286. package/src/intl/zh-CN.ts +1 -1
  287. package/src/intl/zh-TW.ts +1 -1
  288. package/src/local-first/Evolu.test.ts +1921 -0
  289. package/src/local-first/Evolu.ts +18 -14
  290. package/src/local-first/Owner.test.ts +83 -0
  291. package/src/local-first/Protocol.test.ts +1037 -0
  292. package/src/local-first/Protocol.ts +58 -261
  293. package/src/local-first/Query.test.ts +349 -0
  294. package/src/local-first/Query.ts +11 -11
  295. package/src/local-first/Relay.ts +2 -2
  296. package/src/local-first/Schema.ts +3 -5
  297. package/src/local-first/Shared.test.ts +1828 -0
  298. package/src/local-first/Shared.ts +9 -8
  299. package/src/local-first/Storage.ts +4 -4
  300. package/src/local-first/Timestamp.test.ts +374 -0
  301. package/src/local-first/Timestamp.ts +22 -10
  302. package/dist/src/Buffer.d.ts.map +0 -1
@@ -0,0 +1,1828 @@
1
+ import { describe, it } from "node:test";
2
+ import { sql as kyselySql } from "kysely";
3
+
4
+ import {
5
+ assertEqual,
6
+ assertFalse,
7
+ assertInstanceOf,
8
+ assertLength,
9
+ assertNonNullable,
10
+ assertNotUndefined,
11
+ assertSame,
12
+ assertTrue,
13
+ } from "../Assert.ts";
14
+ import type { ConsoleEntry } from "../Console.ts";
15
+ import {
16
+ createAppOwner,
17
+ createOwnerSecret,
18
+ createOwnerWebSocketTransport,
19
+ testAppOwner,
20
+ } from "./Owner.ts";
21
+ import { createProtocolMessageForUnsubscribe } from "./Protocol.ts";
22
+ import {
23
+ createQueryBuilder,
24
+ type EvoluSchema,
25
+ type MutationChange,
26
+ } from "./Schema.ts";
27
+ import {
28
+ consoleEntryOrErrorBroadcastChannelName,
29
+ type EvoluInstanceId,
30
+ initSharedWorker,
31
+ type ConsoleEntryOrError,
32
+ type DbWorkerInput,
33
+ type DbWorkerOutput,
34
+ type EvoluInput,
35
+ type EvoluOutput,
36
+ type SharedWorkerInput,
37
+ type SharedWorkerOutput,
38
+ } from "./Shared.ts";
39
+ import { testCreateCrdtMessage } from "./Storage.ts";
40
+ import { acquireLeaderLock, testCreateLockManager } from "../LockManager.ts";
41
+ import { installPolyfills } from "../Polyfills.ts";
42
+ import { createSet } from "../Set.ts";
43
+ import type { SqliteSchema } from "../Sqlite.ts";
44
+ import { createStore } from "../Store.ts";
45
+ import { AbortError, testCreateDeps, testCreateRun } from "../Task.ts";
46
+ import { testCreateId } from "../Test.ts";
47
+ import {
48
+ assertType,
49
+ createId,
50
+ id,
51
+ String,
52
+ testName,
53
+ type Id,
54
+ type Name,
55
+ } from "../Type.ts";
56
+ import { testCreateWebSocket, type CreateWebSocket } from "../WebSocket.ts";
57
+ import {
58
+ testCreateBroadcastChannel,
59
+ testCreateMessageChannel,
60
+ testCreateMessagePort,
61
+ testCreateSharedWorker,
62
+ testWaitForWorkerMessage,
63
+ type TestMessageChannel,
64
+ } from "../Worker.ts";
65
+
66
+ installPolyfills();
67
+
68
+ const testAppOwner2 = createAppOwner(
69
+ createOwnerSecret(testCreateDeps({ seed: "shared-owner-2" })),
70
+ );
71
+
72
+ const TestRowId = id("TestRow");
73
+
74
+ const testEvoluSchema = {
75
+ test: {
76
+ id: TestRowId,
77
+ value: String,
78
+ },
79
+ } satisfies EvoluSchema;
80
+
81
+ const createTestQuery = createQueryBuilder(testEvoluSchema);
82
+
83
+ const testQuery = createTestQuery((db) =>
84
+ db.selectFrom("test").select(() => [kyselySql<string>`"test"`.as("query")]),
85
+ );
86
+
87
+ const testSqliteSchema: SqliteSchema = {
88
+ tables: {
89
+ todo: new Set(["title"]),
90
+ },
91
+ indexes: [],
92
+ };
93
+
94
+ const protocolMessageToArrayBuffer = (message: Uint8Array): ArrayBuffer =>
95
+ Uint8Array.from(message).buffer;
96
+
97
+ const setupSharedWorker = async ({
98
+ createWebSocket = testCreateWebSocket({ throwOnCreate: true }),
99
+ }: {
100
+ createWebSocket?: CreateWebSocket;
101
+ } = {}) => {
102
+ await using disposer = new AsyncDisposableStack();
103
+ const createTestId = testCreateId();
104
+
105
+ const consoleStoreOutputEntry = createStore<ConsoleEntry | null>(null);
106
+ const worker = disposer.use(
107
+ testCreateSharedWorker<SharedWorkerInput, SharedWorkerOutput>(),
108
+ );
109
+ const sharedWorkerOutputs: Array<SharedWorkerOutput> = [];
110
+ let sharedWorkerOutput = Promise.withResolvers<void>();
111
+ const waitForSharedWorkerOutput = (): Promise<void> =>
112
+ sharedWorkerOutput.promise;
113
+ const lockManager = testCreateLockManager();
114
+ const mainThreadRun = disposer.use(
115
+ testCreateRun({
116
+ lockManager,
117
+ }),
118
+ );
119
+ const run = disposer.use(
120
+ testCreateRun({
121
+ consoleStoreOutputEntry,
122
+ createBroadcastChannel: testCreateBroadcastChannel,
123
+ createMessageChannel: testCreateMessageChannel,
124
+ lockManager,
125
+ createMessagePort: testCreateMessagePort,
126
+ createWebSocket,
127
+ }),
128
+ );
129
+
130
+ disposer.use(await run.ok(initSharedWorker(worker.self)));
131
+ worker.connect();
132
+ worker.port.onMessage = (output) => {
133
+ sharedWorkerOutputs.push(output);
134
+ sharedWorkerOutput.resolve();
135
+ sharedWorkerOutput = Promise.withResolvers<void>();
136
+ };
137
+
138
+ const disposables = disposer.move();
139
+
140
+ const createEvoluBeforeDbWorkerLeader = async ({
141
+ tenantName = testName,
142
+ evoluChannel = testCreateMessageChannel<EvoluOutput, EvoluInput>(),
143
+ releaseDbWorkerLeaderOnDispose = true,
144
+ autoDispose = true,
145
+ }: {
146
+ tenantName?: Name;
147
+ evoluChannel?: TestMessageChannel<EvoluOutput, EvoluInput>;
148
+ releaseDbWorkerLeaderOnDispose?: boolean;
149
+ autoDispose?: boolean;
150
+ } = {}) => {
151
+ const instanceDisposables = new AsyncDisposableStack();
152
+ const id = createTestId<"EvoluInstance">();
153
+ const dbInputs: Array<Exclude<DbWorkerInput, { type: "Dispose" }>> = [];
154
+ const dbDisposeInputs: Array<Extract<DbWorkerInput, { type: "Dispose" }>> =
155
+ [];
156
+ let dbWorkerLeaderLock: AsyncDisposable | null = null;
157
+
158
+ const releaseDbWorkerLeader = async (): Promise<void> => {
159
+ if (!dbWorkerLeaderLock) return;
160
+ const lock = dbWorkerLeaderLock;
161
+ dbWorkerLeaderLock = null;
162
+ await lock[Symbol.asyncDispose]();
163
+ };
164
+
165
+ const acquireDbWorkerLeader = async (): Promise<void> => {
166
+ dbWorkerLeaderLock = await mainThreadRun.ok(
167
+ acquireLeaderLock(tenantName),
168
+ );
169
+ dbWorkerPort.postMessage({
170
+ type: "LeaderAcquired",
171
+ name: tenantName,
172
+ });
173
+ await testWaitForWorkerMessage();
174
+ };
175
+
176
+ instanceDisposables.defer(releaseDbWorkerLeader);
177
+
178
+ instanceDisposables.use(await mainThreadRun.ok(acquireLeaderLock(id)));
179
+
180
+ worker.port.postMessage({
181
+ type: "AnnounceTabLeader",
182
+ consoleLevel: "debug",
183
+ });
184
+ await testWaitForWorkerMessage();
185
+
186
+ const message: Extract<SharedWorkerInput, { type: "CreateEvolu" }> = {
187
+ type: "CreateEvolu",
188
+ name: tenantName,
189
+ id,
190
+ consoleLevel: "debug",
191
+ sqliteSchema: testSqliteSchema,
192
+ encryptionKey: testAppOwner.encryptionKey,
193
+ memoryOnly: false,
194
+ evoluPort: evoluChannel.port1.native,
195
+ };
196
+
197
+ const outputCount = sharedWorkerOutputs.length;
198
+ const output = waitForSharedWorkerOutput();
199
+ worker.port.postMessage(message);
200
+
201
+ await output;
202
+
203
+ const initDbWorker = sharedWorkerOutputs.at(outputCount);
204
+ assertNotUndefined(initDbWorker);
205
+
206
+ const dbWorkerPort = instanceDisposables.use(
207
+ testCreateMessagePort<DbWorkerOutput, DbWorkerInput>(initDbWorker.port),
208
+ );
209
+ dbWorkerPort.onMessage = (input) => {
210
+ if (input.type === "Dispose") {
211
+ dbDisposeInputs.push(input);
212
+ if (releaseDbWorkerLeaderOnDispose) void releaseDbWorkerLeader();
213
+ return;
214
+ }
215
+
216
+ dbInputs.push(input);
217
+ };
218
+
219
+ const instance = {
220
+ id,
221
+ acquireDbWorkerLeader,
222
+ evoluChannel,
223
+ dbWorkerPort,
224
+ dbInputs,
225
+ dbDisposeInputs,
226
+ releaseDbWorkerLeader,
227
+ [Symbol.asyncDispose]: async () => {
228
+ if (instanceDisposables.disposed) return;
229
+ await instanceDisposables.disposeAsync();
230
+ await testWaitForWorkerMessage();
231
+ },
232
+ };
233
+
234
+ if (autoDispose) disposables.use(instance);
235
+
236
+ return instance;
237
+ };
238
+
239
+ const createEvolu = async (
240
+ options?: Parameters<typeof createEvoluBeforeDbWorkerLeader>[0],
241
+ ) => {
242
+ const instance = await createEvoluBeforeDbWorkerLeader(options);
243
+ await instance.acquireDbWorkerLeader();
244
+ return instance;
245
+ };
246
+
247
+ return {
248
+ consoleStoreOutputEntry,
249
+ sharedWorkerOutputs,
250
+ run,
251
+ worker,
252
+
253
+ announceTabLeader: async () => {
254
+ const outputs: Array<ConsoleEntryOrError> = [];
255
+ const consoleEntryOrErrorBroadcastChannel =
256
+ testCreateBroadcastChannel<ConsoleEntryOrError>(
257
+ consoleEntryOrErrorBroadcastChannelName,
258
+ );
259
+ consoleEntryOrErrorBroadcastChannel.onMessage = (output) => {
260
+ outputs.push(output);
261
+ };
262
+
263
+ worker.port.postMessage({
264
+ type: "AnnounceTabLeader",
265
+ consoleLevel: "debug",
266
+ });
267
+
268
+ await testWaitForWorkerMessage();
269
+ disposables.use(consoleEntryOrErrorBroadcastChannel);
270
+
271
+ return {
272
+ outputs,
273
+ consoleEntryOrErrorBroadcastChannel,
274
+ };
275
+ },
276
+
277
+ createEvolu,
278
+ createEvoluBeforeDbWorkerLeader,
279
+
280
+ [Symbol.asyncDispose]: () => disposables.disposeAsync(),
281
+ };
282
+ };
283
+
284
+ describe("AnnounceTabLeader", () => {
285
+ describe("console output", () => {
286
+ it("drops console entry logged before the first connected tab", async () => {
287
+ await using setup = await setupSharedWorker();
288
+ const { consoleStoreOutputEntry, announceTabLeader } = setup;
289
+
290
+ const entry: ConsoleEntry = {
291
+ method: "info",
292
+ path: ["test"],
293
+ args: ["queued"],
294
+ };
295
+
296
+ consoleStoreOutputEntry.set(entry);
297
+
298
+ const { outputs } = await announceTabLeader();
299
+
300
+ assertEqual(outputs, []);
301
+ });
302
+
303
+ it("delivers live console entry after a tab connects", async () => {
304
+ await using setup = await setupSharedWorker();
305
+ const { consoleStoreOutputEntry, announceTabLeader } = setup;
306
+ const { outputs } = await announceTabLeader();
307
+
308
+ const entry: ConsoleEntry = {
309
+ method: "info",
310
+ path: ["test"],
311
+ args: ["live"],
312
+ };
313
+
314
+ consoleStoreOutputEntry.set(entry);
315
+
316
+ await testWaitForWorkerMessage();
317
+
318
+ assertEqual(outputs, [{ type: "ConsoleEntry", entry }]);
319
+ });
320
+
321
+ it("ignores null console store updates", async () => {
322
+ await using setup = await setupSharedWorker();
323
+ const { consoleStoreOutputEntry, announceTabLeader } = setup;
324
+ const { outputs } = await announceTabLeader();
325
+
326
+ const entry: ConsoleEntry = {
327
+ method: "info",
328
+ path: ["test"],
329
+ args: ["before-null"],
330
+ };
331
+
332
+ consoleStoreOutputEntry.set(entry);
333
+ consoleStoreOutputEntry.set(null);
334
+
335
+ await testWaitForWorkerMessage();
336
+
337
+ assertEqual(outputs, [{ type: "ConsoleEntry", entry }]);
338
+ });
339
+ });
340
+
341
+ it("logs unknown shared worker inputs", async () => {
342
+ await using setup = await setupSharedWorker();
343
+ const { run, worker } = setup;
344
+ const { console } = run.deps;
345
+
346
+ worker.port.postMessage({ type: "UnknownInput" } as never);
347
+ await testWaitForWorkerMessage();
348
+
349
+ assertEqual(console.getEntriesSnapshot().at(-1), {
350
+ path: ["SharedWorker"],
351
+ method: "error",
352
+ args: ["Unknown shared worker input", { type: "UnknownInput" }],
353
+ });
354
+ });
355
+ });
356
+
357
+ describe("with one evolu instance", () => {
358
+ describe("queue processing", () => {
359
+ it("does not send queued requests before leader is acquired", async () => {
360
+ await using setup = await setupSharedWorker();
361
+ const { createEvoluBeforeDbWorkerLeader, run } = setup;
362
+ const { time } = run.deps;
363
+
364
+ const { acquireDbWorkerLeader, dbInputs, evoluChannel, id } =
365
+ await createEvoluBeforeDbWorkerLeader();
366
+
367
+ evoluChannel.port2.postMessage({
368
+ type: "Query",
369
+ queries: createSet([testQuery]),
370
+ });
371
+
372
+ time.advance("10s");
373
+ await testWaitForWorkerMessage();
374
+
375
+ assertEqual(dbInputs, []);
376
+
377
+ await acquireDbWorkerLeader();
378
+ await testWaitForWorkerMessage();
379
+
380
+ time.advance("10s");
381
+ await testWaitForWorkerMessage();
382
+ await testWaitForWorkerMessage();
383
+
384
+ const firstInput = dbInputs[0];
385
+ assertNotUndefined(firstInput);
386
+ assertEqual(typeof firstInput.callbackId, "string");
387
+ assertEqual(firstInput, {
388
+ type: "Request",
389
+ callbackId: firstInput.callbackId,
390
+ request: {
391
+ type: "ForEvolu",
392
+ id,
393
+ message: {
394
+ type: "Query",
395
+ queries: createSet([testQuery]),
396
+ },
397
+ },
398
+ });
399
+ });
400
+
401
+ it("starts the next queued request after the first response arrives", async () => {
402
+ await using setup = await setupSharedWorker();
403
+ const { createEvolu, run } = setup;
404
+ const { time } = run.deps;
405
+
406
+ const { dbInputs, dbWorkerPort, evoluChannel, id } = await createEvolu();
407
+
408
+ evoluChannel.port2.postMessage({
409
+ type: "Query",
410
+ queries: createSet([testQuery]),
411
+ });
412
+ evoluChannel.port2.postMessage({
413
+ type: "Query",
414
+ queries: createSet([testQuery]),
415
+ });
416
+
417
+ time.advance("10s");
418
+ await testWaitForWorkerMessage();
419
+
420
+ const firstInput = dbInputs[0];
421
+ assertNotUndefined(firstInput);
422
+ assertEqual(firstInput.request, {
423
+ type: "ForEvolu",
424
+ id,
425
+ message: {
426
+ type: "Query",
427
+ queries: createSet([testQuery]),
428
+ },
429
+ });
430
+
431
+ dbWorkerPort.postMessage({
432
+ type: "OnQueuedResponse",
433
+ callbackId: firstInput.callbackId,
434
+ response: {
435
+ type: "ForEvolu",
436
+ id,
437
+ message: {
438
+ type: "Query",
439
+ rowsByQuery: new Map([[testQuery, []]]),
440
+ },
441
+ },
442
+ });
443
+ await testWaitForWorkerMessage();
444
+
445
+ assertLength(dbInputs, 2);
446
+ const secondInput = dbInputs[1];
447
+ assertNotUndefined(secondInput);
448
+ assertEqual(secondInput.request, {
449
+ type: "ForEvolu",
450
+ id,
451
+ message: {
452
+ type: "Query",
453
+ queries: createSet([testQuery]),
454
+ },
455
+ });
456
+ assertFalse(Object.is(secondInput.callbackId, firstInput.callbackId));
457
+ });
458
+ });
459
+
460
+ describe("queued responses", () => {
461
+ it("handles mutate and query responses with correct onCompleteIds", async () => {
462
+ await using setup = await setupSharedWorker();
463
+ const { createEvolu, run } = setup;
464
+ const { time } = run.deps;
465
+ const { dbInputs, dbWorkerPort, evoluChannel, id } = await createEvolu();
466
+ const outputs: Array<EvoluOutput> = [];
467
+ evoluChannel.port2.onMessage = (output) => {
468
+ outputs.push(output);
469
+ };
470
+
471
+ const mutateOnCompleteId = "mutate-complete" as Id;
472
+
473
+ evoluChannel.port2.postMessage({
474
+ type: "Mutate",
475
+ changes: [{} as MutationChange],
476
+ onCompleteIds: [mutateOnCompleteId],
477
+ subscribedQueries: new Set([testQuery]),
478
+ });
479
+ time.advance("10s");
480
+ await testWaitForWorkerMessage();
481
+
482
+ const mutateInput = dbInputs.at(-1);
483
+ assertNotUndefined(mutateInput);
484
+ assertEqual(mutateInput.request, {
485
+ type: "ForEvolu",
486
+ id,
487
+ message: {
488
+ type: "Mutate",
489
+ changes: [{}],
490
+ onCompleteIds: [mutateOnCompleteId],
491
+ subscribedQueries: new Set([testQuery]),
492
+ },
493
+ });
494
+
495
+ dbWorkerPort.postMessage({
496
+ type: "OnQueuedResponse",
497
+ callbackId: mutateInput.callbackId,
498
+ response: {
499
+ type: "ForEvolu",
500
+ id,
501
+ message: {
502
+ type: "Mutate",
503
+ messagesByOwnerId: new Map(),
504
+ rowsByQuery: new Map([[testQuery, [{ value: 1 }]]]),
505
+ },
506
+ },
507
+ });
508
+ await testWaitForWorkerMessage();
509
+
510
+ evoluChannel.port2.postMessage({
511
+ type: "Query",
512
+ queries: createSet([testQuery]),
513
+ });
514
+ time.advance("10s");
515
+ await testWaitForWorkerMessage();
516
+
517
+ const queryInput = dbInputs.at(-1);
518
+ assertNotUndefined(queryInput);
519
+ assertEqual(queryInput.request, {
520
+ type: "ForEvolu",
521
+ id,
522
+ message: {
523
+ type: "Query",
524
+ queries: createSet([testQuery]),
525
+ },
526
+ });
527
+
528
+ dbWorkerPort.postMessage({
529
+ type: "OnQueuedResponse",
530
+ callbackId: queryInput.callbackId,
531
+ response: {
532
+ type: "ForEvolu",
533
+ id,
534
+ message: {
535
+ type: "Query",
536
+ rowsByQuery: new Map([[testQuery, [{ value: 2 }]]]),
537
+ },
538
+ },
539
+ });
540
+ await testWaitForWorkerMessage();
541
+
542
+ const mutateOutput = outputs[0];
543
+ const queryOutput = outputs[1];
544
+ assertSame(mutateOutput.type, "OnPatchesByQuery");
545
+ assertSame(queryOutput.type, "OnPatchesByQuery");
546
+
547
+ assertEqual(mutateOutput.onCompleteIds, [mutateOnCompleteId]);
548
+ assertEqual(queryOutput.onCompleteIds, []);
549
+ assertEqual(mutateOutput.patchesByQuery.get(testQuery)?.[0], {
550
+ op: "replaceAll",
551
+ value: [{ value: 1 }],
552
+ });
553
+ assertEqual(queryOutput.patchesByQuery.get(testQuery)?.[0], {
554
+ op: "replaceAll",
555
+ value: [{ value: 2 }],
556
+ });
557
+ });
558
+
559
+ it("refreshes sibling instances after mutate responses", async () => {
560
+ await using setup = await setupSharedWorker();
561
+ const { createEvolu, run, worker } = setup;
562
+ const { time } = run.deps;
563
+ const first = await createEvolu({ autoDispose: false });
564
+ const firstOutputs: Array<EvoluOutput> = [];
565
+ const secondOutputs: Array<EvoluOutput> = [];
566
+ first.evoluChannel.port2.onMessage = (output) => {
567
+ firstOutputs.push(output);
568
+ };
569
+
570
+ const secondId: EvoluInstanceId = createId(run.deps);
571
+ await using _secondInstanceLock = await run.ok(
572
+ acquireLeaderLock(secondId),
573
+ );
574
+ using secondChannel = testCreateMessageChannel<EvoluOutput, EvoluInput>();
575
+ secondChannel.port2.onMessage = (output) => {
576
+ secondOutputs.push(output);
577
+ };
578
+
579
+ worker.port.postMessage({
580
+ type: "CreateEvolu",
581
+ name: testName,
582
+ id: secondId,
583
+ consoleLevel: "debug",
584
+ sqliteSchema: testSqliteSchema,
585
+ encryptionKey: testAppOwner.encryptionKey,
586
+ memoryOnly: false,
587
+ evoluPort: secondChannel.port1.native,
588
+ });
589
+ await testWaitForWorkerMessage();
590
+
591
+ secondChannel.port2.postMessage({
592
+ type: "Query",
593
+ queries: createSet([testQuery]),
594
+ });
595
+ time.advance("10s");
596
+ await testWaitForWorkerMessage();
597
+
598
+ const secondQueryInput = first.dbInputs.at(-1);
599
+ assertNotUndefined(secondQueryInput);
600
+ assertEqual(secondQueryInput.request, {
601
+ type: "ForEvolu",
602
+ id: secondId,
603
+ message: {
604
+ type: "Query",
605
+ queries: createSet([testQuery]),
606
+ },
607
+ });
608
+
609
+ first.dbWorkerPort.postMessage({
610
+ type: "OnQueuedResponse",
611
+ callbackId: secondQueryInput.callbackId,
612
+ response: {
613
+ type: "ForEvolu",
614
+ id: secondId,
615
+ message: {
616
+ type: "Query",
617
+ rowsByQuery: new Map([[testQuery, [{ value: 0 }]]]),
618
+ },
619
+ },
620
+ });
621
+ await testWaitForWorkerMessage();
622
+
623
+ first.evoluChannel.port2.postMessage({
624
+ type: "Mutate",
625
+ changes: [{} as MutationChange],
626
+ onCompleteIds: [],
627
+ subscribedQueries: new Set([testQuery]),
628
+ });
629
+ time.advance("10s");
630
+ await testWaitForWorkerMessage();
631
+
632
+ const mutateInput = first.dbInputs.at(-1);
633
+ assertNotUndefined(mutateInput);
634
+ assertEqual(mutateInput.request, {
635
+ type: "ForEvolu",
636
+ id: first.id,
637
+ message: {
638
+ type: "Mutate",
639
+ changes: [{}],
640
+ onCompleteIds: [],
641
+ subscribedQueries: new Set([testQuery]),
642
+ },
643
+ });
644
+
645
+ first.dbWorkerPort.postMessage({
646
+ type: "OnQueuedResponse",
647
+ callbackId: mutateInput.callbackId,
648
+ response: {
649
+ type: "ForEvolu",
650
+ id: first.id,
651
+ message: {
652
+ type: "Mutate",
653
+ messagesByOwnerId: new Map(),
654
+ rowsByQuery: new Map([[testQuery, [{ value: 1 }]]]),
655
+ },
656
+ },
657
+ });
658
+ await testWaitForWorkerMessage();
659
+
660
+ assertTrue(
661
+ firstOutputs.some((output) => output.type === "OnPatchesByQuery"),
662
+ );
663
+ assertTrue(
664
+ secondOutputs.some((output) => output.type === "RefreshQueries"),
665
+ );
666
+ });
667
+
668
+ it("drops CreateEvolu when shared worker stops during tenant startup", async () => {
669
+ await using setup = await setupSharedWorker();
670
+ const { announceTabLeader, run, sharedWorkerOutputs, worker } = setup;
671
+ const id: EvoluInstanceId = createId(run.deps);
672
+ const evoluOutputs: Array<EvoluOutput> = [];
673
+
674
+ using evoluChannel = testCreateMessageChannel<EvoluOutput, EvoluInput>();
675
+ evoluChannel.port2.onMessage = (output) => {
676
+ evoluOutputs.push(output);
677
+ };
678
+
679
+ await announceTabLeader();
680
+
681
+ const outputCount = sharedWorkerOutputs.length;
682
+ worker.port.postMessage({
683
+ type: "CreateEvolu",
684
+ name: testName,
685
+ id,
686
+ consoleLevel: "debug",
687
+ sqliteSchema: testSqliteSchema,
688
+ encryptionKey: testAppOwner.encryptionKey,
689
+ memoryOnly: false,
690
+ evoluPort: evoluChannel.port1.native,
691
+ });
692
+
693
+ await testWaitForWorkerMessage();
694
+
695
+ const initDbWorker = sharedWorkerOutputs[outputCount];
696
+ assertNotUndefined(initDbWorker);
697
+
698
+ using dbWorkerPort = testCreateMessagePort<DbWorkerOutput, DbWorkerInput>(
699
+ initDbWorker.port,
700
+ );
701
+ const dbDisposeInputs: Array<
702
+ Extract<DbWorkerInput, { type: "Dispose" }>
703
+ > = [];
704
+ dbWorkerPort.onMessage = (input) => {
705
+ if (input.type === "Dispose") dbDisposeInputs.push(input);
706
+ };
707
+
708
+ const disposePromise = setup[Symbol.asyncDispose]();
709
+ dbWorkerPort.postMessage({
710
+ type: "LeaderAcquired",
711
+ name: testName,
712
+ });
713
+ await disposePromise;
714
+ await testWaitForWorkerMessage();
715
+
716
+ assertEqual(dbDisposeInputs, [{ type: "Dispose" }]);
717
+ assertEqual(evoluOutputs, []);
718
+ });
719
+
720
+ it("forwards export responses back to the evolu port", async () => {
721
+ await using setup = await setupSharedWorker();
722
+ const { createEvolu, run } = setup;
723
+ const { time } = run.deps;
724
+ const { dbInputs, dbWorkerPort, evoluChannel, id } = await createEvolu();
725
+ const outputs: Array<EvoluOutput> = [];
726
+ evoluChannel.port2.onMessage = (output) => {
727
+ outputs.push(output);
728
+ };
729
+
730
+ evoluChannel.port2.postMessage({ type: "Export" });
731
+ time.advance("10s");
732
+ await testWaitForWorkerMessage();
733
+
734
+ const exportInput = dbInputs.at(-1);
735
+ assertNotUndefined(exportInput);
736
+ assertEqual(exportInput.request, {
737
+ type: "ForEvolu",
738
+ id,
739
+ message: { type: "Export" },
740
+ });
741
+
742
+ const file = new Uint8Array([1, 2, 3]);
743
+ dbWorkerPort.postMessage({
744
+ type: "OnQueuedResponse",
745
+ callbackId: exportInput.callbackId,
746
+ response: {
747
+ type: "ForEvolu",
748
+ id,
749
+ message: {
750
+ type: "Export",
751
+ file,
752
+ },
753
+ },
754
+ });
755
+ await testWaitForWorkerMessage();
756
+
757
+ const output = outputs[0];
758
+ assertSame(output.type, "OnExport");
759
+ assertEqual(Array.from(output.file), [1, 2, 3]);
760
+ });
761
+
762
+ it("ignores queued evolu responses for missing instances", async () => {
763
+ await using setup = await setupSharedWorker();
764
+ const { createEvolu, run } = setup;
765
+ const { time } = run.deps;
766
+ const { dbInputs, dbWorkerPort, evoluChannel } = await createEvolu();
767
+ const outputs: Array<EvoluOutput> = [];
768
+ evoluChannel.port2.onMessage = (output) => {
769
+ outputs.push(output);
770
+ };
771
+
772
+ evoluChannel.port2.postMessage({
773
+ type: "Query",
774
+ queries: createSet([testQuery]),
775
+ });
776
+ time.advance("10s");
777
+ await testWaitForWorkerMessage();
778
+
779
+ const queryInput = dbInputs.at(-1);
780
+ assertNotUndefined(queryInput);
781
+
782
+ dbWorkerPort.postMessage({
783
+ type: "OnQueuedResponse",
784
+ callbackId: queryInput.callbackId,
785
+ response: {
786
+ type: "ForEvolu",
787
+ id: createId<"EvoluInstance">(run.deps),
788
+ message: {
789
+ type: "Query",
790
+ rowsByQuery: new Map([[testQuery, [{ value: 1 }]]]),
791
+ },
792
+ },
793
+ });
794
+ await testWaitForWorkerMessage();
795
+
796
+ assertEqual(outputs, []);
797
+ });
798
+ });
799
+
800
+ describe("sync behavior", () => {
801
+ it("sends protocol messages only for writable used owners", async () => {
802
+ const createWebSocket = testCreateWebSocket();
803
+ await using setup = await setupSharedWorker({ createWebSocket });
804
+ const { createEvolu, run } = setup;
805
+ const { time } = run.deps;
806
+ const { dbInputs, dbWorkerPort, evoluChannel, id } = await createEvolu();
807
+ const writableTransport = createOwnerWebSocketTransport({
808
+ url: "wss://relay.example",
809
+ ownerId: testAppOwner.id,
810
+ });
811
+ const readonlyTransport = createOwnerWebSocketTransport({
812
+ url: "wss://readonly.example",
813
+ ownerId: testAppOwner2.id,
814
+ });
815
+ const readonlyOwner = {
816
+ id: testAppOwner2.id,
817
+ encryptionKey: testAppOwner2.encryptionKey,
818
+ };
819
+
820
+ evoluChannel.port2.postMessage({
821
+ type: "UseOwner",
822
+ actions: [
823
+ {
824
+ owner: { owner: testAppOwner, transports: [writableTransport] },
825
+ action: "add",
826
+ },
827
+ {
828
+ owner: { owner: readonlyOwner, transports: [readonlyTransport] },
829
+ action: "add",
830
+ },
831
+ ],
832
+ });
833
+ await testWaitForWorkerMessage();
834
+
835
+ const createSyncInput = dbInputs.at(-1);
836
+ assertNotUndefined(createSyncInput);
837
+ assertEqual(createSyncInput.request, {
838
+ type: "ForSharedWorker",
839
+ message: {
840
+ type: "CreateSyncMessages",
841
+ owners: [testAppOwner],
842
+ },
843
+ });
844
+
845
+ dbWorkerPort.postMessage({
846
+ type: "OnQueuedResponse",
847
+ callbackId: createSyncInput.callbackId,
848
+ response: {
849
+ type: "ForSharedWorker",
850
+ message: {
851
+ type: "CreateSyncMessages",
852
+ protocolMessagesByOwnerId: new Map(),
853
+ },
854
+ },
855
+ });
856
+ await testWaitForWorkerMessage();
857
+
858
+ evoluChannel.port2.postMessage({
859
+ type: "Mutate",
860
+ changes: [{ ownerId: testAppOwner.id } as MutationChange],
861
+ onCompleteIds: [],
862
+ subscribedQueries: new Set([testQuery]),
863
+ });
864
+ time.advance("10s");
865
+ await testWaitForWorkerMessage();
866
+
867
+ const mutateInput = dbInputs.at(-1);
868
+ assertNotUndefined(mutateInput);
869
+ assertSame(mutateInput.request.type, "ForEvolu");
870
+ assertSame(mutateInput.request.id, id);
871
+
872
+ const messages = [
873
+ testCreateCrdtMessage(createId(run.deps), 1, "hello"),
874
+ ] as const;
875
+ dbWorkerPort.postMessage({
876
+ type: "OnQueuedResponse",
877
+ callbackId: mutateInput.callbackId,
878
+ response: {
879
+ type: "ForEvolu",
880
+ id,
881
+ message: {
882
+ type: "Mutate",
883
+ messagesByOwnerId: new Map([
884
+ [testAppOwner.id, messages],
885
+ [readonlyOwner.id, messages],
886
+ [createId<"OwnerId">(run.deps), messages],
887
+ ]),
888
+ rowsByQuery: new Map([[testQuery, [{ value: 1 }]]]),
889
+ },
890
+ },
891
+ });
892
+ await testWaitForWorkerMessage();
893
+
894
+ assertLength(createWebSocket.sentMessages, 1);
895
+ assertSame(createWebSocket.sentMessages[0]?.url, writableTransport.url);
896
+ assertInstanceOf(createWebSocket.sentMessages[0]?.data, Uint8Array);
897
+ });
898
+
899
+ it("ignores non-binary and invalid transport messages", async () => {
900
+ const createWebSocket = testCreateWebSocket({ isOpen: false });
901
+ await using setup = await setupSharedWorker({ createWebSocket });
902
+ const { createEvolu } = setup;
903
+ const { dbInputs, evoluChannel } = await createEvolu();
904
+ const transport = createOwnerWebSocketTransport({
905
+ url: "wss://transport.example",
906
+ ownerId: testAppOwner.id,
907
+ });
908
+
909
+ evoluChannel.port2.postMessage({
910
+ type: "UseOwner",
911
+ actions: [
912
+ {
913
+ owner: { owner: testAppOwner, transports: [transport] },
914
+ action: "add",
915
+ },
916
+ ],
917
+ });
918
+ await testWaitForWorkerMessage();
919
+
920
+ createWebSocket.message(transport.url, "not-binary");
921
+ createWebSocket.message(transport.url, new Uint8Array([1]).buffer);
922
+ await testWaitForWorkerMessage();
923
+
924
+ assertEqual(dbInputs, []);
925
+ });
926
+
927
+ it("requests sync messages when a claimed transport opens later", async () => {
928
+ const createWebSocket = testCreateWebSocket({ isOpen: false });
929
+ await using setup = await setupSharedWorker({ createWebSocket });
930
+ const { createEvolu } = setup;
931
+ const { dbInputs, evoluChannel } = await createEvolu();
932
+ const transport = createOwnerWebSocketTransport({
933
+ url: "wss://open-later.example",
934
+ ownerId: testAppOwner.id,
935
+ });
936
+
937
+ evoluChannel.port2.postMessage({
938
+ type: "UseOwner",
939
+ actions: [
940
+ {
941
+ owner: { owner: testAppOwner, transports: [transport] },
942
+ action: "add",
943
+ },
944
+ ],
945
+ });
946
+ await testWaitForWorkerMessage();
947
+
948
+ assertEqual(dbInputs, []);
949
+
950
+ createWebSocket.open(transport.url);
951
+ await testWaitForWorkerMessage();
952
+
953
+ assertEqual(dbInputs.at(-1)?.request, {
954
+ type: "ForSharedWorker",
955
+ message: {
956
+ type: "CreateSyncMessages",
957
+ owners: [testAppOwner],
958
+ },
959
+ });
960
+ });
961
+
962
+ it("sends unsubscribe when the last transport claim is removed", async () => {
963
+ const createWebSocket = testCreateWebSocket();
964
+ await using setup = await setupSharedWorker({ createWebSocket });
965
+ const { createEvolu } = setup;
966
+ const { evoluChannel } = await createEvolu();
967
+ const transport = createOwnerWebSocketTransport({
968
+ url: "wss://unsubscribe.example",
969
+ ownerId: testAppOwner.id,
970
+ });
971
+
972
+ evoluChannel.port2.postMessage({
973
+ type: "UseOwner",
974
+ actions: [
975
+ {
976
+ owner: { owner: testAppOwner, transports: [transport] },
977
+ action: "add",
978
+ },
979
+ ],
980
+ });
981
+ await testWaitForWorkerMessage();
982
+
983
+ evoluChannel.port2.postMessage({
984
+ type: "UseOwner",
985
+ actions: [
986
+ {
987
+ owner: { owner: testAppOwner, transports: [transport] },
988
+ action: "remove",
989
+ },
990
+ ],
991
+ });
992
+ await testWaitForWorkerMessage();
993
+
994
+ assertEqual(createWebSocket.sentMessages.at(-1), {
995
+ url: transport.url,
996
+ data: createProtocolMessageForUnsubscribe(testAppOwner.id),
997
+ });
998
+ });
999
+
1000
+ it("keeps a repeated owner transport claimed until every use is removed", async () => {
1001
+ const createWebSocket = testCreateWebSocket();
1002
+ await using setup = await setupSharedWorker({ createWebSocket });
1003
+ const { createEvolu } = setup;
1004
+ const { evoluChannel } = await createEvolu();
1005
+ const transport = createOwnerWebSocketTransport({
1006
+ url: "wss://repeated-owner.example",
1007
+ ownerId: testAppOwner.id,
1008
+ });
1009
+ const syncOwner = {
1010
+ owner: testAppOwner,
1011
+ transports: [transport],
1012
+ } as const;
1013
+
1014
+ evoluChannel.port2.postMessage({
1015
+ type: "UseOwner",
1016
+ actions: [
1017
+ { owner: syncOwner, action: "add" },
1018
+ { owner: syncOwner, action: "add" },
1019
+ ],
1020
+ });
1021
+ await testWaitForWorkerMessage();
1022
+
1023
+ evoluChannel.port2.postMessage({
1024
+ type: "UseOwner",
1025
+ actions: [{ owner: syncOwner, action: "remove" }],
1026
+ });
1027
+ await testWaitForWorkerMessage();
1028
+
1029
+ assertEqual(createWebSocket.sentMessages, []);
1030
+
1031
+ evoluChannel.port2.postMessage({
1032
+ type: "UseOwner",
1033
+ actions: [{ owner: syncOwner, action: "remove" }],
1034
+ });
1035
+ await testWaitForWorkerMessage();
1036
+
1037
+ assertEqual(createWebSocket.sentMessages.at(-1), {
1038
+ url: transport.url,
1039
+ data: createProtocolMessageForUnsubscribe(testAppOwner.id),
1040
+ });
1041
+ });
1042
+
1043
+ it("releases the matching transport set when owner uses are removed out of order", async () => {
1044
+ const createWebSocket = testCreateWebSocket();
1045
+ await using setup = await setupSharedWorker({ createWebSocket });
1046
+ const { createEvolu } = setup;
1047
+ const { evoluChannel } = await createEvolu();
1048
+ const firstTransport = createOwnerWebSocketTransport({
1049
+ url: "wss://first-owner-use.example",
1050
+ ownerId: testAppOwner.id,
1051
+ });
1052
+ const secondTransport = createOwnerWebSocketTransport({
1053
+ url: "wss://second-owner-use.example",
1054
+ ownerId: testAppOwner.id,
1055
+ });
1056
+ const firstSyncOwner = {
1057
+ owner: testAppOwner,
1058
+ transports: [firstTransport],
1059
+ } as const;
1060
+ const secondSyncOwner = {
1061
+ owner: testAppOwner,
1062
+ transports: [secondTransport],
1063
+ } as const;
1064
+
1065
+ evoluChannel.port2.postMessage({
1066
+ type: "UseOwner",
1067
+ actions: [
1068
+ { owner: firstSyncOwner, action: "add" },
1069
+ { owner: secondSyncOwner, action: "add" },
1070
+ ],
1071
+ });
1072
+ await testWaitForWorkerMessage();
1073
+
1074
+ evoluChannel.port2.postMessage({
1075
+ type: "UseOwner",
1076
+ actions: [{ owner: firstSyncOwner, action: "remove" }],
1077
+ });
1078
+ await testWaitForWorkerMessage();
1079
+
1080
+ assertEqual(createWebSocket.sentMessages, [
1081
+ {
1082
+ url: firstTransport.url,
1083
+ data: createProtocolMessageForUnsubscribe(testAppOwner.id),
1084
+ },
1085
+ ]);
1086
+
1087
+ evoluChannel.port2.postMessage({
1088
+ type: "UseOwner",
1089
+ actions: [{ owner: secondSyncOwner, action: "remove" }],
1090
+ });
1091
+ await testWaitForWorkerMessage();
1092
+
1093
+ assertEqual(createWebSocket.sentMessages.at(-1), {
1094
+ url: secondTransport.url,
1095
+ data: createProtocolMessageForUnsubscribe(testAppOwner.id),
1096
+ });
1097
+ });
1098
+
1099
+ it("reports a transport creation defect without leaving a pending owner use", async () => {
1100
+ await using setup = await setupSharedWorker();
1101
+ const { createEvolu, run } = setup;
1102
+ const { evoluChannel } = await createEvolu();
1103
+ const transport = createOwnerWebSocketTransport({
1104
+ url: "wss://create-defect.example",
1105
+ ownerId: testAppOwner.id,
1106
+ });
1107
+ const reported = run.deps.reportDefect.next();
1108
+
1109
+ evoluChannel.port2.postMessage({
1110
+ type: "UseOwner",
1111
+ actions: [
1112
+ {
1113
+ owner: { owner: testAppOwner, transports: [transport] },
1114
+ action: "add",
1115
+ },
1116
+ ],
1117
+ });
1118
+
1119
+ const error = await reported;
1120
+ assertType(AbortError, error);
1121
+ assertSame(error.reason.type, "PanicAbortReason");
1122
+ assertInstanceOf(error.reason.defect, Error);
1123
+ assertEqual(
1124
+ error.reason.defect.message,
1125
+ "testCreateWebSocket is configured to throw on create",
1126
+ );
1127
+ });
1128
+
1129
+ it("handles apply sync responses for errors, refreshes, and response messages", async () => {
1130
+ const createWebSocket = testCreateWebSocket();
1131
+ await using setup = await setupSharedWorker({ createWebSocket });
1132
+ const { createEvolu, run } = setup;
1133
+ const { time } = run.deps;
1134
+ const { dbInputs, dbWorkerPort, evoluChannel } = await createEvolu();
1135
+ const consoleEntryOrErrors: Array<ConsoleEntryOrError> = [];
1136
+ using consoleEntryOrErrorBroadcastChannel =
1137
+ testCreateBroadcastChannel<ConsoleEntryOrError>(
1138
+ consoleEntryOrErrorBroadcastChannelName,
1139
+ );
1140
+ const evoluOutputs: Array<EvoluOutput> = [];
1141
+ const transport = createOwnerWebSocketTransport({
1142
+ url: "wss://apply-sync.example",
1143
+ ownerId: testAppOwner.id,
1144
+ });
1145
+
1146
+ consoleEntryOrErrorBroadcastChannel.onMessage = (output) => {
1147
+ consoleEntryOrErrors.push(output);
1148
+ };
1149
+ evoluChannel.port2.onMessage = (output) => {
1150
+ evoluOutputs.push(output);
1151
+ };
1152
+
1153
+ evoluChannel.port2.postMessage({
1154
+ type: "UseOwner",
1155
+ actions: [
1156
+ {
1157
+ owner: { owner: testAppOwner, transports: [transport] },
1158
+ action: "add",
1159
+ },
1160
+ ],
1161
+ });
1162
+ await testWaitForWorkerMessage();
1163
+
1164
+ const createSyncInput = dbInputs.at(-1);
1165
+ assertNotUndefined(createSyncInput);
1166
+ dbWorkerPort.postMessage({
1167
+ type: "OnQueuedResponse",
1168
+ callbackId: createSyncInput.callbackId,
1169
+ response: {
1170
+ type: "ForSharedWorker",
1171
+ message: {
1172
+ type: "CreateSyncMessages",
1173
+ protocolMessagesByOwnerId: new Map(),
1174
+ },
1175
+ },
1176
+ });
1177
+ await testWaitForWorkerMessage();
1178
+ dbInputs.length = 0;
1179
+
1180
+ createWebSocket.message(
1181
+ transport.url,
1182
+ protocolMessageToArrayBuffer(
1183
+ createProtocolMessageForUnsubscribe(testAppOwner.id),
1184
+ ),
1185
+ );
1186
+ time.advance("10s");
1187
+ await testWaitForWorkerMessage();
1188
+
1189
+ const firstApplyInput = dbInputs.at(-1);
1190
+ assertNotUndefined(firstApplyInput);
1191
+ assertSame(firstApplyInput.request.type, "ForSharedWorker");
1192
+ assertSame(firstApplyInput.request.message.type, "ApplySyncMessage");
1193
+ assertEqual(firstApplyInput.request.message.owner, testAppOwner);
1194
+
1195
+ dbWorkerPort.postMessage({
1196
+ type: "OnQueuedResponse",
1197
+ callbackId: firstApplyInput.callbackId,
1198
+ response: {
1199
+ type: "ForSharedWorker",
1200
+ message: {
1201
+ type: "ApplySyncMessage",
1202
+ ownerId: testAppOwner.id,
1203
+ didWriteMessages: true,
1204
+ result: {
1205
+ ok: false,
1206
+ error: {
1207
+ type: "ProtocolInvalidDataError",
1208
+ data: new Uint8Array(),
1209
+ error: "boom",
1210
+ },
1211
+ },
1212
+ },
1213
+ },
1214
+ });
1215
+ await testWaitForWorkerMessage();
1216
+
1217
+ assertTrue(
1218
+ evoluOutputs.some((output) => output.type === "RefreshQueries"),
1219
+ );
1220
+ assertEqual(consoleEntryOrErrors.at(-1), {
1221
+ type: "Error",
1222
+ error: {
1223
+ type: "ProtocolInvalidDataError",
1224
+ data: new Uint8Array(),
1225
+ error: "boom",
1226
+ },
1227
+ });
1228
+
1229
+ createWebSocket.message(
1230
+ transport.url,
1231
+ protocolMessageToArrayBuffer(
1232
+ createProtocolMessageForUnsubscribe(testAppOwner.id),
1233
+ ),
1234
+ );
1235
+ time.advance("10s");
1236
+ await testWaitForWorkerMessage();
1237
+
1238
+ const responseApplyInput = dbInputs.at(-1);
1239
+ assertNotUndefined(responseApplyInput);
1240
+ dbWorkerPort.postMessage({
1241
+ type: "OnQueuedResponse",
1242
+ callbackId: responseApplyInput.callbackId,
1243
+ response: {
1244
+ type: "ForSharedWorker",
1245
+ message: {
1246
+ type: "ApplySyncMessage",
1247
+ ownerId: testAppOwner.id,
1248
+ didWriteMessages: false,
1249
+ result: {
1250
+ ok: true,
1251
+ value: {
1252
+ type: "Response",
1253
+ message: createProtocolMessageForUnsubscribe(testAppOwner.id),
1254
+ },
1255
+ },
1256
+ },
1257
+ },
1258
+ });
1259
+ await testWaitForWorkerMessage();
1260
+
1261
+ assertEqual(createWebSocket.sentMessages.at(-1), {
1262
+ url: transport.url,
1263
+ data: createProtocolMessageForUnsubscribe(testAppOwner.id),
1264
+ });
1265
+ });
1266
+
1267
+ it("ignores abort, broadcast, and no-response apply sync results", async () => {
1268
+ const createWebSocket = testCreateWebSocket();
1269
+ await using setup = await setupSharedWorker({ createWebSocket });
1270
+ const { createEvolu, run } = setup;
1271
+ const { time } = run.deps;
1272
+ const { dbInputs, dbWorkerPort, evoluChannel } = await createEvolu();
1273
+ const consoleEntryOrErrors: Array<ConsoleEntryOrError> = [];
1274
+ using consoleEntryOrErrorBroadcastChannel =
1275
+ testCreateBroadcastChannel<ConsoleEntryOrError>(
1276
+ consoleEntryOrErrorBroadcastChannelName,
1277
+ );
1278
+ const transport = createOwnerWebSocketTransport({
1279
+ url: "wss://apply-sync-ignored.example",
1280
+ ownerId: testAppOwner.id,
1281
+ });
1282
+
1283
+ consoleEntryOrErrorBroadcastChannel.onMessage = (output) => {
1284
+ consoleEntryOrErrors.push(output);
1285
+ };
1286
+
1287
+ evoluChannel.port2.postMessage({
1288
+ type: "UseOwner",
1289
+ actions: [
1290
+ {
1291
+ owner: { owner: testAppOwner, transports: [transport] },
1292
+ action: "add",
1293
+ },
1294
+ ],
1295
+ });
1296
+ await testWaitForWorkerMessage();
1297
+
1298
+ const createSyncInput = dbInputs.at(-1);
1299
+ assertNotUndefined(createSyncInput);
1300
+ dbWorkerPort.postMessage({
1301
+ type: "OnQueuedResponse",
1302
+ callbackId: createSyncInput.callbackId,
1303
+ response: {
1304
+ type: "ForSharedWorker",
1305
+ message: {
1306
+ type: "CreateSyncMessages",
1307
+ protocolMessagesByOwnerId: new Map(),
1308
+ },
1309
+ },
1310
+ });
1311
+ await testWaitForWorkerMessage();
1312
+ dbInputs.length = 0;
1313
+
1314
+ const runApplySync = async (
1315
+ response: Extract<
1316
+ Extract<DbWorkerOutput, { type: "OnQueuedResponse" }>["response"],
1317
+ { type: "ForSharedWorker" }
1318
+ >["message"],
1319
+ ): Promise<void> => {
1320
+ createWebSocket.message(
1321
+ transport.url,
1322
+ protocolMessageToArrayBuffer(
1323
+ createProtocolMessageForUnsubscribe(testAppOwner.id),
1324
+ ),
1325
+ );
1326
+ time.advance("10s");
1327
+ await testWaitForWorkerMessage();
1328
+
1329
+ const applyInput = dbInputs.at(-1);
1330
+ assertNotUndefined(applyInput);
1331
+ dbWorkerPort.postMessage({
1332
+ type: "OnQueuedResponse",
1333
+ callbackId: applyInput.callbackId,
1334
+ response: {
1335
+ type: "ForSharedWorker",
1336
+ message: response,
1337
+ },
1338
+ });
1339
+ await testWaitForWorkerMessage();
1340
+ dbInputs.length = 0;
1341
+ };
1342
+
1343
+ const sentMessageCount = createWebSocket.sentMessages.length;
1344
+
1345
+ await runApplySync({
1346
+ type: "ApplySyncMessage",
1347
+ ownerId: testAppOwner.id,
1348
+ didWriteMessages: false,
1349
+ result: {
1350
+ ok: false,
1351
+ error: { type: "AbortError", reason: { type: "Stop" } },
1352
+ },
1353
+ });
1354
+ await runApplySync({
1355
+ type: "ApplySyncMessage",
1356
+ ownerId: testAppOwner.id,
1357
+ didWriteMessages: false,
1358
+ result: { ok: true, value: { type: "Broadcast" } },
1359
+ });
1360
+ await runApplySync({
1361
+ type: "ApplySyncMessage",
1362
+ ownerId: testAppOwner.id,
1363
+ didWriteMessages: false,
1364
+ result: { ok: true, value: { type: "NoResponse" } },
1365
+ });
1366
+
1367
+ assertEqual(consoleEntryOrErrors, []);
1368
+ assertLength(createWebSocket.sentMessages, sentMessageCount);
1369
+ });
1370
+
1371
+ it("ignores sync creation and apply when no writable owner is active", async () => {
1372
+ const createWebSocket = testCreateWebSocket({ isOpen: false });
1373
+ await using setup = await setupSharedWorker({ createWebSocket });
1374
+ const { createEvolu, run } = setup;
1375
+ const { time } = run.deps;
1376
+ const { dbInputs, evoluChannel } = await createEvolu();
1377
+ const readonlyOwner = {
1378
+ id: testAppOwner2.id,
1379
+ encryptionKey: testAppOwner2.encryptionKey,
1380
+ };
1381
+ const readonlyTransport = createOwnerWebSocketTransport({
1382
+ url: "wss://readonly-only.example",
1383
+ ownerId: readonlyOwner.id,
1384
+ });
1385
+
1386
+ evoluChannel.port2.postMessage({
1387
+ type: "UseOwner",
1388
+ actions: [
1389
+ {
1390
+ owner: { owner: readonlyOwner, transports: [readonlyTransport] },
1391
+ action: "add",
1392
+ },
1393
+ ],
1394
+ });
1395
+ await testWaitForWorkerMessage();
1396
+
1397
+ createWebSocket.open(readonlyTransport.url);
1398
+ await testWaitForWorkerMessage();
1399
+ assertEqual(dbInputs, []);
1400
+
1401
+ const writableTransport = createOwnerWebSocketTransport({
1402
+ url: "wss://removed-owner.example",
1403
+ ownerId: testAppOwner.id,
1404
+ });
1405
+ evoluChannel.port2.postMessage({
1406
+ type: "UseOwner",
1407
+ actions: [
1408
+ {
1409
+ owner: { owner: testAppOwner, transports: [writableTransport] },
1410
+ action: "add",
1411
+ },
1412
+ ],
1413
+ });
1414
+ await testWaitForWorkerMessage();
1415
+
1416
+ evoluChannel.port2.postMessage({
1417
+ type: "UseOwner",
1418
+ actions: [
1419
+ {
1420
+ owner: { owner: testAppOwner, transports: [writableTransport] },
1421
+ action: "remove",
1422
+ },
1423
+ ],
1424
+ });
1425
+ await testWaitForWorkerMessage();
1426
+
1427
+ dbInputs.length = 0;
1428
+ createWebSocket.message(
1429
+ writableTransport.url,
1430
+ protocolMessageToArrayBuffer(
1431
+ createProtocolMessageForUnsubscribe(testAppOwner.id),
1432
+ ),
1433
+ );
1434
+ time.advance("10s");
1435
+ await testWaitForWorkerMessage();
1436
+
1437
+ assertEqual(dbInputs, []);
1438
+ });
1439
+
1440
+ it("ignores protocol sends through closed transports", async () => {
1441
+ const createWebSocket = testCreateWebSocket({ isOpen: false });
1442
+ await using setup = await setupSharedWorker({ createWebSocket });
1443
+ const { createEvolu, run } = setup;
1444
+ const { time } = run.deps;
1445
+ const { dbInputs, dbWorkerPort, evoluChannel, id } = await createEvolu();
1446
+ const outputs: Array<EvoluOutput> = [];
1447
+ const transport = createOwnerWebSocketTransport({
1448
+ url: "wss://closed-transport.example",
1449
+ ownerId: testAppOwner.id,
1450
+ });
1451
+
1452
+ evoluChannel.port2.onMessage = (output) => {
1453
+ outputs.push(output);
1454
+ };
1455
+
1456
+ evoluChannel.port2.postMessage({
1457
+ type: "UseOwner",
1458
+ actions: [
1459
+ {
1460
+ owner: { owner: testAppOwner, transports: [transport] },
1461
+ action: "add",
1462
+ },
1463
+ ],
1464
+ });
1465
+ evoluChannel.port2.postMessage({
1466
+ type: "Mutate",
1467
+ changes: [{ ownerId: testAppOwner.id } as MutationChange],
1468
+ onCompleteIds: [],
1469
+ subscribedQueries: new Set([testQuery]),
1470
+ });
1471
+ time.advance("10s");
1472
+ await testWaitForWorkerMessage();
1473
+
1474
+ const mutateInput = dbInputs.at(-1);
1475
+ assertNotUndefined(mutateInput);
1476
+ dbWorkerPort.postMessage({
1477
+ type: "OnQueuedResponse",
1478
+ callbackId: mutateInput.callbackId,
1479
+ response: {
1480
+ type: "ForEvolu",
1481
+ id,
1482
+ message: {
1483
+ type: "Mutate",
1484
+ messagesByOwnerId: new Map([
1485
+ [
1486
+ testAppOwner.id,
1487
+ [testCreateCrdtMessage(createId(run.deps), 1, "closed")],
1488
+ ],
1489
+ ]),
1490
+ rowsByQuery: new Map([[testQuery, [{ value: 2 }]]]),
1491
+ },
1492
+ },
1493
+ });
1494
+ await testWaitForWorkerMessage();
1495
+
1496
+ assertEqual(outputs.at(-1), {
1497
+ type: "OnPatchesByQuery",
1498
+ patchesByQuery: new Map([
1499
+ [testQuery, [{ op: "replaceAll", value: [{ value: 2 }] }]],
1500
+ ]),
1501
+ onCompleteIds: [],
1502
+ });
1503
+ assertEqual(createWebSocket.sentMessages, []);
1504
+ });
1505
+
1506
+ it("serializes overlapping UseOwner add and remove across multiple transports", async () => {
1507
+ const createWebSocket = testCreateWebSocket();
1508
+ const firstCreateStarted = Promise.withResolvers<void>();
1509
+ const allowFirstCreateToFinish = Promise.withResolvers<void>();
1510
+ const transportA = createOwnerWebSocketTransport({
1511
+ url: "wss://use-owner-a.example",
1512
+ ownerId: testAppOwner.id,
1513
+ });
1514
+ const transportB = createOwnerWebSocketTransport({
1515
+ url: "wss://use-owner-b.example",
1516
+ ownerId: testAppOwner.id,
1517
+ });
1518
+ const evoluChannel = testCreateMessageChannel<EvoluOutput, EvoluInput>();
1519
+
1520
+ let pausedFirstCreate = false;
1521
+ const pausingCreateWebSocket: CreateWebSocket =
1522
+ (url, options) => async (run) => {
1523
+ if (url === transportA.url && !pausedFirstCreate) {
1524
+ pausedFirstCreate = true;
1525
+ firstCreateStarted.resolve();
1526
+ evoluChannel.port2.postMessage({
1527
+ type: "UseOwner",
1528
+ actions: [
1529
+ {
1530
+ owner: {
1531
+ owner: testAppOwner,
1532
+ transports: [transportB, transportA],
1533
+ },
1534
+ action: "remove",
1535
+ },
1536
+ ],
1537
+ });
1538
+ await allowFirstCreateToFinish.promise;
1539
+ }
1540
+
1541
+ return run(createWebSocket(url, options));
1542
+ };
1543
+
1544
+ await using setup = await setupSharedWorker({
1545
+ createWebSocket: pausingCreateWebSocket,
1546
+ });
1547
+ const { createEvolu } = setup;
1548
+ await createEvolu({ evoluChannel });
1549
+
1550
+ evoluChannel.port2.postMessage({
1551
+ type: "UseOwner",
1552
+ actions: [
1553
+ {
1554
+ owner: {
1555
+ owner: testAppOwner,
1556
+ transports: [transportA, transportB],
1557
+ },
1558
+ action: "add",
1559
+ },
1560
+ ],
1561
+ });
1562
+
1563
+ await firstCreateStarted.promise;
1564
+ await testWaitForWorkerMessage();
1565
+ allowFirstCreateToFinish.resolve();
1566
+ await testWaitForWorkerMessage();
1567
+ await testWaitForWorkerMessage();
1568
+
1569
+ assertLength(createWebSocket.sentMessages, 2);
1570
+ assertEqual(
1571
+ new Set(createWebSocket.sentMessages),
1572
+ new Set([
1573
+ {
1574
+ url: transportA.url,
1575
+ data: createProtocolMessageForUnsubscribe(testAppOwner.id),
1576
+ },
1577
+ {
1578
+ url: transportB.url,
1579
+ data: createProtocolMessageForUnsubscribe(testAppOwner.id),
1580
+ },
1581
+ ]),
1582
+ );
1583
+ });
1584
+ });
1585
+
1586
+ describe("tab leader changes", () => {
1587
+ it("starts a new DbWorker when tab leader changes", async () => {
1588
+ await using setup = await setupSharedWorker();
1589
+ const { createEvolu, worker } = setup;
1590
+ const { dbDisposeInputs, releaseDbWorkerLeader } = await createEvolu({
1591
+ releaseDbWorkerLeaderOnDispose: false,
1592
+ });
1593
+
1594
+ await releaseDbWorkerLeader();
1595
+
1596
+ using tabLeaderChannel = testCreateMessageChannel<
1597
+ SharedWorkerInput,
1598
+ SharedWorkerOutput
1599
+ >();
1600
+ const tabLeaderOutputs: Array<SharedWorkerOutput> = [];
1601
+ tabLeaderChannel.port1.onMessage = (output) => {
1602
+ tabLeaderOutputs.push(output);
1603
+ };
1604
+
1605
+ assertNonNullable(worker.self.onConnect);
1606
+ worker.self.onConnect(tabLeaderChannel.port2);
1607
+ tabLeaderChannel.port1.postMessage({
1608
+ type: "AnnounceTabLeader",
1609
+ consoleLevel: "debug",
1610
+ });
1611
+ await testWaitForWorkerMessage();
1612
+ await testWaitForWorkerMessage();
1613
+
1614
+ assertEqual(dbDisposeInputs, []);
1615
+ const initDbWorker = tabLeaderOutputs[0];
1616
+ assertSame(initDbWorker?.type, "DbWorkerInit");
1617
+ assertSame(initDbWorker.name, testName);
1618
+ });
1619
+
1620
+ it("retries in-flight request when new DbWorker leader is acquired", async () => {
1621
+ await using setup = await setupSharedWorker();
1622
+ const { createEvolu, worker } = setup;
1623
+ const {
1624
+ dbInputs,
1625
+ dbWorkerPort: oldDbWorkerPort,
1626
+ evoluChannel,
1627
+ id,
1628
+ releaseDbWorkerLeader,
1629
+ } = await createEvolu({
1630
+ releaseDbWorkerLeaderOnDispose: false,
1631
+ });
1632
+ const evoluOutputs: Array<EvoluOutput> = [];
1633
+ evoluChannel.port2.onMessage = (output) => {
1634
+ evoluOutputs.push(output);
1635
+ };
1636
+
1637
+ evoluChannel.port2.postMessage({
1638
+ type: "Query",
1639
+ queries: createSet([testQuery]),
1640
+ });
1641
+
1642
+ await testWaitForWorkerMessage();
1643
+
1644
+ const firstInput = dbInputs[0];
1645
+ assertNotUndefined(firstInput);
1646
+
1647
+ await releaseDbWorkerLeader();
1648
+
1649
+ using tabLeaderChannel = testCreateMessageChannel<
1650
+ SharedWorkerInput,
1651
+ SharedWorkerOutput
1652
+ >();
1653
+ const tabLeaderOutputs: Array<SharedWorkerOutput> = [];
1654
+ tabLeaderChannel.port1.onMessage = (output) => {
1655
+ tabLeaderOutputs.push(output);
1656
+ };
1657
+
1658
+ assertNonNullable(worker.self.onConnect);
1659
+ worker.self.onConnect(tabLeaderChannel.port2);
1660
+ tabLeaderChannel.port1.postMessage({
1661
+ type: "AnnounceTabLeader",
1662
+ consoleLevel: "debug",
1663
+ });
1664
+ await testWaitForWorkerMessage();
1665
+ await testWaitForWorkerMessage();
1666
+
1667
+ const initDbWorker = tabLeaderOutputs.at(0);
1668
+ assertNotUndefined(initDbWorker);
1669
+
1670
+ using dbWorkerPort = testCreateMessagePort<DbWorkerOutput, DbWorkerInput>(
1671
+ initDbWorker.port,
1672
+ );
1673
+ const nextDbInputs: Array<Exclude<DbWorkerInput, { type: "Dispose" }>> =
1674
+ [];
1675
+ dbWorkerPort.onMessage = (input) => {
1676
+ if (input.type !== "Dispose") nextDbInputs.push(input);
1677
+ };
1678
+
1679
+ dbWorkerPort.postMessage({
1680
+ type: "LeaderAcquired",
1681
+ name: testName,
1682
+ });
1683
+ await testWaitForWorkerMessage();
1684
+
1685
+ assertLength(nextDbInputs, 1);
1686
+ const [nextInput] = nextDbInputs;
1687
+ assertEqual(nextInput, {
1688
+ type: "Request",
1689
+ callbackId: nextInput.callbackId,
1690
+ request: {
1691
+ type: "ForEvolu",
1692
+ id,
1693
+ message: {
1694
+ type: "Query",
1695
+ queries: createSet([testQuery]),
1696
+ },
1697
+ },
1698
+ });
1699
+ assertFalse(Object.is(nextInput.callbackId, firstInput.callbackId));
1700
+
1701
+ oldDbWorkerPort.postMessage({
1702
+ type: "OnQueuedResponse",
1703
+ callbackId: firstInput.callbackId,
1704
+ response: {
1705
+ type: "ForEvolu",
1706
+ id,
1707
+ message: {
1708
+ type: "Query",
1709
+ rowsByQuery: new Map([[testQuery, []]]),
1710
+ },
1711
+ },
1712
+ });
1713
+ await testWaitForWorkerMessage();
1714
+
1715
+ assertEqual(evoluOutputs, []);
1716
+ });
1717
+ });
1718
+
1719
+ describe("disposal", () => {
1720
+ it("removes owner claims when tenant is disposed with a live instance", async () => {
1721
+ const createWebSocket = testCreateWebSocket();
1722
+ await using setup = await setupSharedWorker({ createWebSocket });
1723
+ const { createEvolu } = setup;
1724
+ const { evoluChannel } = await createEvolu({
1725
+ autoDispose: false,
1726
+ });
1727
+ const transport = createOwnerWebSocketTransport({
1728
+ url: "wss://tenant-dispose.example",
1729
+ ownerId: testAppOwner.id,
1730
+ });
1731
+
1732
+ evoluChannel.port2.postMessage({
1733
+ type: "UseOwner",
1734
+ actions: [
1735
+ {
1736
+ owner: { owner: testAppOwner, transports: [transport] },
1737
+ action: "add",
1738
+ },
1739
+ ],
1740
+ });
1741
+ await testWaitForWorkerMessage();
1742
+
1743
+ assertEqual(createWebSocket.createdUrls, [transport.url]);
1744
+
1745
+ await setup[Symbol.asyncDispose]();
1746
+ await testWaitForWorkerMessage();
1747
+
1748
+ assertEqual(createWebSocket.sentMessages.at(-1), {
1749
+ url: transport.url,
1750
+ data: createProtocolMessageForUnsubscribe(testAppOwner.id),
1751
+ });
1752
+ });
1753
+
1754
+ it("waits for DbWorker leader lock during tenant disposal", async () => {
1755
+ await using setup = await setupSharedWorker();
1756
+ const { createEvolu } = setup;
1757
+ const { dbDisposeInputs, releaseDbWorkerLeader } = await createEvolu({
1758
+ releaseDbWorkerLeaderOnDispose: false,
1759
+ autoDispose: false,
1760
+ });
1761
+ let disposed = false;
1762
+
1763
+ const disposing = setup[Symbol.asyncDispose]().then(() => {
1764
+ disposed = true;
1765
+ });
1766
+
1767
+ await testWaitForWorkerMessage();
1768
+
1769
+ assertEqual(dbDisposeInputs, [{ type: "Dispose" }]);
1770
+ assertFalse(disposed);
1771
+
1772
+ await releaseDbWorkerLeader();
1773
+ await disposing;
1774
+
1775
+ assertTrue(disposed);
1776
+ });
1777
+
1778
+ it("drops UseOwner messages posted after instance disposal starts", async () => {
1779
+ const createWebSocket = testCreateWebSocket();
1780
+ await using setup = await setupSharedWorker({ createWebSocket });
1781
+ const { createEvolu } = setup;
1782
+ const { evoluChannel, [Symbol.asyncDispose]: disposeInstance } =
1783
+ await createEvolu();
1784
+ const transport = createOwnerWebSocketTransport({
1785
+ url: "wss://after-dispose.example",
1786
+ ownerId: testAppOwner.id,
1787
+ });
1788
+
1789
+ const disposed = disposeInstance();
1790
+
1791
+ evoluChannel.port2.postMessage({
1792
+ type: "UseOwner",
1793
+ actions: [
1794
+ {
1795
+ owner: { owner: testAppOwner, transports: [transport] },
1796
+ action: "add",
1797
+ },
1798
+ ],
1799
+ });
1800
+
1801
+ await disposed;
1802
+ await testWaitForWorkerMessage();
1803
+
1804
+ assertEqual(createWebSocket.createdUrls, []);
1805
+ });
1806
+
1807
+ it("releases pending instance lock if tenant disposal wins the acquisition race", async () => {
1808
+ await using setup = await setupSharedWorker();
1809
+ const { createEvolu } = setup;
1810
+ const { [Symbol.asyncDispose]: disposeInstance } = await createEvolu({
1811
+ autoDispose: false,
1812
+ });
1813
+
1814
+ const disposeSetup = setup[Symbol.asyncDispose]();
1815
+ await testWaitForWorkerMessage();
1816
+ await disposeInstance();
1817
+ await disposeSetup;
1818
+ });
1819
+ });
1820
+ });
1821
+
1822
+ describe("with multiple evolu instances", () => {
1823
+ it.todo("coordinates shared tenant state across instances");
1824
+ });
1825
+
1826
+ describe("with multiple tabs", () => {
1827
+ it.todo("coordinates tab leader changes across connected tabs");
1828
+ });