@spooky-sync/core 0.0.1-canary.29 → 0.0.1-canary.291

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 (257) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2026 -137
  4. package/dist/index.js +14941 -2433
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +709 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +329 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +358 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +472 -0
  18. package/dist/types.d.ts +1765 -38
  19. package/dist/worker-edges.js +423 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +46 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +27 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +172 -0
  27. package/skills/sp00ky-core/SKILL.md +31 -6
  28. package/skills/sp00ky-core/references/config.md +6 -0
  29. package/src/boot/auth-flip.saga.test.ts +82 -0
  30. package/src/boot/auth-flip.saga.ts +62 -0
  31. package/src/boot/boot.saga.test.ts +158 -0
  32. package/src/boot/boot.saga.ts +131 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +51 -0
  36. package/src/boot/preload.saga.ts +31 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +117 -0
  40. package/src/client/bucket-handle.ts +227 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.ts +370 -0
  46. package/src/client/sp00ky-client.test.ts +303 -0
  47. package/src/client/sp00ky-client.ts +586 -0
  48. package/src/events/events.test.ts +2 -1
  49. package/src/events/index.ts +3 -0
  50. package/src/index.ts +69 -1
  51. package/src/kernel/constants.test.ts +19 -0
  52. package/src/kernel/constants.ts +80 -0
  53. package/src/kernel/effects.test.ts +40 -0
  54. package/src/kernel/effects.ts +161 -0
  55. package/src/kernel/events.ts +57 -0
  56. package/src/kernel/interpreter.test.ts +116 -0
  57. package/src/kernel/interpreter.ts +148 -0
  58. package/src/kernel/saga.test.ts +89 -0
  59. package/src/kernel/saga.ts +73 -0
  60. package/src/live/index.test.ts +478 -0
  61. package/src/live/index.ts +1013 -0
  62. package/src/modules/app-release/index.test.ts +125 -0
  63. package/src/modules/app-release/index.ts +209 -0
  64. package/src/modules/auth/auth.local-first.test.ts +141 -0
  65. package/src/modules/auth/events/index.ts +9 -2
  66. package/src/modules/auth/impersonation-banner.ts +443 -0
  67. package/src/modules/auth/impersonation.test.ts +355 -0
  68. package/src/modules/auth/impersonation.ts +179 -0
  69. package/src/modules/auth/index.ts +386 -17
  70. package/src/modules/crdt/crdt-field.ts +294 -0
  71. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  72. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  73. package/src/modules/crdt/index.ts +463 -0
  74. package/src/modules/crdt/loro-loader.ts +25 -0
  75. package/src/modules/devtools/flags.ts +349 -0
  76. package/src/modules/devtools/index.ts +750 -96
  77. package/src/modules/devtools/mutations.test.ts +92 -0
  78. package/src/modules/devtools/mutations.ts +160 -0
  79. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  80. package/src/modules/devtools/state-shape.test.ts +263 -0
  81. package/src/modules/devtools/storage-info.test.ts +79 -0
  82. package/src/modules/devtools/storage-info.ts +168 -0
  83. package/src/modules/devtools/versions.test.ts +74 -0
  84. package/src/modules/devtools/versions.ts +110 -0
  85. package/src/modules/feature-flag/index.test.ts +251 -0
  86. package/src/modules/feature-flag/index.ts +316 -0
  87. package/src/modules/ref-tables.test.ts +91 -0
  88. package/src/modules/ref-tables.ts +88 -0
  89. package/src/modules/web-push/index.test.ts +824 -0
  90. package/src/modules/web-push/index.ts +1027 -0
  91. package/src/mutation/jobs.test.ts +25 -0
  92. package/src/mutation/jobs.ts +41 -0
  93. package/src/mutation/mutation-id.test.ts +37 -0
  94. package/src/mutation/mutation-id.ts +35 -0
  95. package/src/mutation/pending-writes.integration.test.ts +175 -0
  96. package/src/mutation/push.saga.test.ts +285 -0
  97. package/src/mutation/push.saga.ts +217 -0
  98. package/src/mutation/rows.test.ts +231 -0
  99. package/src/mutation/rows.ts +427 -0
  100. package/src/mutation/tray.saga.test.ts +63 -0
  101. package/src/mutation/tray.saga.ts +48 -0
  102. package/src/mutation/write.saga.test.ts +316 -0
  103. package/src/mutation/write.saga.ts +268 -0
  104. package/src/otel/index.ts +10 -7
  105. package/src/pure.ts +65 -0
  106. package/src/push/types.ts +174 -0
  107. package/src/query/env.test.ts +23 -0
  108. package/src/query/env.ts +56 -0
  109. package/src/query/fetch.saga.test.ts +199 -0
  110. package/src/query/fetch.saga.ts +120 -0
  111. package/src/query/hash.test.ts +14 -0
  112. package/src/query/hash.ts +18 -0
  113. package/src/query/id-set-plan.test.ts +132 -0
  114. package/src/query/lifecycle.saga.test.ts +274 -0
  115. package/src/query/lifecycle.saga.ts +159 -0
  116. package/src/query/materialize.saga.test.ts +115 -0
  117. package/src/query/materialize.saga.ts +84 -0
  118. package/src/query/materialize.test.ts +58 -0
  119. package/src/query/materialize.ts +57 -0
  120. package/src/query/membership.saga.test.ts +350 -0
  121. package/src/query/membership.saga.ts +278 -0
  122. package/src/query/membership.test.ts +118 -0
  123. package/src/query/membership.ts +201 -0
  124. package/src/query/register.saga.test.ts +293 -0
  125. package/src/query/register.saga.ts +226 -0
  126. package/src/query/render-set.test.ts +47 -0
  127. package/src/query/render-set.ts +71 -0
  128. package/src/query/sql.test.ts +63 -0
  129. package/src/query/sql.ts +93 -0
  130. package/src/query/window-query.test.ts +70 -0
  131. package/src/query/window-query.ts +195 -0
  132. package/src/services/blobs/blob-cache.test.ts +359 -0
  133. package/src/services/blobs/blob-cache.ts +603 -0
  134. package/src/services/blobs/blob-manifest.ts +227 -0
  135. package/src/services/blobs/blob-store.test.ts +77 -0
  136. package/src/services/blobs/blob-store.ts +359 -0
  137. package/src/services/blobs/blob.fixture.ts +90 -0
  138. package/src/services/blobs/index.ts +70 -0
  139. package/src/services/database/cache-engine.ts +199 -0
  140. package/src/services/database/connection-supervisor.test.ts +318 -0
  141. package/src/services/database/connection-supervisor.ts +422 -0
  142. package/src/services/database/database.query-timeout.test.ts +140 -0
  143. package/src/services/database/database.responses.test.ts +43 -0
  144. package/src/services/database/database.ts +146 -61
  145. package/src/services/database/engine-factory.ts +33 -0
  146. package/src/services/database/errors.ts +34 -0
  147. package/src/services/database/events/index.ts +2 -1
  148. package/src/services/database/index.ts +7 -0
  149. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  150. package/src/services/database/local-migrator.integration.test.ts +110 -0
  151. package/src/services/database/local-migrator.ts +59 -16
  152. package/src/services/database/local.test.ts +64 -0
  153. package/src/services/database/local.ts +487 -63
  154. package/src/services/database/plan-render.test.ts +159 -0
  155. package/src/services/database/plan-render.ts +108 -0
  156. package/src/services/database/relation-resolver.test.ts +467 -0
  157. package/src/services/database/relation-resolver.ts +0 -0
  158. package/src/services/database/remote.ts +213 -6
  159. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  160. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  161. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  162. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  163. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  164. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  165. package/src/services/database/sqlite-lock-verify.ts +45 -0
  166. package/src/services/database/sqlite-open.test.ts +150 -0
  167. package/src/services/database/sqlite-open.ts +164 -0
  168. package/src/services/database/sqlite-plan-sql.test.ts +139 -0
  169. package/src/services/database/sqlite-plan-sql.ts +179 -0
  170. package/src/services/database/sqlite-projection.test.ts +121 -0
  171. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  172. package/src/services/database/sqlite-select.test.ts +246 -0
  173. package/src/services/database/sqlite-select.ts +131 -0
  174. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  175. package/src/services/database/sqlite-transport.ts +224 -0
  176. package/src/services/database/sqlite-worker.ts +437 -0
  177. package/src/services/database/surql-translate.ts +416 -0
  178. package/src/services/database/surreal-cache-engine.ts +161 -0
  179. package/src/services/logger/index.test.ts +132 -0
  180. package/src/services/logger/index.ts +189 -7
  181. package/src/services/persistence/localstorage.ts +2 -2
  182. package/src/services/persistence/resilient.ts +10 -3
  183. package/src/services/persistence/surrealdb.ts +4 -4
  184. package/src/services/stream-processor/index.ts +779 -67
  185. package/src/services/stream-processor/permissions.test.ts +47 -0
  186. package/src/services/stream-processor/permissions.ts +53 -0
  187. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  188. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  189. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  190. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  191. package/src/services/stream-processor/wasm-types.ts +58 -2
  192. package/src/services/tabs/broker-client.ts +283 -0
  193. package/src/services/tabs/broker.test.ts +327 -0
  194. package/src/services/tabs/coordinator.test.ts +386 -0
  195. package/src/services/tabs/coordinator.ts +638 -0
  196. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  197. package/src/services/tabs/leader-locks.ts +75 -0
  198. package/src/services/tabs/protocol.ts +262 -0
  199. package/src/services/tabs/support.ts +36 -0
  200. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  201. package/src/sp00ky.ts +5 -395
  202. package/src/state/client-state.ts +200 -0
  203. package/src/state/lifecycle.test.ts +118 -0
  204. package/src/state/lifecycle.ts +86 -0
  205. package/src/state/reducers.test.ts +304 -0
  206. package/src/state/reducers.ts +526 -0
  207. package/src/state/selectors.test.ts +167 -0
  208. package/src/state/selectors.ts +216 -0
  209. package/src/sw/index.test.ts +582 -0
  210. package/src/sw/index.ts +607 -0
  211. package/src/sw/store.ts +96 -0
  212. package/src/sync/connection.saga.test.ts +86 -0
  213. package/src/sync/connection.saga.ts +85 -0
  214. package/src/sync/live.saga.test.ts +178 -0
  215. package/src/sync/live.saga.ts +107 -0
  216. package/src/sync/policy.health.test.ts +56 -0
  217. package/src/sync/policy.test.ts +659 -0
  218. package/src/sync/policy.ts +502 -0
  219. package/src/sync/poll.saga.test.ts +51 -0
  220. package/src/sync/poll.saga.ts +36 -0
  221. package/src/sync/tabs.saga.test.ts +78 -0
  222. package/src/sync/tabs.saga.ts +96 -0
  223. package/src/testing/adapters.ts +133 -0
  224. package/src/testing/build.ts +63 -0
  225. package/src/testing/fake-services.ts +48 -0
  226. package/src/testing/run-pure.test.ts +92 -0
  227. package/src/testing/run-pure.ts +128 -0
  228. package/src/testing/services.ts +17 -0
  229. package/src/types.ts +624 -10
  230. package/src/utils/blurhash.ts +90 -0
  231. package/src/utils/edges.ts +39 -0
  232. package/src/utils/error-classification.test.ts +54 -0
  233. package/src/utils/error-classification.ts +25 -0
  234. package/src/utils/index.ts +75 -9
  235. package/src/utils/parser.test.ts +67 -102
  236. package/src/utils/parser.ts +64 -2
  237. package/src/utils/semver.test.ts +32 -0
  238. package/src/utils/semver.ts +30 -0
  239. package/src/utils/sha256.ts +15 -0
  240. package/src/utils/surql.ts +30 -18
  241. package/src/utils/utils.test.ts +2 -1
  242. package/tsdown.config.ts +117 -3
  243. package/vitest.config.ts +48 -0
  244. package/src/modules/cache/index.ts +0 -241
  245. package/src/modules/cache/types.ts +0 -19
  246. package/src/modules/data/data.test.ts +0 -58
  247. package/src/modules/data/index.ts +0 -879
  248. package/src/modules/sync/engine.ts +0 -173
  249. package/src/modules/sync/events/index.ts +0 -77
  250. package/src/modules/sync/index.ts +0 -3
  251. package/src/modules/sync/queue/index.ts +0 -2
  252. package/src/modules/sync/queue/queue-down.ts +0 -89
  253. package/src/modules/sync/queue/queue-up.ts +0 -223
  254. package/src/modules/sync/scheduler.ts +0 -84
  255. package/src/modules/sync/sync.ts +0 -421
  256. package/src/modules/sync/utils.test.ts +0 -311
  257. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,582 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ installPushHandlers,
4
+ getBridgedToken,
5
+ memoryStore,
6
+ notificationOptions,
7
+ DEFAULT_PLACEHOLDER,
8
+ type SwScope,
9
+ } from './index';
10
+ import type { PushPayload } from '../push/types';
11
+ import { base64UrlToBytes, bytesToBase64Url } from '../push/types';
12
+
13
+ function jwt(claims: Record<string, unknown>): string {
14
+ const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url');
15
+ return `${b64({ alg: 'HS512' })}.${b64(claims)}.sig`;
16
+ }
17
+ const token = jwt({ ID: 'user:alice', exp: Math.floor(Date.now() / 1000) + 3600 });
18
+ const KEY = bytesToBase64Url(new Uint8Array([4, 1, 2, 3, 4, 5]));
19
+
20
+ interface FakeWindow {
21
+ url: string;
22
+ visibilityState: string;
23
+ focused?: boolean;
24
+ focus: ReturnType<typeof vi.fn>;
25
+ navigate: ReturnType<typeof vi.fn>;
26
+ postMessage: ReturnType<typeof vi.fn>;
27
+ }
28
+
29
+ function win(url: string, visibilityState = 'hidden', navigateFails = false): FakeWindow {
30
+ return {
31
+ url,
32
+ visibilityState,
33
+ focus: vi.fn(async () => undefined),
34
+ navigate: vi.fn(async () => {
35
+ if (navigateFails) throw new TypeError('not controlled');
36
+ }),
37
+ postMessage: vi.fn(),
38
+ };
39
+ }
40
+
41
+ function fakeScope() {
42
+ const listeners = new Map<string, (e: any) => void>();
43
+ const shown: Array<{ title: string; options: any; closed: boolean }> = [];
44
+ const windows: FakeWindow[] = [];
45
+ const subscribe = vi.fn(async (_o: unknown) => ({
46
+ endpoint: 'https://push.example/new',
47
+ toJSON: () => ({ endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } }),
48
+ }));
49
+ const scope = {
50
+ registration: {
51
+ scope: 'https://app.example/',
52
+ showNotification: vi.fn(async (title: string, options: any = {}) => {
53
+ for (const n of shown)
54
+ if (!n.closed && options.tag && n.options.tag === options.tag) n.closed = true;
55
+ shown.push({ title, options, closed: false });
56
+ }),
57
+ getNotifications: async () =>
58
+ shown
59
+ .filter((n) => !n.closed)
60
+ .map((n) => ({
61
+ title: n.title,
62
+ tag: n.options.tag ?? '',
63
+ body: n.options.body,
64
+ data: n.options.data,
65
+ close: () => void (n.closed = true),
66
+ })),
67
+ pushManager: { getSubscription: async () => null, subscribe },
68
+ },
69
+ clients: { matchAll: vi.fn(async () => windows), openWindow: vi.fn(async () => null) },
70
+ location: { origin: 'https://app.example' },
71
+ navigator: { userAgent: 'sw-ua' },
72
+ addEventListener: (type: string, l: (e: any) => void) => void listeners.set(type, l),
73
+ removeEventListener: (type: string) => void listeners.delete(type),
74
+ };
75
+ const dispatch = async (type: string, event: Record<string, unknown> = {}) => {
76
+ let work: Promise<unknown> | undefined;
77
+ listeners.get(type)!({ ...event, waitUntil: (p: Promise<unknown>) => void (work = p) });
78
+ await work;
79
+ };
80
+ const open = () => shown.filter((n) => !n.closed);
81
+ return {
82
+ scope: scope as unknown as SwScope,
83
+ raw: scope,
84
+ shown,
85
+ open,
86
+ windows,
87
+ dispatch,
88
+ listeners,
89
+ subscribe,
90
+ };
91
+ }
92
+
93
+ const pushEvent = (payload: unknown) => ({
94
+ data: { json: () => payload, text: () => JSON.stringify(payload) },
95
+ });
96
+
97
+ const content: PushPayload = {
98
+ v: 1,
99
+ kind: 'rule',
100
+ rule: 'new-message',
101
+ table: 'message',
102
+ id: 'message:1',
103
+ op: 'create',
104
+ topic: 'dm:1',
105
+ notification: {
106
+ title: 'Ada',
107
+ body: 'hi there',
108
+ url: '/m/1',
109
+ icon: '/i.png',
110
+ vibrate: [100, 50],
111
+ actions: [{ action: 'reply', title: 'Reply', url: '/m/1/reply' }],
112
+ data: { k: 1 },
113
+ },
114
+ data: { conversation: 'conversation:1' },
115
+ ts: 1,
116
+ };
117
+ const nudge: PushPayload = {
118
+ v: 1,
119
+ kind: 'rule',
120
+ rule: 'feed',
121
+ table: 'notification',
122
+ id: 'notification:9',
123
+ op: 'create',
124
+ ts: 1,
125
+ };
126
+
127
+ function fakeFeed(rows: Record<string, any>[], opts: { fail?: boolean } = {}) {
128
+ const feed = {
129
+ closed: false,
130
+ subscribe: vi.fn((_q: unknown, h: { onSet(r: unknown[]): void }) => {
131
+ if (!opts.fail) h.onSet(rows);
132
+ return {
133
+ ready: opts.fail ? Promise.reject(new Error('register failed')) : Promise.resolve(rows),
134
+ rows: () => rows,
135
+ key: 'k',
136
+ unsubscribe: async () => {},
137
+ };
138
+ }),
139
+ idle: vi.fn(async () => true),
140
+ close: vi.fn(async () => void (feed.closed = true)),
141
+ query: vi.fn(async (sql: string, _vars?: unknown) =>
142
+ sql.includes('fn::push::list')
143
+ ? [[{ endpoint: 'https://push.example/old', label: 'Laptop', rules: ['dm'] }]]
144
+ : ['ok']
145
+ ),
146
+ connect: async () => {},
147
+ userId: 'user:alice',
148
+ listRefTable: '_00_list_ref_user_alice',
149
+ };
150
+ return feed;
151
+ }
152
+
153
+ describe('installPushHandlers: push', () => {
154
+ it('shows a content push with every option, tag defaulting to the topic, url and payload in data', async () => {
155
+ const f = fakeScope();
156
+ installPushHandlers({ scope: f.scope, store: memoryStore() });
157
+ await f.dispatch('push', pushEvent(content));
158
+ expect(f.raw.registration.showNotification).toHaveBeenCalledTimes(1);
159
+ const [title, options] = f.raw.registration.showNotification.mock.calls[0];
160
+ expect(title).toBe('Ada');
161
+ expect(options).toEqual({
162
+ body: 'hi there',
163
+ icon: '/i.png',
164
+ vibrate: [100, 50],
165
+ tag: 'dm:1',
166
+ actions: [{ action: 'reply', title: 'Reply' }],
167
+ data: { k: 1, url: '/m/1', payload: content, actionUrls: { reply: '/m/1/reply' } },
168
+ });
169
+ });
170
+
171
+ it('posts to a visible window instead of showing; shows anyway with suppressWhenVisible: false', async () => {
172
+ const f = fakeScope();
173
+ const visible = win('https://app.example/inbox', 'visible');
174
+ const foreign = win('https://evil.example/', 'visible');
175
+ f.windows.push(visible, foreign, win('https://app.example/other'));
176
+ installPushHandlers({ scope: f.scope, store: memoryStore() });
177
+ await f.dispatch('push', pushEvent(content));
178
+ expect(visible.postMessage).toHaveBeenCalledWith({ type: 'sp00ky:push', payload: content });
179
+ expect(foreign.postMessage).not.toHaveBeenCalled();
180
+ expect(f.raw.registration.showNotification).not.toHaveBeenCalled();
181
+
182
+ const g = fakeScope();
183
+ g.windows.push(win('https://app.example/inbox', 'visible'));
184
+ installPushHandlers({ scope: g.scope, store: memoryStore(), suppressWhenVisible: false });
185
+ await g.dispatch('push', pushEvent(content));
186
+ expect(g.raw.registration.showNotification).toHaveBeenCalledTimes(1);
187
+ });
188
+
189
+ it('shows the placeholder for a nudge nobody renders, an unknown version and garbage', async () => {
190
+ const f = fakeScope();
191
+ installPushHandlers({
192
+ scope: f.scope,
193
+ store: memoryStore(),
194
+ placeholder: { title: 'WhitePawn', body: 'New activity' },
195
+ });
196
+ await f.dispatch('push', pushEvent(nudge));
197
+ await f.dispatch('push', pushEvent({ ...content, v: 2 }));
198
+ await f.dispatch('push', { data: { json: () => JSON.parse('{') } });
199
+ await f.dispatch('push', { data: null });
200
+ expect(f.raw.registration.showNotification).toHaveBeenCalledTimes(4);
201
+ for (const [title, options] of f.raw.registration.showNotification.mock.calls) {
202
+ expect(title).toBe('WhitePawn');
203
+ expect(options.tag).toBe('sp00ky-placeholder');
204
+ }
205
+ const h = fakeScope();
206
+ installPushHandlers({ scope: h.scope, store: memoryStore() });
207
+ await h.dispatch('push', pushEvent(nudge));
208
+ expect(h.raw.registration.showNotification.mock.calls[0][0]).toBe(DEFAULT_PLACEHOLDER.title);
209
+ });
210
+
211
+ it('onPush can take over; onNudge and render handle nudges; a throwing handler falls back to the placeholder', async () => {
212
+ const f = fakeScope();
213
+ const onPush = vi.fn(async () => true);
214
+ installPushHandlers({ scope: f.scope, store: memoryStore(), onPush });
215
+ await f.dispatch('push', pushEvent(content));
216
+ expect(onPush).toHaveBeenCalledWith(
217
+ content,
218
+ expect.objectContaining({ registration: f.raw.registration })
219
+ );
220
+ expect(f.raw.registration.showNotification).not.toHaveBeenCalled();
221
+
222
+ const g = fakeScope();
223
+ installPushHandlers({
224
+ scope: g.scope,
225
+ store: memoryStore(),
226
+ onNudge: async (p, ctx) => ctx.show({ title: `nudge ${p.rule}`, tag: 'n' }),
227
+ });
228
+ await g.dispatch('push', pushEvent(nudge));
229
+ expect(g.raw.registration.showNotification.mock.calls[0][0]).toBe('nudge feed');
230
+
231
+ const h = fakeScope();
232
+ installPushHandlers({
233
+ scope: h.scope,
234
+ store: memoryStore(),
235
+ render: (p) => (p.notification ? null : { title: `rendered ${p.id}` }),
236
+ });
237
+ await h.dispatch('push', pushEvent(nudge));
238
+ await h.dispatch('push', pushEvent(content));
239
+ expect(h.raw.registration.showNotification.mock.calls.map((c) => c[0])).toEqual([
240
+ 'rendered notification:9',
241
+ 'Ada',
242
+ ]);
243
+
244
+ const k = fakeScope();
245
+ installPushHandlers({
246
+ scope: k.scope,
247
+ store: memoryStore(),
248
+ onNudge: async () => {
249
+ throw new Error('boom');
250
+ },
251
+ });
252
+ await k.dispatch('push', pushEvent(nudge));
253
+ expect(k.raw.registration.showNotification.mock.calls[0][1].tag).toBe('sp00ky-placeholder');
254
+ });
255
+
256
+ it('renders a nudge from live data: one notification per visible row, stale tags closed, feed closed', async () => {
257
+ const f = fakeScope();
258
+ const store = memoryStore();
259
+ await store.set('token', {
260
+ token,
261
+ userId: 'user:alice',
262
+ endpoint: 'wss://db/rpc',
263
+ namespace: 'n',
264
+ database: 'd',
265
+ at: 1,
266
+ });
267
+ // A notification the live renderer showed earlier for a row that is now read.
268
+ await f.raw.registration.showNotification('old', { tag: 'n:old', data: { sp00kyLive: true } });
269
+ await f.raw.registration.showNotification('unrelated', { tag: 'other' });
270
+ const rows = [
271
+ { id: 'notification:1', title: 'A', read: false },
272
+ { id: 'notification:2', title: 'B', read: true },
273
+ { id: 'notification:3', title: 'C', read: false },
274
+ ];
275
+ const feed = fakeFeed(rows);
276
+ const factory = vi.fn(() => feed);
277
+ const query = vi.fn((userId: string) => ({
278
+ surql: 'SELECT * FROM notification WHERE user = $u',
279
+ params: { u: userId },
280
+ }));
281
+ installPushHandlers({
282
+ scope: f.scope,
283
+ store,
284
+ live: {
285
+ feed: factory as never,
286
+ query,
287
+ visible: (r) => !r.read,
288
+ render: (r) => ({
289
+ title: r.title,
290
+ body: String(r.id),
291
+ tag: `n:${r.id}`,
292
+ url: `/n/${r.id}`,
293
+ }),
294
+ },
295
+ });
296
+ await f.dispatch('push', pushEvent(nudge));
297
+ expect(factory).toHaveBeenCalledWith(
298
+ expect.objectContaining({ token, userId: 'user:alice', endpoint: 'wss://db/rpc' })
299
+ );
300
+ expect(query).toHaveBeenCalledWith('user:alice', nudge);
301
+ expect(feed.close).toHaveBeenCalled();
302
+ expect(
303
+ f
304
+ .open()
305
+ .map((n) => n.options.tag)
306
+ .sort()
307
+ ).toEqual(['n:notification:1', 'n:notification:3', 'other']);
308
+ const shownLive = f.open().find((n) => n.options.tag === 'n:notification:1')!;
309
+ expect(shownLive.options.data).toEqual({ url: '/n/notification:1', sp00kyLive: true });
310
+
311
+ // Nothing visible any more: the stale one closes, and closing was the
312
+ // push's effect, so nothing else is shown.
313
+ const g = fakeScope();
314
+ await g.raw.registration.showNotification('old', { tag: 'n:old', data: { sp00kyLive: true } });
315
+ installPushHandlers({
316
+ scope: g.scope,
317
+ store,
318
+ live: {
319
+ feed: () => fakeFeed([]) as never,
320
+ query,
321
+ visible: () => true,
322
+ render: (r: any) => ({ title: r.title, tag: 'x' }),
323
+ },
324
+ });
325
+ await g.dispatch('push', pushEvent(nudge));
326
+ expect(g.open()).toEqual([]);
327
+
328
+ // `silentClose: false` keeps a notification on screen for every push.
329
+ const h = fakeScope();
330
+ await h.raw.registration.showNotification('old', { tag: 'n:old', data: { sp00kyLive: true } });
331
+ installPushHandlers({
332
+ scope: h.scope,
333
+ store,
334
+ live: {
335
+ feed: () => fakeFeed([]) as never,
336
+ query,
337
+ visible: () => true,
338
+ render: (r: any) => ({ title: r.title, tag: 'x' }),
339
+ silentClose: false,
340
+ },
341
+ });
342
+ await h.dispatch('push', pushEvent(nudge));
343
+ expect(h.open().map((n) => n.options.tag)).toEqual(['sp00ky-placeholder']);
344
+
345
+ // Nothing to show and nothing closed: the placeholder.
346
+ const k = fakeScope();
347
+ installPushHandlers({
348
+ scope: k.scope,
349
+ store,
350
+ live: {
351
+ feed: () => fakeFeed([]) as never,
352
+ query,
353
+ visible: () => true,
354
+ render: (r: any) => ({ title: r.title, tag: 'x' }),
355
+ },
356
+ });
357
+ await k.dispatch('push', pushEvent(nudge));
358
+ expect(k.open().map((n) => n.options.tag)).toEqual(['sp00ky-placeholder']);
359
+ });
360
+
361
+ it('falls back to the placeholder when the feed fails or no session was bridged', async () => {
362
+ const store = memoryStore();
363
+ const f = fakeScope();
364
+ const live = {
365
+ feed: () => fakeFeed([], { fail: true }) as never,
366
+ query: () => ({ surql: 'SELECT * FROM x' }),
367
+ visible: () => true,
368
+ render: () => ({ title: 't', tag: 't' }),
369
+ };
370
+ installPushHandlers({ scope: f.scope, store, live });
371
+ await f.dispatch('push', pushEvent(nudge));
372
+ expect(f.open().map((n) => n.options.tag)).toEqual(['sp00ky-placeholder']);
373
+ await store.set('token', {
374
+ token,
375
+ userId: 'user:alice',
376
+ endpoint: 'wss://db/rpc',
377
+ namespace: 'n',
378
+ database: 'd',
379
+ at: 1,
380
+ });
381
+ const g = fakeScope();
382
+ installPushHandlers({ scope: g.scope, store, live });
383
+ await g.dispatch('push', pushEvent(nudge));
384
+ expect(g.open().map((n) => n.options.tag)).toEqual(['sp00ky-placeholder']);
385
+ });
386
+ });
387
+
388
+ describe('installPushHandlers: clicks', () => {
389
+ const clickEvent = (data: unknown, action = '') => {
390
+ const close = vi.fn();
391
+ return { event: { notification: { title: 't', tag: 't', data, close }, action }, close };
392
+ };
393
+
394
+ it('opens the url when no window is open', async () => {
395
+ const f = fakeScope();
396
+ installPushHandlers({ scope: f.scope, store: memoryStore() });
397
+ const { event, close } = clickEvent({ url: '/m/1', payload: content });
398
+ await f.dispatch('notificationclick', event);
399
+ expect(close).toHaveBeenCalled();
400
+ expect(f.raw.clients.openWindow).toHaveBeenCalledWith('https://app.example/m/1');
401
+ });
402
+
403
+ it('focuses an open window and navigates it; action urls win; message mode posts instead', async () => {
404
+ const f = fakeScope();
405
+ const w = win('https://app.example/inbox');
406
+ f.windows.push(w);
407
+ installPushHandlers({ scope: f.scope, store: memoryStore() });
408
+ await f.dispatch(
409
+ 'notificationclick',
410
+ clickEvent({ url: '/m/1', actionUrls: { reply: '/m/1/reply' }, payload: content }, 'reply')
411
+ .event
412
+ );
413
+ expect(w.focus).toHaveBeenCalled();
414
+ expect(w.navigate).toHaveBeenCalledWith('https://app.example/m/1/reply');
415
+ expect(f.raw.clients.openWindow).not.toHaveBeenCalled();
416
+
417
+ const g = fakeScope();
418
+ const uncontrolled = win('https://app.example/inbox', 'visible', true);
419
+ g.windows.push(uncontrolled);
420
+ installPushHandlers({ scope: g.scope, store: memoryStore() });
421
+ await g.dispatch('notificationclick', clickEvent({ url: '/m/2', payload: content }).event);
422
+ expect(uncontrolled.postMessage).toHaveBeenCalledWith({
423
+ type: 'sp00ky:navigate',
424
+ url: 'https://app.example/m/2',
425
+ payload: content,
426
+ action: undefined,
427
+ });
428
+
429
+ const h = fakeScope();
430
+ const spa = win('https://app.example/inbox', 'visible');
431
+ h.windows.push(spa);
432
+ installPushHandlers({
433
+ scope: h.scope,
434
+ store: memoryStore(),
435
+ clickMode: 'message',
436
+ resolveUrl: (u) => `https://app.example/#${u}`,
437
+ });
438
+ await h.dispatch('notificationclick', clickEvent({ url: '/m/3' }).event);
439
+ expect(spa.navigate).not.toHaveBeenCalled();
440
+ expect(spa.postMessage).toHaveBeenCalledWith({
441
+ type: 'sp00ky:navigate',
442
+ url: 'https://app.example/#/m/3',
443
+ payload: undefined,
444
+ action: undefined,
445
+ });
446
+ });
447
+
448
+ it('onClick can take over; onClose sees the payload', async () => {
449
+ const f = fakeScope();
450
+ const onClick = vi.fn(async () => true);
451
+ const onClose = vi.fn();
452
+ installPushHandlers({ scope: f.scope, store: memoryStore(), onClick, onClose });
453
+ const { event, close } = clickEvent({ url: '/x', payload: content }, 'reply');
454
+ await f.dispatch('notificationclick', event);
455
+ expect(onClick).toHaveBeenCalledWith(expect.anything(), content, 'reply');
456
+ expect(close).not.toHaveBeenCalled();
457
+ expect(f.raw.clients.openWindow).not.toHaveBeenCalled();
458
+ await f.dispatch('notificationclose', clickEvent({ payload: content }).event);
459
+ expect(onClose).toHaveBeenCalledWith(expect.anything(), content);
460
+ });
461
+ });
462
+
463
+ describe('installPushHandlers: token bridge', () => {
464
+ const tokenMessage = {
465
+ type: 'sp00ky:token',
466
+ token,
467
+ userId: 'user:alice',
468
+ endpoint: 'wss://db/rpc',
469
+ namespace: 'n',
470
+ database: 'd',
471
+ publicKey: KEY,
472
+ };
473
+
474
+ it('stores the bridged session, ignores foreign origins, wipes it and every notification on sign-out', async () => {
475
+ const f = fakeScope();
476
+ const store = memoryStore();
477
+ installPushHandlers({ scope: f.scope, store });
478
+ await f.dispatch('message', {
479
+ data: { ...tokenMessage, token: 'foreign' },
480
+ origin: 'https://evil.example',
481
+ });
482
+ expect(await getBridgedToken(store)).toBeNull();
483
+ await f.dispatch('message', { data: tokenMessage, origin: 'https://app.example' });
484
+ expect(await getBridgedToken(store)).toMatchObject({
485
+ token,
486
+ userId: 'user:alice',
487
+ endpoint: 'wss://db/rpc',
488
+ namespace: 'n',
489
+ database: 'd',
490
+ publicKey: KEY,
491
+ });
492
+ await f.dispatch('message', { data: { type: 'sp00ky:token', token: 'x' } });
493
+ expect((await getBridgedToken(store))!.token).toBe(token);
494
+
495
+ await f.dispatch('push', pushEvent(content));
496
+ expect(f.open()).toHaveLength(1);
497
+ await f.dispatch('message', {
498
+ data: { type: 'sp00ky:signout' },
499
+ origin: 'https://app.example',
500
+ });
501
+ expect(await getBridgedToken(store)).toBeNull();
502
+ expect(f.open()).toHaveLength(0);
503
+ });
504
+
505
+ it('treats an expired bridged token as none', async () => {
506
+ const store = memoryStore();
507
+ await store.set('token', {
508
+ token: jwt({ ID: 'user:a', exp: 1 }),
509
+ userId: 'user:a',
510
+ endpoint: 'e',
511
+ namespace: 'n',
512
+ database: 'd',
513
+ at: 1,
514
+ });
515
+ expect(await getBridgedToken(store)).toBeNull();
516
+ const broken = {
517
+ get: async () => Promise.reject(new Error('idb gone')),
518
+ set: async () => {},
519
+ delete: async () => {},
520
+ };
521
+ expect(await getBridgedToken(broken as never)).toBeNull();
522
+ });
523
+
524
+ it('pushsubscriptionchange: re-subscribes with the stored key, moves the device on the server, tells the pages', async () => {
525
+ const f = fakeScope();
526
+ const store = memoryStore();
527
+ await store.set('token', { ...tokenMessage, pushEndpoint: 'https://push.example/old', at: 1 });
528
+ const w = win('https://app.example/');
529
+ f.windows.push(w);
530
+ const feed = fakeFeed([]);
531
+ installPushHandlers({ scope: f.scope, store, feed: () => feed as never });
532
+ await f.dispatch('pushsubscriptionchange', { oldSubscription: null, newSubscription: null });
533
+ expect(f.subscribe).toHaveBeenCalledWith({
534
+ userVisibleOnly: true,
535
+ applicationServerKey: base64UrlToBytes(KEY),
536
+ });
537
+ const sqls = feed.query.mock.calls.map((c) => c[0]);
538
+ expect(sqls).toEqual([
539
+ 'RETURN fn::push::list()',
540
+ 'RETURN fn::push::subscribe($sub, $opts)',
541
+ 'RETURN fn::push::unsubscribe($endpoint)',
542
+ ]);
543
+ expect(feed.query.mock.calls[1][1]).toEqual({
544
+ sub: { endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } },
545
+ opts: { label: 'Laptop', rules: ['dm'], userAgent: 'sw-ua' },
546
+ });
547
+ expect(feed.query.mock.calls[2][1]).toEqual({ endpoint: 'https://push.example/old' });
548
+ expect(feed.close).toHaveBeenCalled();
549
+ expect((await getBridgedToken(store))!.pushEndpoint).toBe('https://push.example/new');
550
+ expect(w.postMessage).toHaveBeenCalledWith({
551
+ type: 'sp00ky:subscriptionchange',
552
+ endpoint: 'https://push.example/new',
553
+ });
554
+ });
555
+
556
+ it('uninstall removes every listener', () => {
557
+ const f = fakeScope();
558
+ const off = installPushHandlers({ scope: f.scope, store: memoryStore() });
559
+ expect([...f.listeners.keys()].sort()).toEqual([
560
+ 'message',
561
+ 'notificationclick',
562
+ 'notificationclose',
563
+ 'push',
564
+ 'pushsubscriptionchange',
565
+ ]);
566
+ off();
567
+ expect(f.listeners.size).toBe(0);
568
+ });
569
+ });
570
+
571
+ describe('notificationOptions', () => {
572
+ it('drops renotify without a tag and keeps unknown keys', () => {
573
+ const [title, options] = notificationOptions({ title: 'x', renotify: true, timestamp: 5 });
574
+ expect(title).toBe('x');
575
+ expect(options).toEqual({ timestamp: 5, data: {} });
576
+ const [, tagged] = notificationOptions(
577
+ { title: 'x', renotify: true },
578
+ { v: 1, kind: 'message', topic: 't', ts: 1 }
579
+ );
580
+ expect(tagged).toMatchObject({ renotify: true, tag: 't' });
581
+ });
582
+ });