@spooky-sync/core 0.0.1-canary.17 → 0.0.1-canary.170

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 (139) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2046 -365
  3. package/dist/index.js +11016 -2476
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/sqlite-open.js +276 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +420 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +434 -0
  11. package/dist/types.d.ts +997 -0
  12. package/package.json +14 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +94 -18
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +4 -4
  17. package/src/build-globals.d.ts +12 -0
  18. package/src/events/events.test.ts +2 -1
  19. package/src/events/index.ts +3 -0
  20. package/src/index.ts +24 -2
  21. package/src/modules/app-release/index.test.ts +125 -0
  22. package/src/modules/app-release/index.ts +201 -0
  23. package/src/modules/auth/events/index.ts +2 -1
  24. package/src/modules/auth/index.ts +59 -20
  25. package/src/modules/cache/index.ts +112 -32
  26. package/src/modules/cache/types.ts +2 -2
  27. package/src/modules/crdt/crdt-field.ts +294 -0
  28. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  29. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  30. package/src/modules/crdt/index.ts +463 -0
  31. package/src/modules/crdt/loro-loader.ts +25 -0
  32. package/src/modules/data/data.hydration.test.ts +142 -0
  33. package/src/modules/data/data.membership.test.ts +297 -0
  34. package/src/modules/data/data.rebind.test.ts +147 -0
  35. package/src/modules/data/data.run.test.ts +113 -0
  36. package/src/modules/data/data.status.test.ts +249 -0
  37. package/src/modules/data/id-set-plan.test.ts +122 -0
  38. package/src/modules/data/index.ts +1403 -129
  39. package/src/modules/data/mutation-id.test.ts +25 -0
  40. package/src/modules/data/mutation-id.ts +35 -0
  41. package/src/modules/data/window-query.test.ts +52 -0
  42. package/src/modules/data/window-query.ts +194 -0
  43. package/src/modules/devtools/flags.ts +349 -0
  44. package/src/modules/devtools/index.ts +382 -37
  45. package/src/modules/devtools/notify-throttle.test.ts +149 -0
  46. package/src/modules/devtools/storage-info.test.ts +79 -0
  47. package/src/modules/devtools/storage-info.ts +168 -0
  48. package/src/modules/devtools/versions.test.ts +74 -0
  49. package/src/modules/devtools/versions.ts +81 -0
  50. package/src/modules/feature-flag/index.test.ts +251 -0
  51. package/src/modules/feature-flag/index.ts +308 -0
  52. package/src/modules/ref-tables.test.ts +91 -0
  53. package/src/modules/ref-tables.ts +88 -0
  54. package/src/modules/sync/engine.ts +101 -37
  55. package/src/modules/sync/events/index.ts +9 -2
  56. package/src/modules/sync/queue/queue-down.ts +12 -5
  57. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  58. package/src/modules/sync/queue/queue-up.ts +216 -56
  59. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  60. package/src/modules/sync/scheduler.ts +77 -9
  61. package/src/modules/sync/sync.health.test.ts +149 -0
  62. package/src/modules/sync/sync.live-removal.test.ts +134 -0
  63. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  64. package/src/modules/sync/sync.subquery.test.ts +82 -0
  65. package/src/modules/sync/sync.ts +1456 -92
  66. package/src/modules/sync/utils.test.ts +269 -2
  67. package/src/modules/sync/utils.ts +182 -17
  68. package/src/otel/index.ts +127 -0
  69. package/src/services/blobs/blob-cache.test.ts +359 -0
  70. package/src/services/blobs/blob-cache.ts +603 -0
  71. package/src/services/blobs/blob-manifest.ts +227 -0
  72. package/src/services/blobs/blob-store.test.ts +77 -0
  73. package/src/services/blobs/blob-store.ts +359 -0
  74. package/src/services/blobs/blob.fixture.ts +90 -0
  75. package/src/services/blobs/index.ts +70 -0
  76. package/src/services/database/cache-engine.ts +160 -0
  77. package/src/services/database/connection-supervisor.test.ts +289 -0
  78. package/src/services/database/connection-supervisor.ts +415 -0
  79. package/src/services/database/database.query-timeout.test.ts +83 -0
  80. package/src/services/database/database.ts +32 -12
  81. package/src/services/database/engine-factory.ts +33 -0
  82. package/src/services/database/events/index.ts +2 -1
  83. package/src/services/database/index.ts +7 -0
  84. package/src/services/database/local-migrator.ts +30 -27
  85. package/src/services/database/local.test.ts +64 -0
  86. package/src/services/database/local.ts +478 -67
  87. package/src/services/database/plan-render.test.ts +159 -0
  88. package/src/services/database/plan-render.ts +108 -0
  89. package/src/services/database/relation-resolver.test.ts +413 -0
  90. package/src/services/database/relation-resolver.ts +0 -0
  91. package/src/services/database/remote.ts +110 -14
  92. package/src/services/database/sqlite-cache-engine.test.ts +464 -0
  93. package/src/services/database/sqlite-cache-engine.ts +1159 -0
  94. package/src/services/database/sqlite-open.test.ts +150 -0
  95. package/src/services/database/sqlite-open.ts +164 -0
  96. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  97. package/src/services/database/sqlite-plan-sql.ts +106 -0
  98. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  99. package/src/services/database/sqlite-select.test.ts +246 -0
  100. package/src/services/database/sqlite-select.ts +113 -0
  101. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  102. package/src/services/database/sqlite-transport.ts +219 -0
  103. package/src/services/database/sqlite-worker.ts +437 -0
  104. package/src/services/database/surql-translate.ts +291 -0
  105. package/src/services/database/surreal-cache-engine.ts +141 -0
  106. package/src/services/logger/index.ts +6 -109
  107. package/src/services/persistence/localstorage.ts +2 -2
  108. package/src/services/persistence/resilient.ts +11 -4
  109. package/src/services/persistence/surrealdb.ts +10 -10
  110. package/src/services/stream-processor/index.ts +444 -52
  111. package/src/services/stream-processor/permissions.test.ts +47 -0
  112. package/src/services/stream-processor/permissions.ts +53 -0
  113. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  114. package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
  115. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  116. package/src/services/stream-processor/wasm-types.ts +23 -2
  117. package/src/services/tabs/broker-client.ts +283 -0
  118. package/src/services/tabs/broker.test.ts +278 -0
  119. package/src/services/tabs/coordinator.test.ts +244 -0
  120. package/src/services/tabs/coordinator.ts +576 -0
  121. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  122. package/src/services/tabs/leader-locks.ts +75 -0
  123. package/src/services/tabs/protocol.ts +242 -0
  124. package/src/services/tabs/support.ts +36 -0
  125. package/src/services/tabs/tabs-broker-worker.ts +586 -0
  126. package/src/sp00ky.auth-order.test.ts +92 -0
  127. package/src/sp00ky.init-query.test.ts +183 -0
  128. package/src/sp00ky.ts +1424 -0
  129. package/src/types.ts +467 -15
  130. package/src/utils/error-classification.test.ts +44 -0
  131. package/src/utils/error-classification.ts +7 -0
  132. package/src/utils/index.ts +73 -13
  133. package/src/utils/parser.ts +3 -2
  134. package/src/utils/semver.test.ts +32 -0
  135. package/src/utils/semver.ts +30 -0
  136. package/src/utils/surql.ts +30 -18
  137. package/src/utils/withRetry.test.ts +1 -1
  138. package/tsdown.config.ts +86 -1
  139. package/src/spooky.ts +0 -395
package/package.json CHANGED
@@ -1,24 +1,29 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.17",
3
+ "version": "0.0.1-canary.170",
4
4
  "type": "module",
5
+ "sideEffects": false,
5
6
  "main": "./dist/index.js",
6
7
  "types": "./dist/index.d.ts",
7
8
  "exports": {
8
9
  ".": {
9
10
  "types": "./dist/index.d.ts",
10
11
  "import": "./dist/index.js"
12
+ },
13
+ "./otel": {
14
+ "types": "./dist/otel/index.d.ts",
15
+ "import": "./dist/otel/index.js"
11
16
  }
12
17
  },
13
18
  "scripts": {
14
19
  "dev": "tsdown --watch",
15
20
  "dev:example": "pnpm dev",
16
- "build": "tsdown",
21
+ "build": "tsdown && node scripts/check-broker-bundle.mjs",
17
22
  "test": "vitest"
18
23
  },
19
24
  "repository": {
20
25
  "type": "git",
21
- "url": "https://github.com/mono424/spooky.git",
26
+ "url": "https://github.com/mono424/sp00ky.git",
22
27
  "directory": "packages/core"
23
28
  },
24
29
  "keywords": [
@@ -55,12 +60,14 @@
55
60
  }
56
61
  },
57
62
  "dependencies": {
58
- "@spooky-sync/query-builder": "workspace:*",
59
- "@spooky-sync/ssp-wasm": "workspace:*",
60
- "@surrealdb/wasm": "^3.0.0",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.170",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.170",
65
+ "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
+ "@surrealdb/wasm": "^3.0.3",
61
67
  "fast-json-patch": "^3.1.1",
68
+ "loro-crdt": "^1.5.6",
62
69
  "pino": "^10.1.0",
63
- "surrealdb": "2.0.0"
70
+ "surrealdb": "2.0.3"
64
71
  },
65
72
  "devDependencies": {
66
73
  "@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,20 +159,96 @@ 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 operations via an outbox pattern. The payload is queued locally and synced to the server.
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
+
164
+ ### `db.run()` Signature
165
+
166
+ ```typescript
167
+ db.run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
168
+ backend: B, // Backend name from sp00ky.yml (e.g., 'api')
169
+ route: R, // Route path from OpenAPI spec (e.g., '/spookify')
170
+ payload: RoutePayload<S, B, R>, // Typed args for the route
171
+ options?: RunOptions
172
+ ): Promise<void>
173
+ ```
174
+
175
+ All parameters are type-safe — `backend`, `route`, and `payload` are inferred from the generated schema.
176
+
177
+ ### RunOptions
178
+
179
+ | Option | Type | Default | Description |
180
+ |--------|------|---------|-------------|
181
+ | `assignedTo` | `string` | `undefined` | Record ID to link the job to an entity (e.g., `'thread:abc'`) |
182
+ | `max_retries` | `number` | `3` | Maximum retry attempts on failure |
183
+ | `retry_strategy` | `'linear' \| 'exponential'` | `'linear'` | Backoff strategy between retries |
184
+
185
+ ### Entity Linking via `assignedTo`
186
+
187
+ The `assignedTo` option is how you link a job to an entity. Passing a record ID:
188
+
189
+ - Sets the `assigned_to` field on the job record (typed as `record<thread>` etc. in the outbox schema)
190
+ - Enables **permission scoping** — e.g., `WHERE assigned_to.author.id = $auth.id` restricts job visibility to the entity's owner
191
+ - Allows **querying jobs via relationships** — use `.related('jobs')` on the parent entity to reactively track job status
192
+
193
+ ### Full Example
194
+
195
+ **1. Configure the backend in `sp00ky.yml`:**
196
+
197
+ ```yaml
198
+ backends:
199
+ api:
200
+ type: http
201
+ spec: ../api/openapi.yml
202
+ baseUrl: http://host.docker.internal:3660
203
+ auth:
204
+ type: token
205
+ token: MY_SECRET
206
+ method:
207
+ type: outbox
208
+ table: job
209
+ schema: ./src/outbox/api.surql
210
+ ```
211
+
212
+ **2. Define the outbox table in SurrealQL** (`src/outbox/api.surql`):
213
+
214
+ ```sql
215
+ DEFINE TABLE job SCHEMAFULL
216
+ PERMISSIONS
217
+ FOR select, create, update, delete
218
+ WHERE $access = "account" AND assigned_to.author.id = $auth.id;
219
+
220
+ DEFINE FIELD assigned_to ON TABLE job TYPE record<thread>;
221
+ DEFINE FIELD path ON TABLE job TYPE string;
222
+ DEFINE FIELD payload ON TABLE job TYPE any;
223
+ DEFINE FIELD retries ON TABLE job TYPE int DEFAULT ALWAYS 0;
224
+ DEFINE FIELD max_retries ON TABLE job TYPE int DEFAULT ALWAYS 3;
225
+ DEFINE FIELD retry_strategy ON TABLE job TYPE string DEFAULT ALWAYS "linear"
226
+ ASSERT $value IN ["linear", "exponential"];
227
+ DEFINE FIELD status ON TABLE job TYPE string DEFAULT ALWAYS "pending"
228
+ ASSERT $value IN ["pending", "processing", "success", "failed"];
229
+ DEFINE FIELD errors ON TABLE job TYPE array<object> DEFAULT ALWAYS [];
230
+ DEFINE FIELD created_at ON TABLE job TYPE datetime VALUE time::now();
231
+ ```
232
+
233
+ **3. Run `spooky generate`** — this produces the typed `backends` block in `schema.gen.ts`.
234
+
235
+ **4. Call `db.run()` with entity linking:**
163
236
 
164
237
  ```typescript
165
- await client.run('email', '/send', {
166
- to: 'alice@example.com',
167
- subject: 'Hello',
168
- body: 'World',
169
- }, {
170
- max_retries: 3,
171
- retry_strategy: 'exponential',
238
+ // Trigger the /spookify backend route, linked to a thread
239
+ await client.run('api', '/spookify', { id: threadId }, {
240
+ assignedTo: threadId, // Links job to the thread entity
172
241
  });
173
242
  ```
174
243
 
175
- Backends and routes are defined in the schema under `backends`.
244
+ ### How It Works Under the Hood
245
+
246
+ When you call `db.run('api', '/spookify', payload, options)`:
247
+
248
+ 1. Validates the route exists in the schema and all required args are present
249
+ 2. Creates a record in the outbox table (`job`) with fields: `path`, `payload` (JSON-stringified), `max_retries`, `retry_strategy`, and optionally `assigned_to`
250
+ 3. The record is created via the standard `client.create()` path — optimistic, synced to remote
251
+ 4. SSP detects the new job, calls your backend HTTP API, and updates the job's `status` field (`pending` → `processing` → `success`/`failed`)
176
252
 
177
253
  ## Common Pitfalls
178
254
 
@@ -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;
@@ -29,8 +29,8 @@ interface SpookyConfig<S extends SchemaStructure> {
29
29
  * - Custom: Provide an object implementing PersistenceClient
30
30
  */
31
31
  persistenceClient?: PersistenceClient | 'surrealdb' | 'localstorage';
32
- /** OpenTelemetry collector endpoint */
33
- otelEndpoint?: string;
32
+ /** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel) */
33
+ otelTransmit?: PinoTransmit;
34
34
  /** Debounce time in ms for stream updates (default: 100) */
35
35
  streamDebounceTime?: number;
36
36
  }
@@ -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,26 @@
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 { fileToUint8Array, textToHtml } from './utils/index';
@@ -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
+ });
@@ -0,0 +1,201 @@
1
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
2
+ import type { DataModule } from '../data/index';
3
+ import type { Sp00kySync } from '../sync/index';
4
+ import type { AuthService } from '../auth/index';
5
+ import type { Logger } from '../../services/logger/index';
6
+ import type { QueryTimeToLive } from '../../types';
7
+ import { semverGt } from '../../utils/semver';
8
+
9
+ // One shared LIVE query over every app's release row. `_00_app_release` is
10
+ // world-readable (root-only writes), one row per app keyed by name — written
11
+ // by `spky deploy` / `spky release` / the git-linked builder. A single
12
+ // registration observes every app at once; a handle for an app with no row
13
+ // simply reports no update. Mirrors the FeatureFlagModule design.
14
+ const RELEASE_QUERY = 'SELECT * FROM _00_app_release';
15
+
16
+ interface ReleaseRow {
17
+ app?: string;
18
+ version?: string;
19
+ cache_bust?: boolean | null;
20
+ mandatory?: boolean | null;
21
+ released_at?: string;
22
+ }
23
+
24
+ export interface AppReleaseSnapshot {
25
+ /** Latest announced version for the app, or undefined when no row exists. */
26
+ version: string | undefined;
27
+ /** Clients should clear SW/caches when reloading onto this version. */
28
+ cacheBust: boolean;
29
+ /** Clients should reload/update immediately instead of asking. */
30
+ mandatory: boolean;
31
+ releasedAt: string | undefined;
32
+ }
33
+
34
+ const EMPTY_SNAPSHOT: AppReleaseSnapshot = {
35
+ version: undefined,
36
+ cacheBust: false,
37
+ mandatory: false,
38
+ releasedAt: undefined,
39
+ };
40
+
41
+ export interface AppReleaseOptions {
42
+ ttl?: QueryTimeToLive;
43
+ }
44
+
45
+ export class AppReleaseHandle {
46
+ private latest: AppReleaseSnapshot = EMPTY_SNAPSHOT;
47
+ private listeners = new Set<(s: AppReleaseSnapshot) => void>();
48
+ private onCloseFn: (() => void) | null = null;
49
+ private closed = false;
50
+
51
+ constructor(public readonly app: string) {}
52
+
53
+ set(snapshot: AppReleaseSnapshot): void {
54
+ if (this.closed) return;
55
+ this.latest = snapshot;
56
+ for (const cb of this.listeners) cb(snapshot);
57
+ }
58
+
59
+ snapshot(): AppReleaseSnapshot {
60
+ return this.latest;
61
+ }
62
+
63
+ version(): string | undefined {
64
+ return this.latest.version;
65
+ }
66
+
67
+ /** True when the announced version is semver-newer than `currentVersion`. */
68
+ updateAvailable(currentVersion: string): boolean {
69
+ return semverGt(this.latest.version, currentVersion);
70
+ }
71
+
72
+ subscribe(cb: (s: AppReleaseSnapshot) => void): () => void {
73
+ this.listeners.add(cb);
74
+ cb(this.latest);
75
+ return () => {
76
+ this.listeners.delete(cb);
77
+ };
78
+ }
79
+
80
+ onClose(cb: () => void): void {
81
+ this.onCloseFn = cb;
82
+ }
83
+
84
+ close(): void {
85
+ if (this.closed) return;
86
+ this.closed = true;
87
+ this.listeners.clear();
88
+ this.onCloseFn?.();
89
+ }
90
+ }
91
+
92
+ export interface AppReleaseModuleDeps<S extends SchemaStructure> {
93
+ dataModule: DataModule<S>;
94
+ sync: Sp00kySync<S>;
95
+ auth: AuthService<S>;
96
+ logger: Logger;
97
+ }
98
+
99
+ export class AppReleaseModule<S extends SchemaStructure> {
100
+ private logger: Logger;
101
+ private handles = new Set<AppReleaseHandle>();
102
+ private authUnsubscribe: (() => void) | null = null;
103
+ private lastUserId: string | null = null;
104
+
105
+ private querySubscription: (() => void) | null = null;
106
+ private starting = false;
107
+ private ttl: QueryTimeToLive = '10m';
108
+ private snapshots = new Map<string, AppReleaseSnapshot>();
109
+ private loaded = false;
110
+
111
+ constructor(private deps: AppReleaseModuleDeps<S>) {
112
+ this.logger = deps.logger.child({ service: 'AppReleaseModule' });
113
+ }
114
+
115
+ init(): void {
116
+ if (this.authUnsubscribe) return;
117
+ // Auth changes re-register the shared query (a new session invalidates the
118
+ // old SSP plan). The table itself is world-readable, so the data is the
119
+ // same for every user — this is purely plumbing hygiene.
120
+ this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
121
+ if (userId === this.lastUserId) return;
122
+ this.lastUserId = userId;
123
+ void this.refresh();
124
+ });
125
+ }
126
+
127
+ release(app: string, options: AppReleaseOptions = {}): AppReleaseHandle {
128
+ const handle = new AppReleaseHandle(app);
129
+ this.handles.add(handle);
130
+ handle.onClose(() => this.handles.delete(handle));
131
+ if (options.ttl) this.ttl = options.ttl;
132
+ if (this.loaded) {
133
+ handle.set(this.snapshots.get(app) ?? EMPTY_SNAPSHOT);
134
+ }
135
+ void this.ensureStarted();
136
+ return handle;
137
+ }
138
+
139
+ async closeAll(): Promise<void> {
140
+ this.authUnsubscribe?.();
141
+ this.authUnsubscribe = null;
142
+ this.teardownQuery();
143
+ for (const handle of [...this.handles]) handle.close();
144
+ }
145
+
146
+ private async refresh(): Promise<void> {
147
+ this.teardownQuery();
148
+ this.loaded = false;
149
+ this.snapshots.clear();
150
+ await this.ensureStarted();
151
+ }
152
+
153
+ private teardownQuery(): void {
154
+ this.querySubscription?.();
155
+ this.querySubscription = null;
156
+ }
157
+
158
+ private async ensureStarted(): Promise<void> {
159
+ if (this.querySubscription || this.starting || this.handles.size === 0) return;
160
+ this.starting = true;
161
+ try {
162
+ const hash = await this.deps.dataModule.query(
163
+ '_00_app_release' as any,
164
+ RELEASE_QUERY,
165
+ {},
166
+ this.ttl,
167
+ );
168
+ this.deps.sync.enqueueDownEvent({ type: 'register', payload: { hash } });
169
+ this.querySubscription = this.deps.dataModule.subscribe(
170
+ hash,
171
+ (records) => this.applyRecords(records as ReleaseRow[]),
172
+ { immediate: true },
173
+ );
174
+ } catch (err) {
175
+ this.logger.warn(
176
+ { err, Category: 'sp00ky-client::AppReleaseModule::register' },
177
+ 'Failed to register app release query',
178
+ );
179
+ } finally {
180
+ this.starting = false;
181
+ }
182
+ }
183
+
184
+ private applyRecords(records: ReleaseRow[]): void {
185
+ this.snapshots.clear();
186
+ for (const row of records ?? []) {
187
+ if (row && typeof row.app === 'string' && typeof row.version === 'string') {
188
+ this.snapshots.set(row.app, {
189
+ version: row.version,
190
+ cacheBust: row.cache_bust === true,
191
+ mandatory: row.mandatory === true,
192
+ releasedAt: row.released_at,
193
+ });
194
+ }
195
+ }
196
+ this.loaded = true;
197
+ for (const handle of this.handles) {
198
+ handle.set(this.snapshots.get(handle.app) ?? EMPTY_SNAPSHOT);
199
+ }
200
+ }
201
+ }
@@ -1,4 +1,5 @@
1
- import { createEventSystem, EventDefinition, EventSystem } from '../../../events/index';
1
+ import type { EventDefinition, EventSystem } from '../../../events/index';
2
+ import { createEventSystem } from '../../../events/index';
2
3
 
3
4
  export const AuthEventTypes = {
4
5
  AuthStateChanged: 'AUTH_STATE_CHANGED',