@spooky-sync/core 0.0.1-canary.30 → 0.0.1-canary.300

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 +14939 -2432
  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 +746 -95
  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,824 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import {
4
+ WebPushModule,
5
+ WebPushError,
6
+ detectWebPushSupport,
7
+ recordIdString,
8
+ PUSH_ENDPOINT_KEY,
9
+ type WebPushGlobals,
10
+ } from './index';
11
+ import { AuthService } from '../auth/index';
12
+ import { base64UrlToBytes, bytesToBase64Url } from '../../push/types';
13
+
14
+ const noop = () => {};
15
+ const logger: any = {
16
+ debug: noop,
17
+ info: noop,
18
+ warn: noop,
19
+ error: noop,
20
+ trace: noop,
21
+ child: () => logger,
22
+ };
23
+ const KEY = bytesToBase64Url(new Uint8Array([4, 10, 20, 30, 40]));
24
+ const NEW_KEY = bytesToBase64Url(new Uint8Array([4, 99, 98, 97, 96]));
25
+ const flush = async (n = 8) => {
26
+ for (let i = 0; i < n; i++) await Promise.resolve();
27
+ };
28
+
29
+ interface Row {
30
+ id: RecordId;
31
+ endpoint: string;
32
+ kid: string;
33
+ label?: string;
34
+ rules?: string[];
35
+ user_agent?: string;
36
+ created_at?: Date;
37
+ failures?: number;
38
+ current?: boolean;
39
+ disabled_at?: Date;
40
+ kind?: string;
41
+ platform?: string;
42
+ app_id?: string;
43
+ environment?: string;
44
+ }
45
+
46
+ function harness(
47
+ opts: {
48
+ permission?: NotificationPermission;
49
+ config?: Record<string, unknown>;
50
+ globals?: Partial<WebPushGlobals>;
51
+ } = {}
52
+ ) {
53
+ const order: string[] = [];
54
+ const server = {
55
+ info: { enabled: true, publicKey: KEY, kid: 'kid1' } as Record<string, unknown>,
56
+ rows: [] as Row[],
57
+ fail: false,
58
+ };
59
+ const remote = {
60
+ query: vi.fn(async (sql: string, vars?: Record<string, any>) => {
61
+ order.push(sql);
62
+ if (server.fail) throw new Error('socket gone');
63
+ switch (sql) {
64
+ case 'RETURN fn::push::info()':
65
+ return [server.info];
66
+ case 'RETURN fn::push::subscribe($sub, $opts)': {
67
+ const row: Row = {
68
+ id: new RecordId('_00_push_subscription', `s${server.rows.length + 1}`),
69
+ endpoint: vars!.sub.endpoint,
70
+ kid: String(server.info.kid),
71
+ label: vars!.opts.label,
72
+ rules: vars!.opts.rules,
73
+ user_agent: vars!.opts.userAgent,
74
+ created_at: new Date(1_700_000_000_000),
75
+ current: true,
76
+ };
77
+ server.rows = [...server.rows.filter((r) => r.endpoint !== row.endpoint), row];
78
+ return [row];
79
+ }
80
+ case 'RETURN fn::push::list()':
81
+ return [server.rows];
82
+ case 'RETURN fn::push::unsubscribe($endpoint)': {
83
+ const before = server.rows.length;
84
+ server.rows = server.rows.filter((r) => r.endpoint !== vars!.endpoint);
85
+ return [before - server.rows.length];
86
+ }
87
+ case 'RETURN fn::push::unsubscribe(NONE)': {
88
+ const n = server.rows.length;
89
+ server.rows = [];
90
+ return [n];
91
+ }
92
+ case 'RETURN fn::push::update($endpoint, $opts)':
93
+ return [
94
+ server.rows
95
+ .filter((r) => r.endpoint === vars!.endpoint)
96
+ .map((r) => Object.assign(r, { label: vars!.opts.label ?? r.label })),
97
+ ];
98
+ case 'RETURN fn::push::notify($msg)':
99
+ return [
100
+ {
101
+ id: new RecordId('_00_push_message', 'm1'),
102
+ status: 'pending',
103
+ send_at: vars!.msg.sendAt ? new Date(vars!.msg.sendAt) : undefined,
104
+ created_at: new Date(1),
105
+ },
106
+ ];
107
+ case 'RETURN fn::push::test($opts)':
108
+ return [{ id: new RecordId('_00_push_message', 't1'), status: 'pending' }];
109
+ case 'RETURN fn::push::cancel($id)':
110
+ return [true];
111
+ default:
112
+ throw new Error(`unscripted ${sql}`);
113
+ }
114
+ }),
115
+ };
116
+ const persisted = new Map<string, unknown>();
117
+ const persistence = {
118
+ get: async <T>(k: string) => (persisted.get(k) as T) ?? null,
119
+ set: async (k: string, v: unknown) => void persisted.set(k, v),
120
+ remove: async (k: string) => void persisted.delete(k),
121
+ };
122
+ const authCbs: Array<(u: unknown) => void> = [];
123
+ const hooks: Array<(ctx: any) => Promise<void> | void> = [];
124
+ const auth = {
125
+ token: 'tok-1' as string | null,
126
+ currentUser: { id: new RecordId('user', 'alice') } as { id?: unknown } | null,
127
+ isAuthenticated: true,
128
+ impersonation: null as unknown,
129
+ subscribe: (cb: (u: unknown) => void) => {
130
+ authCbs.push(cb);
131
+ cb(auth.currentUser?.id ?? null);
132
+ return () => void authCbs.splice(authCbs.indexOf(cb), 1);
133
+ },
134
+ onBeforeSignOut: (h: (ctx: any) => Promise<void> | void) => {
135
+ hooks.push(h);
136
+ return () => void hooks.splice(hooks.indexOf(h), 1);
137
+ },
138
+ emit: () => authCbs.forEach((cb) => cb(auth.currentUser?.id ?? null)),
139
+ };
140
+ let n = 0;
141
+ const browser = { sub: null as any };
142
+ const makeSub = (endpoint: string, key: Uint8Array | null) => ({
143
+ endpoint,
144
+ options: { applicationServerKey: key ? key.slice().buffer : null },
145
+ toJSON: () => ({ endpoint, expirationTime: null, keys: { p256dh: 'p', auth: 'a' } }),
146
+ unsubscribe: vi.fn(async () => {
147
+ order.push(`unsubscribe ${endpoint}`);
148
+ if (browser.sub?.endpoint === endpoint) browser.sub = null;
149
+ return true;
150
+ }),
151
+ });
152
+ const pushManager = {
153
+ getSubscription: vi.fn(async () => browser.sub),
154
+ subscribe: vi.fn(async (o: { userVisibleOnly: boolean; applicationServerKey: Uint8Array }) => {
155
+ order.push('pushManager.subscribe');
156
+ browser.sub = makeSub(`https://push.example/${++n}`, new Uint8Array(o.applicationServerKey));
157
+ return browser.sub;
158
+ }),
159
+ };
160
+ const active = { postMessage: vi.fn() };
161
+ const registration = { active, pushManager };
162
+ const messageListeners: Array<(e: { data: unknown }) => void> = [];
163
+ const serviceWorker = {
164
+ getRegistration: vi.fn(async () => registration),
165
+ ready: Promise.resolve(registration),
166
+ register: vi.fn(),
167
+ controller: null,
168
+ addEventListener: vi.fn((_t: string, l: (e: { data: unknown }) => void) =>
169
+ messageListeners.push(l)
170
+ ),
171
+ removeEventListener: vi.fn(
172
+ (_t: string, l: (e: { data: unknown }) => void) =>
173
+ void messageListeners.splice(messageListeners.indexOf(l), 1)
174
+ ),
175
+ };
176
+ const Notification = {
177
+ permission: opts.permission ?? ('default' as NotificationPermission),
178
+ requestPermission: vi.fn(async () => {
179
+ order.push('requestPermission');
180
+ Notification.permission = 'granted';
181
+ return 'granted' as NotificationPermission;
182
+ }),
183
+ };
184
+ const globals: WebPushGlobals = {
185
+ window: {},
186
+ navigator: {
187
+ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/130',
188
+ platform: 'Linux x86_64',
189
+ maxTouchPoints: 0,
190
+ serviceWorker,
191
+ } as any,
192
+ Notification,
193
+ isSecureContext: true,
194
+ PushManager: function PushManager() {},
195
+ ...opts.globals,
196
+ };
197
+ const connection = {
198
+ cbs: [] as Array<(s: string) => void>,
199
+ subscribe(cb: (s: string) => void) {
200
+ this.cbs.push(cb);
201
+ cb('connecting');
202
+ return noop;
203
+ },
204
+ };
205
+ const mod = new WebPushModule({
206
+ remote: remote as any,
207
+ auth,
208
+ persistence,
209
+ logger,
210
+ database: { endpoint: 'wss://db.example/rpc', namespace: 'ns', database: 'db' },
211
+ connection,
212
+ config: opts.config as never,
213
+ globals: () => globals,
214
+ });
215
+ return {
216
+ mod,
217
+ remote,
218
+ server,
219
+ persisted,
220
+ auth,
221
+ hooks,
222
+ browser,
223
+ makeSub,
224
+ pushManager,
225
+ active,
226
+ serviceWorker,
227
+ Notification,
228
+ globals,
229
+ order,
230
+ connection,
231
+ messageListeners,
232
+ };
233
+ }
234
+
235
+ afterEach(() => {
236
+ vi.useRealTimers();
237
+ });
238
+
239
+ describe('detectWebPushSupport', () => {
240
+ const base = (): WebPushGlobals => ({
241
+ window: {},
242
+ navigator: { userAgent: 'Chrome', serviceWorker: {} } as any,
243
+ Notification: {
244
+ permission: 'granted',
245
+ requestPermission: async () => 'granted' as NotificationPermission,
246
+ },
247
+ isSecureContext: true,
248
+ PushManager: {},
249
+ });
250
+
251
+ it('names what is missing', () => {
252
+ expect(detectWebPushSupport({})).toEqual({
253
+ supported: false,
254
+ permission: 'unsupported',
255
+ reason: 'no-window',
256
+ });
257
+ expect(detectWebPushSupport({ ...base(), isSecureContext: false }).reason).toBe(
258
+ 'insecure-context'
259
+ );
260
+ expect(detectWebPushSupport({ ...base(), navigator: { userAgent: 'x' } as any }).reason).toBe(
261
+ 'no-service-worker'
262
+ );
263
+ expect(detectWebPushSupport({ ...base(), PushManager: undefined }).reason).toBe(
264
+ 'no-push-manager'
265
+ );
266
+ expect(detectWebPushSupport({ ...base(), Notification: undefined })).toEqual({
267
+ supported: false,
268
+ permission: 'unsupported',
269
+ reason: 'no-notification',
270
+ });
271
+ expect(detectWebPushSupport(base())).toEqual({ supported: true, permission: 'granted' });
272
+ });
273
+
274
+ it('iOS and iPadOS Safari need the home-screen app', () => {
275
+ const iphone = {
276
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
277
+ serviceWorker: {},
278
+ } as any;
279
+ expect(
280
+ detectWebPushSupport({
281
+ ...base(),
282
+ navigator: iphone,
283
+ PushManager: undefined,
284
+ Notification: undefined,
285
+ }).reason
286
+ ).toBe('ios-not-installed');
287
+ const ipad = {
288
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
289
+ platform: 'MacIntel',
290
+ maxTouchPoints: 5,
291
+ serviceWorker: {},
292
+ } as any;
293
+ expect(detectWebPushSupport({ ...base(), navigator: ipad }).reason).toBe('ios-not-installed');
294
+ expect(
295
+ detectWebPushSupport({ ...base(), navigator: { ...iphone, standalone: true } }).supported
296
+ ).toBe(true);
297
+ expect(
298
+ detectWebPushSupport({ ...base(), navigator: iphone, matchMedia: () => ({ matches: true }) })
299
+ .supported
300
+ ).toBe(true);
301
+ });
302
+
303
+ it('is unsupported in Node without throwing', () => {
304
+ const h = harness({ globals: { window: undefined } });
305
+ expect(h.mod.support()).toEqual({
306
+ supported: false,
307
+ permission: 'unsupported',
308
+ reason: 'no-window',
309
+ });
310
+ const real = new WebPushModule({
311
+ remote: {} as any,
312
+ auth: {} as any,
313
+ persistence: {} as any,
314
+ logger,
315
+ database: { namespace: 'n', database: 'd' },
316
+ });
317
+ expect(real.support().supported).toBe(false);
318
+ });
319
+ });
320
+
321
+ describe('WebPushModule.subscribe', () => {
322
+ it('asks for permission first, subscribes with the deployment key, registers and persists the device', async () => {
323
+ const h = harness();
324
+ const device = await h.mod.subscribe({ label: 'Laptop', rules: ['dm'], meta: { app: '1.2' } });
325
+ expect(h.order[0]).toBe('requestPermission');
326
+ expect(h.order).toEqual([
327
+ 'requestPermission',
328
+ 'RETURN fn::push::info()',
329
+ 'pushManager.subscribe',
330
+ 'RETURN fn::push::subscribe($sub, $opts)',
331
+ ]);
332
+ const call = h.pushManager.subscribe.mock.calls[0][0];
333
+ expect(call.userVisibleOnly).toBe(true);
334
+ expect(new Uint8Array(call.applicationServerKey)).toEqual(base64UrlToBytes(KEY));
335
+ const vars = h.remote.query.mock.calls.find(
336
+ (c) => c[0] === 'RETURN fn::push::subscribe($sub, $opts)'
337
+ )![1]!;
338
+ expect(vars).toEqual({
339
+ sub: {
340
+ endpoint: 'https://push.example/1',
341
+ expirationTime: null,
342
+ keys: { p256dh: 'p', auth: 'a' },
343
+ },
344
+ opts: {
345
+ label: 'Laptop',
346
+ rules: ['dm'],
347
+ meta: { app: '1.2' },
348
+ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/130',
349
+ },
350
+ });
351
+ expect(device).toMatchObject({
352
+ id: '_00_push_subscription:s1',
353
+ endpoint: 'https://push.example/1',
354
+ kid: 'kid1',
355
+ label: 'Laptop',
356
+ rules: ['dm'],
357
+ thisDevice: true,
358
+ failures: 0,
359
+ });
360
+ expect(device.createdAt).toEqual(new Date(1_700_000_000_000));
361
+ expect(h.persisted.get(PUSH_ENDPOINT_KEY)).toMatchObject({
362
+ endpoint: 'https://push.example/1',
363
+ userId: 'user:alice',
364
+ kid: 'kid1',
365
+ publicKey: KEY,
366
+ });
367
+ expect(await h.mod.isSubscribed()).toBe(true);
368
+ });
369
+
370
+ it('reuses a subscription made with the same key, replaces one made with another', async () => {
371
+ const same = harness({ permission: 'granted' });
372
+ same.browser.sub = same.makeSub('https://push.example/kept', base64UrlToBytes(KEY));
373
+ await same.mod.subscribe({ rules: [] });
374
+ expect(same.pushManager.subscribe).not.toHaveBeenCalled();
375
+ expect(same.remote.query.mock.calls.at(-1)![1]!.opts).toEqual({
376
+ userAgent: expect.any(String),
377
+ });
378
+
379
+ const other = harness({ permission: 'granted' });
380
+ const stale = other.makeSub('https://push.example/stale', base64UrlToBytes(NEW_KEY));
381
+ other.browser.sub = stale;
382
+ const device = await other.mod.subscribe();
383
+ expect(stale.unsubscribe).toHaveBeenCalled();
384
+ expect(other.pushManager.subscribe).toHaveBeenCalledTimes(1);
385
+ expect(device.endpoint).toBe('https://push.example/1');
386
+ });
387
+
388
+ it('refuses with typed errors: impersonating, signed out, unsupported, denied, disabled', async () => {
389
+ const imp = harness();
390
+ imp.auth.impersonation = { target: 'user:alice' };
391
+ await expect(imp.mod.subscribe()).rejects.toMatchObject({ code: 'impersonating' });
392
+ await expect(imp.mod.notify({ notification: { title: 'x' } })).rejects.toMatchObject({
393
+ code: 'impersonating',
394
+ });
395
+ await expect(imp.mod.update({ label: 'x' })).rejects.toMatchObject({ code: 'impersonating' });
396
+ expect(imp.Notification.requestPermission).not.toHaveBeenCalled();
397
+ expect(imp.remote.query).not.toHaveBeenCalled();
398
+
399
+ const out = harness();
400
+ out.auth.isAuthenticated = false;
401
+ out.auth.currentUser = null;
402
+ const err = await out.mod.subscribe().catch((e) => e);
403
+ expect(err).toBeInstanceOf(WebPushError);
404
+ expect(err.code).toBe('signed-out');
405
+ await expect(out.mod.devices()).rejects.toMatchObject({ code: 'signed-out' });
406
+
407
+ const none = harness({ globals: { PushManager: undefined } });
408
+ await expect(none.mod.subscribe()).rejects.toMatchObject({ code: 'unsupported' });
409
+
410
+ const denied = harness({ permission: 'denied' });
411
+ await expect(denied.mod.subscribe()).rejects.toMatchObject({ code: 'permission-denied' });
412
+ const noPrompt = harness();
413
+ await expect(noPrompt.mod.subscribe({ requestPermission: false })).rejects.toMatchObject({
414
+ code: 'permission-default',
415
+ });
416
+
417
+ const off = harness({ permission: 'granted' });
418
+ off.server.info = { enabled: false };
419
+ await expect(off.mod.subscribe()).rejects.toMatchObject({ code: 'disabled' });
420
+
421
+ const broken = harness({ permission: 'granted' });
422
+ broken.pushManager.subscribe.mockRejectedValueOnce(new Error('AbortError: push service error'));
423
+ await expect(broken.mod.subscribe()).rejects.toMatchObject({ code: 'subscribe-failed' });
424
+ });
425
+ });
426
+
427
+ describe('WebPushModule.sync', () => {
428
+ function subscribed() {
429
+ const h = harness({ permission: 'granted' });
430
+ return h;
431
+ }
432
+
433
+ it('says why it did nothing', async () => {
434
+ const h = subscribed();
435
+ expect(await h.mod.sync()).toBe('not-subscribed');
436
+ h.Notification.permission = 'default';
437
+ expect(await h.mod.sync()).toBe('permission-default');
438
+ h.Notification.permission = 'denied';
439
+ expect(await h.mod.sync()).toBe('permission-denied');
440
+ h.auth.impersonation = {};
441
+ expect(await h.mod.sync()).toBe('impersonating');
442
+ h.auth.isAuthenticated = false;
443
+ expect(await h.mod.sync()).toBe('signed-out');
444
+ expect(await harness({ globals: { window: undefined } }).mod.sync()).toBe('unsupported');
445
+ const off = subscribed();
446
+ off.server.info = { enabled: false };
447
+ expect(await off.mod.sync({ autoResubscribe: true })).toBe('disabled');
448
+ });
449
+
450
+ it('ok when browser, key and server row agree; autoResubscribe subscribes a granted browser', async () => {
451
+ const h = subscribed();
452
+ await h.mod.subscribe();
453
+ h.pushManager.subscribe.mockClear();
454
+ expect(await h.mod.sync()).toBe('ok');
455
+ expect(h.pushManager.subscribe).not.toHaveBeenCalled();
456
+
457
+ const auto = subscribed();
458
+ expect(await auto.mod.sync({ autoResubscribe: true })).toBe('resubscribed');
459
+ expect(auto.server.rows).toHaveLength(1);
460
+ });
461
+
462
+ it('re-creates a browser subscription that went away, keeping label and rules, dropping the dead endpoint', async () => {
463
+ const h = subscribed();
464
+ await h.mod.subscribe({ label: 'Phone', rules: ['dm'] });
465
+ h.browser.sub = null;
466
+ expect(await h.mod.sync()).toBe('resubscribed');
467
+ expect(h.server.rows.map((r) => [r.endpoint, r.label, r.rules])).toEqual([
468
+ ['https://push.example/2', 'Phone', ['dm']],
469
+ ]);
470
+ expect(h.persisted.get(PUSH_ENDPOINT_KEY)).toMatchObject({
471
+ endpoint: 'https://push.example/2',
472
+ });
473
+ });
474
+
475
+ it('resubscribes when the deployment key rotated', async () => {
476
+ const h = subscribed();
477
+ await h.mod.subscribe({ label: 'Desk' });
478
+ const old = h.browser.sub;
479
+ h.server.info = { enabled: true, publicKey: NEW_KEY, kid: 'kid2' };
480
+ h.server.rows = h.server.rows.map((r) => ({ ...r, current: false }));
481
+ expect(await h.mod.sync()).toBe('resubscribed');
482
+ expect(old.unsubscribe).toHaveBeenCalled();
483
+ expect(
484
+ new Uint8Array(h.pushManager.subscribe.mock.calls.at(-1)![0].applicationServerKey)
485
+ ).toEqual(base64UrlToBytes(NEW_KEY));
486
+ expect(h.server.rows.map((r) => [r.endpoint, r.kid, r.label])).toEqual([
487
+ ['https://push.example/2', 'kid2', 'Desk'],
488
+ ]);
489
+ expect(h.persisted.get(PUSH_ENDPOINT_KEY)).toMatchObject({ kid: 'kid2', publicKey: NEW_KEY });
490
+ });
491
+
492
+ it('a browser that cannot tell its key falls back to the stored kid', async () => {
493
+ const h = subscribed();
494
+ await h.mod.subscribe();
495
+ h.browser.sub = h.makeSub(h.browser.sub.endpoint, null);
496
+ expect(await h.mod.sync()).toBe('ok');
497
+ h.server.info = { enabled: true, publicKey: NEW_KEY, kid: 'kid2' };
498
+ expect(await h.mod.sync()).toBe('resubscribed');
499
+ });
500
+
501
+ it('re-registers a server row that went missing or was disabled; reports errors instead of throwing', async () => {
502
+ const h = subscribed();
503
+ await h.mod.subscribe();
504
+ h.server.rows = [];
505
+ expect(await h.mod.sync()).toBe('registered');
506
+ expect(h.server.rows).toHaveLength(1);
507
+ h.server.rows[0].disabled_at = new Date();
508
+ expect(await h.mod.sync()).toBe('resubscribed');
509
+ h.server.fail = true;
510
+ expect(await h.mod.sync()).toBe('error');
511
+ });
512
+
513
+ it('auto-syncs once the socket is connected and a user is signed in', async () => {
514
+ vi.useFakeTimers();
515
+ const h = harness({ permission: 'granted' });
516
+ const sync = vi.spyOn(h.mod, 'sync').mockResolvedValue('ok');
517
+ h.mod.attach();
518
+ await vi.advanceTimersByTimeAsync(2_000);
519
+ expect(sync).not.toHaveBeenCalled();
520
+ h.connection.cbs.forEach((cb) => cb('connected'));
521
+ await vi.advanceTimersByTimeAsync(2_000);
522
+ expect(sync).toHaveBeenCalledTimes(1);
523
+ // Same user again: nothing new.
524
+ h.auth.emit();
525
+ await vi.advanceTimersByTimeAsync(2_000);
526
+ expect(sync).toHaveBeenCalledTimes(1);
527
+ // The worker saw the subscription rotate.
528
+ h.messageListeners.forEach((l) =>
529
+ l({ data: { type: 'sp00ky:subscriptionchange', endpoint: 'x' } })
530
+ );
531
+ expect(sync).toHaveBeenCalledTimes(2);
532
+ h.mod.dispose();
533
+ });
534
+ });
535
+
536
+ describe('WebPushModule sign-out', () => {
537
+ it('drops this device for the user and tells the worker, before the token goes', async () => {
538
+ const h = harness({ permission: 'granted' });
539
+ h.mod.attach();
540
+ await h.mod.subscribe();
541
+ h.active.postMessage.mockClear();
542
+ await h.hooks[0]({ userId: 'user:alice', token: 'tok-1', impersonating: false });
543
+ expect(h.remote.query).toHaveBeenLastCalledWith('RETURN fn::push::unsubscribe($endpoint)', {
544
+ endpoint: 'https://push.example/1',
545
+ });
546
+ expect(h.server.rows).toHaveLength(0);
547
+ expect(h.persisted.has(PUSH_ENDPOINT_KEY)).toBe(false);
548
+ expect(h.active.postMessage).toHaveBeenCalledWith({ type: 'sp00ky:signout' });
549
+ });
550
+
551
+ it('while impersonating (or with unsubscribeOnSignOut: false) only the worker is told', async () => {
552
+ const h = harness({ permission: 'granted' });
553
+ h.mod.attach();
554
+ await h.mod.subscribe();
555
+ h.remote.query.mockClear();
556
+ await h.hooks[0]({ userId: 'user:alice', token: 't', impersonating: true });
557
+ expect(h.remote.query).not.toHaveBeenCalled();
558
+ expect(h.active.postMessage).toHaveBeenCalledWith({ type: 'sp00ky:signout' });
559
+
560
+ const k = harness({ permission: 'granted', config: { unsubscribeOnSignOut: false } });
561
+ k.mod.attach();
562
+ await k.mod.subscribe();
563
+ k.remote.query.mockClear();
564
+ await k.hooks[0]({ userId: 'user:alice', token: 't', impersonating: false });
565
+ expect(k.remote.query).not.toHaveBeenCalled();
566
+ });
567
+
568
+ it('runs inside AuthService.signOut, ahead of the token removal, and never holds it past the timeout', async () => {
569
+ const calls: string[] = [];
570
+ let hang = false;
571
+ const remote = {
572
+ setAuthToken: noop,
573
+ getClient: () => ({ invalidate: async () => void calls.push('invalidate') }),
574
+ query: vi.fn(async (sql: string) => {
575
+ calls.push(sql);
576
+ if (hang) return new Promise(() => {});
577
+ return [1];
578
+ }),
579
+ };
580
+ const store = new Map<string, unknown>([
581
+ [PUSH_ENDPOINT_KEY, { endpoint: 'https://push.example/e', userId: 'user:alice', at: 1 }],
582
+ ]);
583
+ const persistence = {
584
+ get: async <T>(k: string) => (store.get(k) as T) ?? null,
585
+ set: async (k: string, v: unknown) => void store.set(k, v),
586
+ remove: async (k: string) => {
587
+ calls.push(`remove ${k}`);
588
+ store.delete(k);
589
+ },
590
+ };
591
+ const auth = new AuthService(
592
+ { tables: [], relationships: [] } as any,
593
+ remote as any,
594
+ persistence,
595
+ logger
596
+ );
597
+ Object.assign(auth, {
598
+ token: 'tok',
599
+ isAuthenticated: true,
600
+ currentUser: { id: new RecordId('user', 'alice') },
601
+ });
602
+ const h = harness({ permission: 'granted' });
603
+ const mod = new WebPushModule({
604
+ remote: remote as any,
605
+ auth,
606
+ persistence,
607
+ logger,
608
+ database: { endpoint: 'wss://db/rpc', namespace: 'n', database: 'd' },
609
+ globals: () => h.globals,
610
+ });
611
+ mod.attach();
612
+ await auth.signOut();
613
+ const unsub = calls.indexOf('RETURN fn::push::unsubscribe($endpoint)');
614
+ expect(unsub).toBeGreaterThanOrEqual(0);
615
+ expect(unsub).toBeLessThan(calls.indexOf('remove sp00ky_auth_token'));
616
+ expect(h.active.postMessage).toHaveBeenCalledWith({ type: 'sp00ky:signout' });
617
+ expect(auth.token).toBeNull();
618
+
619
+ // A socket that never answers costs at most the hook timeout.
620
+ vi.useFakeTimers();
621
+ hang = true;
622
+ store.set(PUSH_ENDPOINT_KEY, {
623
+ endpoint: 'https://push.example/e',
624
+ userId: 'user:alice',
625
+ at: 1,
626
+ });
627
+ Object.assign(auth, {
628
+ token: 'tok2',
629
+ isAuthenticated: true,
630
+ currentUser: { id: 'user:alice' },
631
+ });
632
+ let done = false;
633
+ const out = auth.signOut().then(() => void (done = true));
634
+ await vi.advanceTimersByTimeAsync(1_000);
635
+ expect(done).toBe(false);
636
+ await vi.advanceTimersByTimeAsync(1_100);
637
+ await out;
638
+ expect(done).toBe(true);
639
+ expect(auth.token).toBeNull();
640
+ });
641
+ });
642
+
643
+ describe('WebPushModule bridge', () => {
644
+ it('posts the session while permission is granted, again on token change, never an impersonation token', async () => {
645
+ const h = harness({ permission: 'granted' });
646
+ h.server.info = { enabled: true, publicKey: KEY, kid: 'kid1' };
647
+ await h.mod.info();
648
+ h.mod.attach();
649
+ await flush();
650
+ expect(h.active.postMessage).toHaveBeenCalledWith({
651
+ type: 'sp00ky:token',
652
+ token: 'tok-1',
653
+ userId: 'user:alice',
654
+ endpoint: 'wss://db.example/rpc',
655
+ namespace: 'ns',
656
+ database: 'db',
657
+ publicKey: KEY,
658
+ });
659
+ h.active.postMessage.mockClear();
660
+ h.auth.emit();
661
+ await flush();
662
+ expect(h.active.postMessage).not.toHaveBeenCalled();
663
+ h.auth.token = 'tok-2';
664
+ h.auth.emit();
665
+ await flush();
666
+ expect(h.active.postMessage).toHaveBeenCalledWith(expect.objectContaining({ token: 'tok-2' }));
667
+ h.active.postMessage.mockClear();
668
+ h.auth.token = 'imp-token';
669
+ h.auth.impersonation = { target: 'user:bob' };
670
+ h.auth.emit();
671
+ await flush();
672
+ expect(h.active.postMessage).not.toHaveBeenCalled();
673
+ h.mod.dispose();
674
+ });
675
+
676
+ it('the automatic bridge waits for permission; an explicit bridge() does not; bridge: false opts out', async () => {
677
+ const h = harness({ permission: 'default' });
678
+ h.mod.attach();
679
+ await flush();
680
+ expect(h.active.postMessage).not.toHaveBeenCalled();
681
+ const reg = { active: { postMessage: vi.fn() } };
682
+ const off = h.mod.bridge(reg as any);
683
+ await flush();
684
+ expect(reg.active.postMessage).toHaveBeenCalledWith(
685
+ expect.objectContaining({ type: 'sp00ky:token', token: 'tok-1' })
686
+ );
687
+ off();
688
+ h.auth.token = 'tok-3';
689
+ h.auth.emit();
690
+ await flush();
691
+ expect(reg.active.postMessage).toHaveBeenCalledTimes(1);
692
+
693
+ const k = harness({ permission: 'granted', config: { bridge: false } });
694
+ k.mod.attach();
695
+ await flush();
696
+ expect(k.active.postMessage).not.toHaveBeenCalled();
697
+ });
698
+
699
+ it('onMessage delivers only sp00ky messages', () => {
700
+ const h = harness();
701
+ const seen: unknown[] = [];
702
+ const off = h.mod.onMessage((m) => seen.push(m));
703
+ h.messageListeners.forEach((l) => l({ data: { type: 'sp00ky:push', payload: { v: 1 } } }));
704
+ h.messageListeners.forEach((l) => l({ data: { type: 'other' } }));
705
+ h.messageListeners.forEach((l) => l({ data: null }));
706
+ expect(seen).toEqual([{ type: 'sp00ky:push', payload: { v: 1 } }]);
707
+ off();
708
+ expect(h.messageListeners).toHaveLength(0);
709
+ });
710
+ });
711
+
712
+ describe('WebPushModule devices and direct messages', () => {
713
+ it('lists devices with this one marked, updates and unsubscribes', async () => {
714
+ const h = harness({ permission: 'granted' });
715
+ h.server.rows.push({
716
+ id: new RecordId('_00_push_subscription', 'other'),
717
+ endpoint: 'https://push.example/other',
718
+ kid: 'kid1',
719
+ user_agent: 'Firefox',
720
+ failures: 2,
721
+ current: true,
722
+ });
723
+ await h.mod.subscribe({ label: 'Laptop' });
724
+ const devices = await h.mod.devices();
725
+ expect(devices.map((d) => [d.id, d.thisDevice, d.userAgent ?? null, d.failures])).toEqual([
726
+ ['_00_push_subscription:other', false, 'Firefox', 2],
727
+ ['_00_push_subscription:s2', true, 'Mozilla/5.0 (X11; Linux x86_64) Chrome/130', 0],
728
+ ]);
729
+ const updated = await h.mod.update({ label: 'Work', rules: null });
730
+ expect(h.remote.query).toHaveBeenLastCalledWith('RETURN fn::push::update($endpoint, $opts)', {
731
+ endpoint: 'https://push.example/1',
732
+ opts: { label: 'Work', rules: [] },
733
+ });
734
+ expect(updated?.label).toBe('Work');
735
+
736
+ expect(await h.mod.unsubscribe({ keepBrowserSubscription: true })).toBe(1);
737
+ expect(h.browser.sub).not.toBeNull();
738
+ expect(h.persisted.has(PUSH_ENDPOINT_KEY)).toBe(false);
739
+ expect(await h.mod.isSubscribed()).toBe(false);
740
+ await expect(
741
+ harness({ permission: 'granted' }).mod.update({ label: 'x' })
742
+ ).rejects.toMatchObject({ code: 'not-subscribed' });
743
+
744
+ expect(await h.mod.unsubscribe({ all: true })).toBe(1);
745
+ expect(h.browser.sub).toBeNull();
746
+ });
747
+
748
+ it('notify, test and cancel call the fn::push API with the right shapes', async () => {
749
+ const h = harness({ permission: 'granted' });
750
+ const at = new Date('2026-10-01T09:00:00.000Z');
751
+ const msg = await h.mod.notify({
752
+ notification: { title: 'Stand up', url: '/break' },
753
+ topic: 'reminder',
754
+ sendAt: at,
755
+ ttl: 600,
756
+ });
757
+ expect(h.remote.query).toHaveBeenLastCalledWith('RETURN fn::push::notify($msg)', {
758
+ msg: {
759
+ notification: { title: 'Stand up', url: '/break' },
760
+ topic: 'reminder',
761
+ ttl: 600,
762
+ sendAt: '2026-10-01T09:00:00.000Z',
763
+ },
764
+ });
765
+ expect(msg).toEqual({
766
+ id: '_00_push_message:m1',
767
+ status: 'pending',
768
+ sendAt: at,
769
+ createdAt: new Date(1),
770
+ });
771
+ expect(await h.mod.test({ title: 'Hi' })).toMatchObject({ id: '_00_push_message:t1' });
772
+ expect(h.remote.query).toHaveBeenLastCalledWith('RETURN fn::push::test($opts)', {
773
+ opts: { title: 'Hi' },
774
+ });
775
+ expect(await h.mod.cancel('_00_push_message:m1')).toBe(true);
776
+ expect(h.remote.query.mock.calls.at(-1)![1]).toEqual({
777
+ id: new RecordId('_00_push_message', 'm1'),
778
+ });
779
+ await h.mod.cancel(new RecordId('_00_push_message', 'm2'));
780
+ expect(h.remote.query.mock.calls.at(-1)![1]).toEqual({
781
+ id: new RecordId('_00_push_message', 'm2'),
782
+ });
783
+ h.server.fail = true;
784
+ await expect(h.mod.test()).rejects.toMatchObject({ code: 'server' });
785
+ });
786
+
787
+ it('lists native devices (APNs / FCM) with their kind, never as this browser', async () => {
788
+ const h = harness({ permission: 'granted' });
789
+ h.server.info = { enabled: true, publicKey: KEY, kid: 'kid1', providers: ['web', 'apns', 'bogus'] };
790
+ h.server.rows.push({
791
+ id: new RecordId('_00_push_subscription', 'phone'),
792
+ endpoint: 'apns:ab12',
793
+ kind: 'apns',
794
+ platform: 'ios',
795
+ app_id: 'im.app',
796
+ environment: 'sandbox',
797
+ kid: '',
798
+ current: true,
799
+ });
800
+ expect((await h.mod.info({ refresh: true })).providers).toEqual(['web', 'apns']);
801
+ await h.mod.subscribe({ label: 'Laptop' });
802
+ const devices = await h.mod.devices();
803
+ const phone = devices.find((d) => d.endpoint === 'apns:ab12')!;
804
+ expect(phone).toMatchObject({ kind: 'apns', platform: 'ios', appId: 'im.app', environment: 'sandbox', thisDevice: false });
805
+ expect(devices.find((d) => d.thisDevice)?.kind).toBe('web');
806
+ // A native row never makes the browser resubscribe.
807
+ expect(await h.mod.sync()).toBe('ok');
808
+ });
809
+
810
+ it('caches info until refreshed; a failed read is not cached', async () => {
811
+ const h = harness();
812
+ expect(await h.mod.info()).toEqual({ enabled: true, publicKey: KEY, kid: 'kid1' });
813
+ await h.mod.info();
814
+ expect(h.remote.query).toHaveBeenCalledTimes(1);
815
+ h.server.info = { enabled: true, publicKey: null, kid: null };
816
+ expect(await h.mod.info({ refresh: true })).toEqual({ enabled: false });
817
+ h.server.fail = true;
818
+ await expect(h.mod.info({ refresh: true })).rejects.toMatchObject({ code: 'server' });
819
+ h.server.fail = false;
820
+ expect((await h.mod.info()).enabled).toBe(false);
821
+ expect(recordIdString({ table: { name: 't' }, id: 'x' })).toBe('t:x');
822
+ expect(recordIdString('a:b')).toBe('a:b');
823
+ });
824
+ });