@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.211

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 (163) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2514 -58
  3. package/dist/index.js +12561 -2449
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +303 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +439 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +472 -0
  11. package/dist/types.d.ts +751 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +36 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/auth.local-first.test.ts +101 -0
  25. package/src/modules/auth/events/index.ts +2 -1
  26. package/src/modules/auth/index.ts +127 -24
  27. package/src/modules/cache/cache.relay.test.ts +95 -0
  28. package/src/modules/cache/index.ts +163 -43
  29. package/src/modules/cache/types.ts +2 -2
  30. package/src/modules/crdt/crdt-field.ts +294 -0
  31. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  32. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  33. package/src/modules/crdt/index.ts +463 -0
  34. package/src/modules/crdt/loro-loader.ts +25 -0
  35. package/src/modules/data/data.hydration.test.ts +142 -0
  36. package/src/modules/data/data.membership.test.ts +523 -0
  37. package/src/modules/data/data.notify-table.test.ts +41 -0
  38. package/src/modules/data/data.pending-ids.test.ts +199 -0
  39. package/src/modules/data/data.rebind.test.ts +170 -0
  40. package/src/modules/data/data.rematerialize.test.ts +114 -0
  41. package/src/modules/data/data.run.test.ts +113 -0
  42. package/src/modules/data/data.settled-writes.test.ts +206 -0
  43. package/src/modules/data/data.status.test.ts +249 -0
  44. package/src/modules/data/id-set-plan.test.ts +122 -0
  45. package/src/modules/data/index.ts +1815 -151
  46. package/src/modules/data/mutation-id.test.ts +25 -0
  47. package/src/modules/data/mutation-id.ts +35 -0
  48. package/src/modules/data/window-query.test.ts +52 -0
  49. package/src/modules/data/window-query.ts +194 -0
  50. package/src/modules/devtools/flags.ts +349 -0
  51. package/src/modules/devtools/index.ts +450 -46
  52. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  53. package/src/modules/devtools/state-shape.test.ts +146 -0
  54. package/src/modules/devtools/storage-info.test.ts +79 -0
  55. package/src/modules/devtools/storage-info.ts +168 -0
  56. package/src/modules/devtools/versions.test.ts +74 -0
  57. package/src/modules/devtools/versions.ts +110 -0
  58. package/src/modules/feature-flag/index.test.ts +251 -0
  59. package/src/modules/feature-flag/index.ts +308 -0
  60. package/src/modules/ref-tables.test.ts +91 -0
  61. package/src/modules/ref-tables.ts +88 -0
  62. package/src/modules/sync/engine.ts +164 -82
  63. package/src/modules/sync/events/index.ts +9 -2
  64. package/src/modules/sync/queue/queue-down.test.ts +180 -0
  65. package/src/modules/sync/queue/queue-down.ts +80 -13
  66. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  67. package/src/modules/sync/queue/queue-up.ts +241 -57
  68. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  69. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  70. package/src/modules/sync/scheduler.ts +215 -13
  71. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  72. package/src/modules/sync/sync.health.test.ts +149 -0
  73. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  74. package/src/modules/sync/sync.live-removal.test.ts +175 -0
  75. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  76. package/src/modules/sync/sync.subquery.test.ts +82 -0
  77. package/src/modules/sync/sync.tabs.test.ts +249 -0
  78. package/src/modules/sync/sync.ts +1726 -99
  79. package/src/modules/sync/utils.test.ts +269 -2
  80. package/src/modules/sync/utils.ts +201 -17
  81. package/src/otel/index.ts +13 -10
  82. package/src/services/blobs/blob-cache.test.ts +359 -0
  83. package/src/services/blobs/blob-cache.ts +603 -0
  84. package/src/services/blobs/blob-manifest.ts +227 -0
  85. package/src/services/blobs/blob-store.test.ts +77 -0
  86. package/src/services/blobs/blob-store.ts +359 -0
  87. package/src/services/blobs/blob.fixture.ts +90 -0
  88. package/src/services/blobs/index.ts +70 -0
  89. package/src/services/database/cache-engine.ts +193 -0
  90. package/src/services/database/connection-supervisor.test.ts +289 -0
  91. package/src/services/database/connection-supervisor.ts +415 -0
  92. package/src/services/database/database.query-timeout.test.ts +83 -0
  93. package/src/services/database/database.ts +41 -12
  94. package/src/services/database/engine-factory.ts +33 -0
  95. package/src/services/database/errors.ts +34 -0
  96. package/src/services/database/events/index.ts +2 -1
  97. package/src/services/database/index.ts +7 -0
  98. package/src/services/database/local-migrator.ts +30 -27
  99. package/src/services/database/local.test.ts +64 -0
  100. package/src/services/database/local.ts +484 -67
  101. package/src/services/database/plan-render.test.ts +159 -0
  102. package/src/services/database/plan-render.ts +108 -0
  103. package/src/services/database/relation-resolver.test.ts +413 -0
  104. package/src/services/database/relation-resolver.ts +0 -0
  105. package/src/services/database/remote.ts +110 -14
  106. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  107. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  108. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  109. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  110. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  111. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  112. package/src/services/database/sqlite-lock-verify.ts +45 -0
  113. package/src/services/database/sqlite-open.test.ts +150 -0
  114. package/src/services/database/sqlite-open.ts +164 -0
  115. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  116. package/src/services/database/sqlite-plan-sql.ts +138 -0
  117. package/src/services/database/sqlite-projection.test.ts +99 -0
  118. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  119. package/src/services/database/sqlite-select.test.ts +246 -0
  120. package/src/services/database/sqlite-select.ts +131 -0
  121. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  122. package/src/services/database/sqlite-transport.ts +224 -0
  123. package/src/services/database/sqlite-worker.ts +437 -0
  124. package/src/services/database/surql-translate.ts +416 -0
  125. package/src/services/database/surreal-cache-engine.ts +161 -0
  126. package/src/services/logger/index.ts +3 -2
  127. package/src/services/persistence/localstorage.ts +2 -2
  128. package/src/services/persistence/resilient.ts +11 -4
  129. package/src/services/persistence/surrealdb.ts +10 -10
  130. package/src/services/stream-processor/index.ts +796 -84
  131. package/src/services/stream-processor/permissions.test.ts +47 -0
  132. package/src/services/stream-processor/permissions.ts +53 -0
  133. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  134. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  135. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  136. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  137. package/src/services/stream-processor/wasm-types.ts +59 -3
  138. package/src/services/tabs/broker-client.ts +283 -0
  139. package/src/services/tabs/broker.test.ts +327 -0
  140. package/src/services/tabs/coordinator.test.ts +365 -0
  141. package/src/services/tabs/coordinator.ts +633 -0
  142. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  143. package/src/services/tabs/leader-locks.ts +75 -0
  144. package/src/services/tabs/protocol.ts +258 -0
  145. package/src/services/tabs/support.ts +36 -0
  146. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  147. package/src/sp00ky.auth-order.test.ts +92 -0
  148. package/src/sp00ky.init-query.test.ts +183 -0
  149. package/src/sp00ky.local-first.test.ts +60 -0
  150. package/src/sp00ky.ts +1693 -0
  151. package/src/types.ts +528 -13
  152. package/src/utils/blurhash.ts +90 -0
  153. package/src/utils/error-classification.test.ts +44 -0
  154. package/src/utils/error-classification.ts +7 -0
  155. package/src/utils/index.ts +79 -13
  156. package/src/utils/parser.test.ts +49 -120
  157. package/src/utils/parser.ts +32 -2
  158. package/src/utils/semver.test.ts +32 -0
  159. package/src/utils/semver.ts +30 -0
  160. package/src/utils/surql.ts +30 -18
  161. package/src/utils/withRetry.test.ts +1 -1
  162. package/tsdown.config.ts +86 -1
  163. package/src/spooky.ts +0 -395
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.21",
3
+ "version": "0.0.1-canary.211",
4
4
  "type": "module",
5
+ "sideEffects": false,
5
6
  "main": "./dist/index.js",
6
7
  "types": "./dist/index.d.ts",
7
8
  "exports": {
@@ -17,12 +18,12 @@
17
18
  "scripts": {
18
19
  "dev": "tsdown --watch",
19
20
  "dev:example": "pnpm dev",
20
- "build": "tsdown",
21
+ "build": "tsdown && node scripts/check-broker-bundle.mjs",
21
22
  "test": "vitest"
22
23
  },
23
24
  "repository": {
24
25
  "type": "git",
25
- "url": "https://github.com/mono424/spooky.git",
26
+ "url": "https://github.com/mono424/sp00ky.git",
26
27
  "directory": "packages/core"
27
28
  },
28
29
  "keywords": [
@@ -59,12 +60,15 @@
59
60
  }
60
61
  },
61
62
  "dependencies": {
62
- "@spooky-sync/query-builder": "workspace:*",
63
- "@spooky-sync/ssp-wasm": "workspace:*",
64
- "@surrealdb/wasm": "^3.0.0",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.211",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.211",
65
+ "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
+ "@surrealdb/wasm": "^3.0.3",
67
+ "blurhash": "^2.0.5",
65
68
  "fast-json-patch": "^3.1.1",
69
+ "loro-crdt": "^1.5.6",
66
70
  "pino": "^10.1.0",
67
- "surrealdb": "2.0.0"
71
+ "surrealdb": "2.0.3"
68
72
  },
69
73
  "devDependencies": {
70
74
  "@types/node": "^22.5.2",
@@ -0,0 +1,33 @@
1
+ // Build gate: dist/tabs-broker-worker.js must be self-contained ESM.
2
+ // Bundlers cannot trace `new SharedWorker(url)` module graphs, so any
3
+ // import statement surviving into the emitted broker file would 404 at
4
+ // runtime in consumers (the file is fetched as a bare URL, its relative
5
+ // chunk siblings are not copied along). Run after `tsdown` in the build.
6
+ import { readFileSync } from 'node:fs';
7
+ import { resolve, dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const dist = resolve(dirname(fileURLToPath(import.meta.url)), '../dist/tabs-broker-worker.js');
11
+
12
+ let code;
13
+ try {
14
+ code = readFileSync(dist, 'utf8');
15
+ } catch {
16
+ console.error(`check-broker-bundle: ${dist} missing; did tsdown run?`);
17
+ process.exit(1);
18
+ }
19
+
20
+ const problems = [];
21
+ // Static or dynamic imports of sibling chunks.
22
+ const importRe = /(?:^|\n)\s*import[\s(]/;
23
+ if (importRe.test(code)) problems.push('contains an import statement');
24
+ if (!code.includes('onconnect')) problems.push('missing onconnect handler');
25
+
26
+ if (problems.length > 0) {
27
+ console.error(`check-broker-bundle: dist/tabs-broker-worker.js ${problems.join('; ')}.`);
28
+ console.error(
29
+ 'The broker worker must stay free of runtime imports (type-only imports are fine in source).'
30
+ );
31
+ process.exit(1);
32
+ }
33
+ console.log('check-broker-bundle: ok');
@@ -1,27 +1,27 @@
1
1
  ---
2
- name: spooky-core
2
+ name: sp00ky-core
3
3
  description: >-
4
- Core client for the Spooky reactive local-first SurrealDB framework. Use when
5
- initializing SpookyClient, registering queries and subscriptions, performing
4
+ Core client for the Sp00ky reactive local-first SurrealDB framework. Use when
5
+ initializing Sp00kyClient, registering queries and subscriptions, performing
6
6
  mutations (create/update/delete), handling auth (signUp/signIn/signOut),
7
7
  working with file buckets, or using backend runs (HTTP outbox pattern).
8
8
  metadata:
9
- author: spooky-sync
9
+ author: sp00ky-sync
10
10
  version: "0.0.1"
11
11
  ---
12
12
 
13
- # Spooky Core
13
+ # Sp00ky Core
14
14
 
15
- `@spooky-sync/core` provides `SpookyClient`, the main entry point for the Spooky framework. It handles local-first data sync with SurrealDB, reactive queries, optimistic mutations, authentication, file storage, and backend runs.
15
+ `@spooky-sync/core` provides `Sp00kyClient`, the main entry point for the Sp00ky framework. It handles local-first data sync with SurrealDB, reactive queries, optimistic mutations, authentication, file storage, and backend runs.
16
16
 
17
17
  ## Initialization
18
18
 
19
19
  ```typescript
20
- import { SpookyClient } from '@spooky-sync/core';
20
+ import { Sp00kyClient } from '@spooky-sync/core';
21
21
  import { schema } from './generated/schema'; // Generated by `spooky generate`
22
22
  import schemaSurql from './generated/schema.surql?raw';
23
23
 
24
- const client = new SpookyClient({
24
+ const client = new Sp00kyClient({
25
25
  database: {
26
26
  endpoint: 'ws://localhost:8000',
27
27
  namespace: 'my_ns',
@@ -40,7 +40,7 @@ await client.init();
40
40
  await client.close();
41
41
  ```
42
42
 
43
- ### SpookyConfig Options
43
+ ### Sp00kyConfig Options
44
44
 
45
45
  See [references/config.md](references/config.md) for all configuration options.
46
46
 
@@ -159,13 +159,13 @@ Buckets must be defined in the schema under the `buckets` array.
159
159
 
160
160
  ## Backend Runs (HTTP Outbox)
161
161
 
162
- Backend runs let you trigger server-side HTTP operations via an **outbox pattern**. When you call `db.run()`, it creates a job record in a local outbox table. That record syncs to remote SurrealDB, where the Spooky Sync Platform (SSP) picks it up and calls your backend HTTP API. The result status is written back to the job record and syncs to the client reactively.
162
+ Backend runs let you trigger server-side HTTP operations via an **outbox pattern**. When you call `db.run()`, it creates a job record in a local outbox table. That record syncs to remote SurrealDB, where the Sp00ky Sync Platform (SSP) picks it up and calls your backend HTTP API. The result status is written back to the job record and syncs to the client reactively.
163
163
 
164
164
  ### `db.run()` Signature
165
165
 
166
166
  ```typescript
167
167
  db.run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
168
- backend: B, // Backend name from spooky.yml (e.g., 'api')
168
+ backend: B, // Backend name from sp00ky.yml (e.g., 'api')
169
169
  route: R, // Route path from OpenAPI spec (e.g., '/spookify')
170
170
  payload: RoutePayload<S, B, R>, // Typed args for the route
171
171
  options?: RunOptions
@@ -192,7 +192,7 @@ The `assignedTo` option is how you link a job to an entity. Passing a record ID:
192
192
 
193
193
  ### Full Example
194
194
 
195
- **1. Configure the backend in `spooky.yml`:**
195
+ **1. Configure the backend in `sp00ky.yml`:**
196
196
 
197
197
  ```yaml
198
198
  backends:
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## AuthService API
4
4
 
5
- The `AuthService` is available on `SpookyClient` as `client.auth`.
5
+ The `AuthService` is available on `Sp00kyClient` as `client.auth`.
6
6
 
7
7
  ### Properties
8
8
 
@@ -1,7 +1,7 @@
1
- # SpookyConfig Reference
1
+ # Sp00kyConfig Reference
2
2
 
3
3
  ```typescript
4
- interface SpookyConfig<S extends SchemaStructure> {
4
+ interface Sp00kyConfig<S extends SchemaStructure> {
5
5
  database: {
6
6
  /** SurrealDB WebSocket endpoint URL */
7
7
  endpoint?: string;
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { BucketHandle } from './sp00ky';
3
+ import type { RemoteDatabaseService } from './services/database/index';
4
+ import {
5
+ blurhashSidecarPath,
6
+ isImagePath,
7
+ encodeImageToBlurhash,
8
+ encodeBlurhash,
9
+ } from './utils/blurhash';
10
+
11
+ // A real 1x1 hash so isBlurhashValid passes in the read-path tests.
12
+ const VALID_HASH = encodeBlurhash(new Uint8ClampedArray([12, 34, 56, 255]), 1, 1, 1, 1);
13
+
14
+ function makeRemote(queries: string[]) {
15
+ return {
16
+ query: vi.fn(async (surql: string) => {
17
+ queries.push(surql);
18
+ return [null];
19
+ }),
20
+ } as unknown as RemoteDatabaseService;
21
+ }
22
+
23
+ describe('blurhash path helpers', () => {
24
+ it('appends .bh for the sidecar', () => {
25
+ expect(blurhashSidecarPath('a/b_t.webp')).toBe('a/b_t.webp.bh');
26
+ });
27
+
28
+ it('recognizes image extensions case-insensitively', () => {
29
+ expect(isImagePath('x/y.webp')).toBe(true);
30
+ expect(isImagePath('x/y.PNG')).toBe(true);
31
+ expect(isImagePath('x/y.webp.bh')).toBe(false);
32
+ expect(isImagePath('x/notes.txt')).toBe(false);
33
+ });
34
+ });
35
+
36
+ describe('encodeImageToBlurhash outside a browser', () => {
37
+ it('returns null instead of throwing (no createImageBitmap in node)', async () => {
38
+ await expect(encodeImageToBlurhash(new Uint8Array([1, 2, 3]))).resolves.toBeNull();
39
+ });
40
+ });
41
+
42
+ describe('BucketHandle.put', () => {
43
+ let queries: string[];
44
+
45
+ beforeEach(() => {
46
+ queries = [];
47
+ });
48
+
49
+ it('image put succeeds with blurhash null when encoding is unavailable', async () => {
50
+ const handle = new BucketHandle('covers', makeRemote(queries), null);
51
+ const result = await handle.put('a/b_t.webp', new Uint8Array([1]));
52
+ expect(result).toEqual({ blurhash: null });
53
+ // Only the image put went out; no sidecar for a failed/unavailable encode.
54
+ expect(queries).toHaveLength(1);
55
+ expect(queries[0]).toContain('covers:/a/b_t.webp".put');
56
+ });
57
+
58
+ it('skips hashing entirely for non-image paths and when disabled', async () => {
59
+ const handle = new BucketHandle('covers', makeRemote(queries), null, { blurhash: false });
60
+ await handle.put('a/b_t.webp', new Uint8Array([1]));
61
+ await handle.put('a/readme.txt', 'hello');
62
+ expect(queries).toHaveLength(2);
63
+ expect(queries.some((q) => q.includes('.bh'))).toBe(false);
64
+ });
65
+
66
+ it('writes the sidecar when a hash is produced', async () => {
67
+ // Simulate a browser: a createImageBitmap whose bitmap a canvas can read.
68
+ const pixels = { width: 2, height: 2 };
69
+ vi.stubGlobal(
70
+ 'createImageBitmap',
71
+ vi.fn(async () => ({ ...pixels, close: vi.fn() }))
72
+ );
73
+ vi.stubGlobal(
74
+ 'OffscreenCanvas',
75
+ class {
76
+ width: number;
77
+ height: number;
78
+ constructor(w: number, h: number) {
79
+ this.width = w;
80
+ this.height = h;
81
+ }
82
+ getContext() {
83
+ return {
84
+ drawImage: () => {},
85
+ getImageData: (_x: number, _y: number, w: number, h: number) => ({
86
+ data: new Uint8ClampedArray(w * h * 4).fill(128),
87
+ }),
88
+ };
89
+ }
90
+ }
91
+ );
92
+ try {
93
+ const handle = new BucketHandle('covers', makeRemote(queries), null);
94
+ const result = await handle.put('a/b_t.webp', new Uint8Array([1]));
95
+ expect(result.blurhash).toBeTruthy();
96
+ expect(queries).toHaveLength(2);
97
+ expect(queries[1]).toContain('covers:/a/b_t.webp.bh".put');
98
+ } finally {
99
+ vi.unstubAllGlobals();
100
+ }
101
+ });
102
+ });
103
+
104
+ describe('BucketHandle.blurhash', () => {
105
+ it('returns a valid stored hash and negative-caches misses per path', async () => {
106
+ const reads: string[] = [];
107
+ let payload: unknown = VALID_HASH;
108
+ const remote = {
109
+ query: vi.fn(async (surql: string) => {
110
+ reads.push(surql);
111
+ return [payload];
112
+ }),
113
+ } as unknown as RemoteDatabaseService;
114
+ const handle = new BucketHandle('covers', remote, null);
115
+
116
+ await expect(handle.blurhash('hit/x.webp')).resolves.toBe(VALID_HASH);
117
+
118
+ payload = null;
119
+ await expect(handle.blurhash('miss/x.webp')).resolves.toBeNull();
120
+ const readsAfterMiss = reads.length;
121
+ // The miss is remembered: no second remote read for the same path.
122
+ await expect(handle.blurhash('miss/x.webp')).resolves.toBeNull();
123
+ expect(reads).toHaveLength(readsAfterMiss);
124
+ });
125
+
126
+ it('treats invalid sidecar content as missing', async () => {
127
+ const remote = {
128
+ query: vi.fn(async () => ['not a blurhash \\ at all']),
129
+ } as unknown as RemoteDatabaseService;
130
+ const handle = new BucketHandle('covers', remote, null);
131
+ await expect(handle.blurhash('bad/x.webp')).resolves.toBeNull();
132
+ });
133
+ });
134
+
135
+ describe('BucketHandle.delete', () => {
136
+ it('also deletes the sidecar for image paths, best-effort', async () => {
137
+ const queries: string[] = [];
138
+ const handle = new BucketHandle('covers', makeRemote(queries), null);
139
+ await handle.delete('a/b_t.webp');
140
+ expect(queries).toHaveLength(2);
141
+ expect(queries[0]).toContain('covers:/a/b_t.webp".delete');
142
+ expect(queries[1]).toContain('covers:/a/b_t.webp.bh".delete');
143
+
144
+ queries.length = 0;
145
+ await handle.delete('a/notes.txt');
146
+ expect(queries).toHaveLength(1);
147
+ });
148
+ });
@@ -0,0 +1,12 @@
1
+ // Build-time string constants injected by tsdown's `define` (see
2
+ // tsdown.config.ts). They carry the real bundled frontend package versions so
3
+ // the DevTools can surface frontend/backend version drift.
4
+ //
5
+ // Typed as `string | undefined` on purpose: the substitution only happens when
6
+ // `@spooky-sync/core` is built with our tsdown plugin. A downstream app that
7
+ // bundles core from source never runs that plugin, so these identifiers must be
8
+ // guarded with `typeof` (see modules/devtools/index.ts) to avoid a runtime
9
+ // ReferenceError that would break DB initialization.
10
+ declare const __SP00KY_CORE_VERSION__: string | undefined;
11
+ declare const __SP00KY_WASM_VERSION__: string | undefined;
12
+ declare const __SP00KY_SURREAL_VERSION__: string | undefined;
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { EventSystem, EventDefinition } from './index';
2
+ import type { EventDefinition } from './index';
3
+ import { EventSystem } from './index';
3
4
 
4
5
  // Define test event types
5
6
  type TestEvents = {
@@ -172,6 +172,9 @@ export class EventSystem<E extends EventTypeMap> {
172
172
  * @param payload The data associated with the event.
173
173
  */
174
174
  emit<T extends EventType<E>, P extends EventPayload<E, T>>(type: T, payload: P): void {
175
+ // `{ type, payload }` structurally matches `Event<E, T>` (= the indexed
176
+ // access `E[T]`), but TS can't verify assignability to an indexed-access
177
+ // type over the generic `E`/`T`, so the constructed event is asserted.
175
178
  const event = {
176
179
  type,
177
180
  payload,
package/src/index.ts CHANGED
@@ -1,4 +1,38 @@
1
1
  export * from './types';
2
- export * from './spooky';
2
+ export * from './sp00ky';
3
3
  export * from './modules/auth/index';
4
- export { fileToUint8Array } from './utils/index';
4
+ export { CrdtField, CrdtManager, cursorColorFromName, CURSOR_COLORS } from './modules/crdt/index';
5
+ export {
6
+ FeatureFlagModule,
7
+ FeatureFlagHandle,
8
+ type FeatureFlagOptions,
9
+ type FeatureFlagOverride,
10
+ type FeatureFlagSnapshot,
11
+ } from './modules/feature-flag/index';
12
+ export {
13
+ AppReleaseModule,
14
+ AppReleaseHandle,
15
+ type AppReleaseOptions,
16
+ type AppReleaseSnapshot,
17
+ } from './modules/app-release/index';
18
+ export type {
19
+ BlobCacheStats,
20
+ BlobEntry,
21
+ BlobKey,
22
+ BlobReadOptions,
23
+ BlobUrlLease,
24
+ } from './services/blobs/index';
25
+ export { semverGt } from './utils/semver';
26
+ export { LocalOpTimeoutError, DEFAULT_LOCAL_OP_TIMEOUT_MS } from './services/database/errors';
27
+ export { fileToUint8Array, textToHtml } from './utils/index';
28
+ export {
29
+ blurhashSidecarPath,
30
+ encodeImageToBlurhash,
31
+ encodeBlurhash,
32
+ decodeBlurhash,
33
+ isBlurhashValid,
34
+ isImagePath,
35
+ BLURHASH_IMAGE_EXTENSIONS,
36
+ type BlurhashSetting,
37
+ type BlurhashEncodeOptions,
38
+ } from './utils/blurhash';
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { AppReleaseModule } from './index';
3
+
4
+ // Mirrors the FeatureFlagModule test rig: the DataModule mock captures the
5
+ // single subscribe callback so tests can push live results, and counts
6
+ // query() calls to assert the query is SHARED across apps.
7
+ function makeDeps() {
8
+ let subCb: ((records: unknown[]) => void) | null = null;
9
+ const calls: Array<{ sql: string; params: unknown }> = [];
10
+ let authCb: ((userId: string | null) => void) | null = null;
11
+
12
+ const dataModule = {
13
+ query: async (_table: string, sql: string, params: unknown) => {
14
+ calls.push({ sql, params });
15
+ return `hash:${calls.length}`;
16
+ },
17
+ subscribe: (_hash: string, cb: (records: unknown[]) => void) => {
18
+ subCb = cb;
19
+ return () => {
20
+ subCb = null;
21
+ };
22
+ },
23
+ };
24
+ const sync = { enqueueDownEvent: () => {} };
25
+ const auth = {
26
+ subscribe: (cb: (userId: string | null) => void) => {
27
+ authCb = cb;
28
+ return () => {
29
+ authCb = null;
30
+ };
31
+ },
32
+ };
33
+ const logger = { child: () => ({ warn: () => {} }) };
34
+
35
+ const deps = { dataModule, sync, auth, logger } as any;
36
+ return {
37
+ deps,
38
+ calls,
39
+ push: (records: unknown[]) => subCb?.(records),
40
+ setUser: (id: string | null) => authCb?.(id),
41
+ hasSub: () => subCb !== null,
42
+ };
43
+ }
44
+
45
+ const tick = () => new Promise((r) => setTimeout(r, 0));
46
+
47
+ describe('AppReleaseModule', () => {
48
+ let env: ReturnType<typeof makeDeps>;
49
+ let mod: AppReleaseModule<any>;
50
+
51
+ beforeEach(() => {
52
+ env = makeDeps();
53
+ mod = new AppReleaseModule(env.deps);
54
+ });
55
+
56
+ it('registers ONE shared, unfiltered query for many apps', async () => {
57
+ mod.release('web');
58
+ mod.release('admin');
59
+ await tick();
60
+
61
+ expect(env.calls.length).toBe(1);
62
+ expect(env.calls[0].sql).not.toContain('WHERE');
63
+ expect(env.calls[0].sql).toContain('FROM _00_app_release');
64
+ });
65
+
66
+ it('fans the shared result out to each handle by app', async () => {
67
+ const web = mod.release('web');
68
+ const missing = mod.release('missing');
69
+ await tick();
70
+
71
+ env.push([{ app: 'web', version: '1.2.0', cache_bust: true, mandatory: null }]);
72
+
73
+ expect(web.version()).toBe('1.2.0');
74
+ expect(web.snapshot().cacheBust).toBe(true);
75
+ expect(web.snapshot().mandatory).toBe(false);
76
+ expect(missing.version()).toBeUndefined();
77
+ expect(missing.updateAvailable('1.0.0')).toBe(false);
78
+ });
79
+
80
+ it('updateAvailable compares semver against the running build', async () => {
81
+ const web = mod.release('web');
82
+ await tick();
83
+
84
+ env.push([{ app: 'web', version: '1.2.0' }]);
85
+ expect(web.updateAvailable('1.1.9')).toBe(true);
86
+ expect(web.updateAvailable('1.2.0')).toBe(false);
87
+ expect(web.updateAvailable('1.3.0')).toBe(false);
88
+ expect(web.updateAvailable('garbage')).toBe(false);
89
+ });
90
+
91
+ it('observes row updates live without re-registering', async () => {
92
+ const web = mod.release('web');
93
+ await tick();
94
+
95
+ env.push([{ app: 'web', version: '1.0.0' }]);
96
+ expect(web.updateAvailable('1.0.0')).toBe(false);
97
+
98
+ env.push([{ app: 'web', version: '1.0.1', mandatory: true }]);
99
+ expect(web.updateAvailable('1.0.0')).toBe(true);
100
+ expect(web.snapshot().mandatory).toBe(true);
101
+
102
+ expect(env.calls.length).toBe(1);
103
+ });
104
+
105
+ it('seeds a late-created handle from the already-loaded snapshot', async () => {
106
+ mod.release('web');
107
+ await tick();
108
+ env.push([{ app: 'web', version: '2.0.0' }]);
109
+
110
+ const late = mod.release('web');
111
+ expect(late.version()).toBe('2.0.0');
112
+ });
113
+
114
+ it('re-registers on user change', async () => {
115
+ mod.init();
116
+ const web = mod.release('web');
117
+ await tick();
118
+ env.push([{ app: 'web', version: '1.0.0' }]);
119
+ expect(web.version()).toBe('1.0.0');
120
+
121
+ env.setUser('user:other');
122
+ await tick();
123
+ expect(env.hasSub()).toBe(true); // re-registered under the new session
124
+ });
125
+ });