@spooky-sync/core 0.0.1-canary.139 → 0.0.1-canary.141

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.
package/dist/index.d.ts CHANGED
@@ -1191,6 +1191,62 @@ declare class FeatureFlagModule<S extends SchemaStructure> {
1191
1191
  private applyRecords;
1192
1192
  }
1193
1193
  //#endregion
1194
+ //#region src/modules/app-release/index.d.ts
1195
+ interface AppReleaseSnapshot {
1196
+ /** Latest announced version for the app, or undefined when no row exists. */
1197
+ version: string | undefined;
1198
+ /** Clients should clear SW/caches when reloading onto this version. */
1199
+ cacheBust: boolean;
1200
+ /** Clients should reload/update immediately instead of asking. */
1201
+ mandatory: boolean;
1202
+ releasedAt: string | undefined;
1203
+ }
1204
+ interface AppReleaseOptions {
1205
+ ttl?: QueryTimeToLive;
1206
+ }
1207
+ declare class AppReleaseHandle {
1208
+ readonly app: string;
1209
+ private latest;
1210
+ private listeners;
1211
+ private onCloseFn;
1212
+ private closed;
1213
+ constructor(app: string);
1214
+ set(snapshot: AppReleaseSnapshot): void;
1215
+ snapshot(): AppReleaseSnapshot;
1216
+ version(): string | undefined;
1217
+ /** True when the announced version is semver-newer than `currentVersion`. */
1218
+ updateAvailable(currentVersion: string): boolean;
1219
+ subscribe(cb: (s: AppReleaseSnapshot) => void): () => void;
1220
+ onClose(cb: () => void): void;
1221
+ close(): void;
1222
+ }
1223
+ interface AppReleaseModuleDeps<S extends SchemaStructure> {
1224
+ dataModule: DataModule<S>;
1225
+ sync: Sp00kySync<S>;
1226
+ auth: AuthService<S>;
1227
+ logger: Logger$1;
1228
+ }
1229
+ declare class AppReleaseModule<S extends SchemaStructure> {
1230
+ private deps;
1231
+ private logger;
1232
+ private handles;
1233
+ private authUnsubscribe;
1234
+ private lastUserId;
1235
+ private querySubscription;
1236
+ private starting;
1237
+ private ttl;
1238
+ private snapshots;
1239
+ private loaded;
1240
+ constructor(deps: AppReleaseModuleDeps<S>);
1241
+ init(): void;
1242
+ release(app: string, options?: AppReleaseOptions): AppReleaseHandle;
1243
+ closeAll(): Promise<void>;
1244
+ private refresh;
1245
+ private teardownQuery;
1246
+ private ensureStarted;
1247
+ private applyRecords;
1248
+ }
1249
+ //#endregion
1194
1250
  //#region src/sp00ky.d.ts
1195
1251
  declare class BucketHandle {
1196
1252
  private bucketName;
@@ -1217,6 +1273,7 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1217
1273
  private devTools;
1218
1274
  private crdtManager;
1219
1275
  private featureFlags;
1276
+ private appReleases;
1220
1277
  private preloadedHashes;
1221
1278
  private pendingQueryInits;
1222
1279
  private logger;
@@ -1285,6 +1342,14 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1285
1342
  * its own row, and cannot create or modify assignments.
1286
1343
  */
1287
1344
  feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle;
1345
+ /**
1346
+ * Observe the announced release of an app (`_00_app_release:<app>`, written
1347
+ * by `spky deploy` / `spky release`). The handle's `snapshot()` carries the
1348
+ * announced version plus the cache-bust/mandatory flags, and
1349
+ * `updateAvailable(currentVersion)` compares it semver-wise against the
1350
+ * running build. World-readable; writes are root-only.
1351
+ */
1352
+ appRelease(app: string, options?: AppReleaseOptions): AppReleaseHandle;
1288
1353
  authenticate(token: string): Promise<surrealdb0.Tokens>;
1289
1354
  /**
1290
1355
  * Open a CRDT field for collaborative editing.
@@ -1388,6 +1453,10 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1388
1453
  private fetchSessionId;
1389
1454
  }
1390
1455
  //#endregion
1456
+ //#region src/utils/semver.d.ts
1457
+ /** True when `a` is a valid version strictly greater than valid version `b`. */
1458
+ declare function semverGt(a: unknown, b: unknown): boolean;
1459
+ //#endregion
1391
1460
  //#region src/utils/index.d.ts
1392
1461
  declare function fileToUint8Array(file: File | Blob): Promise<Uint8Array>;
1393
1462
  /**
@@ -1400,4 +1469,4 @@ declare function textToHtml(text: string): string;
1400
1469
  */
1401
1470
 
1402
1471
  //#endregion
1403
- export { AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
1472
+ export { AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, createAuthEventSystem, cursorColorFromName, fileToUint8Array, semverGt, textToHtml };
package/dist/index.js CHANGED
@@ -3004,6 +3004,7 @@ var DataModule = class {
3004
3004
  const record = {
3005
3005
  path,
3006
3006
  payload: JSON.stringify(payload),
3007
+ status: "pending",
3007
3008
  max_retries: options?.max_retries ?? 3,
3008
3009
  retry_strategy: options?.retry_strategy ?? "linear"
3009
3010
  };
@@ -5106,8 +5107,8 @@ function parseBackendInfo(raw) {
5106
5107
 
5107
5108
  //#endregion
5108
5109
  //#region src/modules/devtools/index.ts
5109
- const CORE_VERSION = "0.0.1-canary.139";
5110
- const WASM_VERSION = "0.0.1-canary.139";
5110
+ const CORE_VERSION = "0.0.1-canary.141";
5111
+ const WASM_VERSION = "0.0.1-canary.141";
5111
5112
  const SURREAL_VERSION = "3.0.3";
5112
5113
  var DevToolsService = class {
5113
5114
  eventsHistory = [];
@@ -6907,6 +6908,159 @@ var FeatureFlagModule = class {
6907
6908
  }
6908
6909
  };
6909
6910
 
6911
+ //#endregion
6912
+ //#region src/utils/semver.ts
6913
+ function parse(v) {
6914
+ const parts = String(v ?? "").trim().split(".");
6915
+ if (parts.length === 0 || parts.length > 3 || parts[0] === "") return null;
6916
+ const nums = [];
6917
+ for (let i = 0; i < 3; i++) {
6918
+ const raw = parts[i] ?? "0";
6919
+ if (!/^\d+$/.test(raw)) return null;
6920
+ nums.push(parseInt(raw, 10));
6921
+ }
6922
+ return nums;
6923
+ }
6924
+ /** True when `a` is a valid version strictly greater than valid version `b`. */
6925
+ function semverGt(a, b) {
6926
+ const pa = parse(a);
6927
+ const pb = parse(b);
6928
+ if (!pa || !pb) return false;
6929
+ for (let i = 0; i < 3; i++) {
6930
+ if (pa[i] > pb[i]) return true;
6931
+ if (pa[i] < pb[i]) return false;
6932
+ }
6933
+ return false;
6934
+ }
6935
+
6936
+ //#endregion
6937
+ //#region src/modules/app-release/index.ts
6938
+ const RELEASE_QUERY = "SELECT * FROM _00_app_release";
6939
+ const EMPTY_SNAPSHOT = {
6940
+ version: void 0,
6941
+ cacheBust: false,
6942
+ mandatory: false,
6943
+ releasedAt: void 0
6944
+ };
6945
+ var AppReleaseHandle = class {
6946
+ latest = EMPTY_SNAPSHOT;
6947
+ listeners = /* @__PURE__ */ new Set();
6948
+ onCloseFn = null;
6949
+ closed = false;
6950
+ constructor(app) {
6951
+ this.app = app;
6952
+ }
6953
+ set(snapshot) {
6954
+ if (this.closed) return;
6955
+ this.latest = snapshot;
6956
+ for (const cb of this.listeners) cb(snapshot);
6957
+ }
6958
+ snapshot() {
6959
+ return this.latest;
6960
+ }
6961
+ version() {
6962
+ return this.latest.version;
6963
+ }
6964
+ /** True when the announced version is semver-newer than `currentVersion`. */
6965
+ updateAvailable(currentVersion) {
6966
+ return semverGt(this.latest.version, currentVersion);
6967
+ }
6968
+ subscribe(cb) {
6969
+ this.listeners.add(cb);
6970
+ cb(this.latest);
6971
+ return () => {
6972
+ this.listeners.delete(cb);
6973
+ };
6974
+ }
6975
+ onClose(cb) {
6976
+ this.onCloseFn = cb;
6977
+ }
6978
+ close() {
6979
+ if (this.closed) return;
6980
+ this.closed = true;
6981
+ this.listeners.clear();
6982
+ this.onCloseFn?.();
6983
+ }
6984
+ };
6985
+ var AppReleaseModule = class {
6986
+ logger;
6987
+ handles = /* @__PURE__ */ new Set();
6988
+ authUnsubscribe = null;
6989
+ lastUserId = null;
6990
+ querySubscription = null;
6991
+ starting = false;
6992
+ ttl = "10m";
6993
+ snapshots = /* @__PURE__ */ new Map();
6994
+ loaded = false;
6995
+ constructor(deps) {
6996
+ this.deps = deps;
6997
+ this.logger = deps.logger.child({ service: "AppReleaseModule" });
6998
+ }
6999
+ init() {
7000
+ if (this.authUnsubscribe) return;
7001
+ this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
7002
+ if (userId === this.lastUserId) return;
7003
+ this.lastUserId = userId;
7004
+ this.refresh();
7005
+ });
7006
+ }
7007
+ release(app, options = {}) {
7008
+ const handle = new AppReleaseHandle(app);
7009
+ this.handles.add(handle);
7010
+ handle.onClose(() => this.handles.delete(handle));
7011
+ if (options.ttl) this.ttl = options.ttl;
7012
+ if (this.loaded) handle.set(this.snapshots.get(app) ?? EMPTY_SNAPSHOT);
7013
+ this.ensureStarted();
7014
+ return handle;
7015
+ }
7016
+ async closeAll() {
7017
+ this.authUnsubscribe?.();
7018
+ this.authUnsubscribe = null;
7019
+ this.teardownQuery();
7020
+ for (const handle of [...this.handles]) handle.close();
7021
+ }
7022
+ async refresh() {
7023
+ this.teardownQuery();
7024
+ this.loaded = false;
7025
+ this.snapshots.clear();
7026
+ await this.ensureStarted();
7027
+ }
7028
+ teardownQuery() {
7029
+ this.querySubscription?.();
7030
+ this.querySubscription = null;
7031
+ }
7032
+ async ensureStarted() {
7033
+ if (this.querySubscription || this.starting || this.handles.size === 0) return;
7034
+ this.starting = true;
7035
+ try {
7036
+ const hash = await this.deps.dataModule.query("_00_app_release", RELEASE_QUERY, {}, this.ttl);
7037
+ this.deps.sync.enqueueDownEvent({
7038
+ type: "register",
7039
+ payload: { hash }
7040
+ });
7041
+ this.querySubscription = this.deps.dataModule.subscribe(hash, (records) => this.applyRecords(records), { immediate: true });
7042
+ } catch (err) {
7043
+ this.logger.warn({
7044
+ err,
7045
+ Category: "sp00ky-client::AppReleaseModule::register"
7046
+ }, "Failed to register app release query");
7047
+ } finally {
7048
+ this.starting = false;
7049
+ }
7050
+ }
7051
+ applyRecords(records) {
7052
+ this.snapshots.clear();
7053
+ for (const row of records ?? []) if (row && typeof row.app === "string" && typeof row.version === "string") this.snapshots.set(row.app, {
7054
+ version: row.version,
7055
+ cacheBust: row.cache_bust === true,
7056
+ mandatory: row.mandatory === true,
7057
+ releasedAt: row.released_at
7058
+ });
7059
+ this.loaded = true;
7060
+ for (const handle of this.handles) handle.set(this.snapshots.get(handle.app) ?? EMPTY_SNAPSHOT);
7061
+ }
7062
+ };
7063
+
6910
7064
  //#endregion
6911
7065
  //#region src/services/persistence/localstorage.ts
6912
7066
  var LocalStoragePersistenceClient = class {
@@ -7084,6 +7238,7 @@ var Sp00kyClient = class {
7084
7238
  devTools;
7085
7239
  crdtManager;
7086
7240
  featureFlags;
7241
+ appReleases;
7087
7242
  preloadedHashes = /* @__PURE__ */ new Set();
7088
7243
  pendingQueryInits = /* @__PURE__ */ new Map();
7089
7244
  logger;
@@ -7157,6 +7312,12 @@ var Sp00kyClient = class {
7157
7312
  auth: this.auth,
7158
7313
  logger
7159
7314
  });
7315
+ this.appReleases = new AppReleaseModule({
7316
+ dataModule: this.dataModule,
7317
+ sync: this.sync,
7318
+ auth: this.auth,
7319
+ logger
7320
+ });
7160
7321
  this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
7161
7322
  this.streamProcessor.addReceiver(this.devTools);
7162
7323
  this.setupCallbacks();
@@ -7265,6 +7426,8 @@ var Sp00kyClient = class {
7265
7426
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sync initialized");
7266
7427
  this.featureFlags.init();
7267
7428
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "FeatureFlagModule initialized");
7429
+ this.appReleases.init();
7430
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "AppReleaseModule initialized");
7268
7431
  this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization completed successfully");
7269
7432
  } catch (e) {
7270
7433
  this.logger.error({
@@ -7362,6 +7525,7 @@ var Sp00kyClient = class {
7362
7525
  }
7363
7526
  async close() {
7364
7527
  await this.featureFlags.closeAll();
7528
+ await this.appReleases.closeAll();
7365
7529
  this.crdtManager.closeAll();
7366
7530
  await this.local.close();
7367
7531
  await this.remote.close();
@@ -7378,6 +7542,16 @@ var Sp00kyClient = class {
7378
7542
  feature(key, options) {
7379
7543
  return this.featureFlags.feature(key, options);
7380
7544
  }
7545
+ /**
7546
+ * Observe the announced release of an app (`_00_app_release:<app>`, written
7547
+ * by `spky deploy` / `spky release`). The handle's `snapshot()` carries the
7548
+ * announced version plus the cache-bust/mandatory flags, and
7549
+ * `updateAvailable(currentVersion)` compares it semver-wise against the
7550
+ * running build. World-readable; writes are root-only.
7551
+ */
7552
+ appRelease(app, options) {
7553
+ return this.appReleases.release(app, options);
7554
+ }
7381
7555
  authenticate(token) {
7382
7556
  return this.remote.getClient().authenticate(token);
7383
7557
  }
@@ -7600,4 +7774,4 @@ var Sp00kyClient = class {
7600
7774
  };
7601
7775
 
7602
7776
  //#endregion
7603
- export { AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
7777
+ export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, semverGt, textToHtml };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.139",
3
+ "version": "0.0.1-canary.141",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.139",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.139",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.141",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.141",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "fast-json-patch": "^3.1.1",
package/src/index.ts CHANGED
@@ -8,4 +8,11 @@ export {
8
8
  type FeatureFlagOptions,
9
9
  type FeatureFlagSnapshot,
10
10
  } from './modules/feature-flag/index';
11
+ export {
12
+ AppReleaseModule,
13
+ AppReleaseHandle,
14
+ type AppReleaseOptions,
15
+ type AppReleaseSnapshot,
16
+ } from './modules/app-release/index';
17
+ export { semverGt } from './utils/semver';
11
18
  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
+ }
@@ -57,6 +57,7 @@ describe('DataModule.runRecurring', () => {
57
57
  expect(record.next_run_at).toBeInstanceOf(Date);
58
58
  expect(record.assigned_to).toBe(CONN);
59
59
  expect(record.path).toBe('/syncGames');
60
+ expect(record.status).toBe('pending');
60
61
  expect(JSON.parse(record.payload)).toEqual({ connection: CONN });
61
62
  });
62
63
 
@@ -103,6 +104,21 @@ describe('DataModule.runRecurring', () => {
103
104
  });
104
105
  });
105
106
 
107
+ describe('DataModule.run', () => {
108
+ it('creates the one-shot job with status pending so the optimistic row reads in-flight', async () => {
109
+ const { dm, create } = makeDm([]);
110
+ await dm.run('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { assignedTo: CONN });
111
+
112
+ expect(create).toHaveBeenCalledTimes(1);
113
+ const [id, record] = create.mock.calls[0] as [string, any];
114
+ expect(id.startsWith('job:')).toBe(true);
115
+ // The schema's DEFAULT ALWAYS "pending" only runs server-side; without the
116
+ // explicit field the local optimistic row has status undefined and
117
+ // in-flight indicators miss it until the first server echo.
118
+ expect(record.status).toBe('pending');
119
+ });
120
+ });
121
+
106
122
  describe('DataModule.pokeRecurring', () => {
107
123
  it('bumps next_run_at on the existing schedule row', async () => {
108
124
  const { dm, update } = makeDm([{ id: 'job:x' }]);
@@ -1205,6 +1205,11 @@ export class DataModule<S extends SchemaStructure> {
1205
1205
  const record: Record<string, unknown> = {
1206
1206
  path,
1207
1207
  payload: JSON.stringify(payload),
1208
+ // Set explicitly: the schema's DEFAULT ALWAYS "pending" is server-side
1209
+ // only. An optimistic local create without it surfaces with status
1210
+ // undefined, so in-flight indicators keyed on pending/processing stay
1211
+ // off until the first server echo (seconds on a delayed job).
1212
+ status: 'pending',
1208
1213
  max_retries: options?.max_retries ?? 3,
1209
1214
  retry_strategy: options?.retry_strategy ?? 'linear',
1210
1215
  };
package/src/sp00ky.ts CHANGED
@@ -46,6 +46,8 @@ import { CrdtManager, CrdtField } from './modules/crdt/index';
46
46
  import { preloadLoro } from './modules/crdt/loro-loader';
47
47
  import { FeatureFlagModule, FeatureFlagHandle } from './modules/feature-flag/index';
48
48
  import type { FeatureFlagOptions } from './modules/feature-flag/index';
49
+ import { AppReleaseModule, AppReleaseHandle } from './modules/app-release/index';
50
+ import type { AppReleaseOptions } from './modules/app-release/index';
49
51
  import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
50
52
  import { ANON_USER_ID, bucketIdForUser } from './modules/ref-tables';
51
53
  import { parseParams, encodeRecordId, parseDuration } from './utils/index';
@@ -132,6 +134,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
132
134
  private devTools: DevToolsService;
133
135
  private crdtManager: CrdtManager;
134
136
  private featureFlags!: FeatureFlagModule<S>;
137
+ private appReleases!: AppReleaseModule<S>;
135
138
  // Query hashes already preloaded this session — skip redundant one-shot
136
139
  // fetches when the same preload query is requested again (e.g. a list row
137
140
  // re-rendering). Cleared on process/session end only.
@@ -291,6 +294,15 @@ export class Sp00kyClient<S extends SchemaStructure> {
291
294
  logger,
292
295
  });
293
296
 
297
+ // App release announcements (world-readable `_00_app_release`, written by
298
+ // spky deploy/release). Same shared-live-query design as feature flags.
299
+ this.appReleases = new AppReleaseModule({
300
+ dataModule: this.dataModule,
301
+ sync: this.sync,
302
+ auth: this.auth,
303
+ logger,
304
+ });
305
+
294
306
  // Initialize DevTools
295
307
  this.devTools = new DevToolsService(
296
308
  this.local,
@@ -503,6 +515,12 @@ export class Sp00kyClient<S extends SchemaStructure> {
503
515
  'FeatureFlagModule initialized'
504
516
  );
505
517
 
518
+ this.appReleases.init();
519
+ this.logger.debug(
520
+ { Category: 'sp00ky-client::Sp00kyClient::init' },
521
+ 'AppReleaseModule initialized'
522
+ );
523
+
506
524
  this.logger.info(
507
525
  { Category: 'sp00ky-client::Sp00kyClient::init' },
508
526
  'Sp00kyClient initialization completed successfully'
@@ -636,6 +654,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
636
654
 
637
655
  async close() {
638
656
  await this.featureFlags.closeAll();
657
+ await this.appReleases.closeAll();
639
658
  this.crdtManager.closeAll();
640
659
  await this.local.close();
641
660
  await this.remote.close();
@@ -654,6 +673,17 @@ export class Sp00kyClient<S extends SchemaStructure> {
654
673
  return this.featureFlags.feature(key, options);
655
674
  }
656
675
 
676
+ /**
677
+ * Observe the announced release of an app (`_00_app_release:<app>`, written
678
+ * by `spky deploy` / `spky release`). The handle's `snapshot()` carries the
679
+ * announced version plus the cache-bust/mandatory flags, and
680
+ * `updateAvailable(currentVersion)` compares it semver-wise against the
681
+ * running build. World-readable; writes are root-only.
682
+ */
683
+ appRelease(app: string, options?: AppReleaseOptions): AppReleaseHandle {
684
+ return this.appReleases.release(app, options);
685
+ }
686
+
657
687
  authenticate(token: string) {
658
688
  return this.remote.getClient().authenticate(token);
659
689
  }
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { semverGt } from './semver';
3
+
4
+ describe('semverGt', () => {
5
+ it('compares strictly greater', () => {
6
+ expect(semverGt('1.0.1', '1.0.0')).toBe(true);
7
+ expect(semverGt('1.1.0', '1.0.9')).toBe(true);
8
+ expect(semverGt('2.0.0', '1.9.9')).toBe(true);
9
+ expect(semverGt('1.10.0', '1.9.0')).toBe(true); // numeric, not lexicographic
10
+ });
11
+
12
+ it('equal and lower are not greater', () => {
13
+ expect(semverGt('1.0.0', '1.0.0')).toBe(false);
14
+ expect(semverGt('1.0.0', '1.0.1')).toBe(false);
15
+ expect(semverGt('0.9.9', '1.0.0')).toBe(false);
16
+ });
17
+
18
+ it('missing parts default to zero', () => {
19
+ expect(semverGt('1.2', '1.2.0')).toBe(false);
20
+ expect(semverGt('1.2.1', '1.2')).toBe(true);
21
+ expect(semverGt('2', '1.9.9')).toBe(true);
22
+ });
23
+
24
+ it('junk never compares greater', () => {
25
+ expect(semverGt('abc', '1.0.0')).toBe(false);
26
+ expect(semverGt('1.0.0', 'abc')).toBe(false);
27
+ expect(semverGt('', '0.0.0')).toBe(false);
28
+ expect(semverGt(null, undefined)).toBe(false);
29
+ expect(semverGt('1.0.0-beta', '0.9.0')).toBe(false); // prerelease unsupported
30
+ expect(semverGt('1.2.3.4', '1.0.0')).toBe(false);
31
+ });
32
+ });
@@ -0,0 +1,30 @@
1
+ // Minimal semver comparison for app-release version checks. "X.Y.Z" numeric
2
+ // with missing parts read as 0 ("1.2" == "1.2.0"); any malformed input never
3
+ // compares greater, so a bad release row can never nag (or force-reload)
4
+ // every client.
5
+
6
+ function parse(v: unknown): [number, number, number] | null {
7
+ const parts = String(v ?? '')
8
+ .trim()
9
+ .split('.');
10
+ if (parts.length === 0 || parts.length > 3 || parts[0] === '') return null;
11
+ const nums: number[] = [];
12
+ for (let i = 0; i < 3; i++) {
13
+ const raw = parts[i] ?? '0';
14
+ if (!/^\d+$/.test(raw)) return null;
15
+ nums.push(parseInt(raw, 10));
16
+ }
17
+ return nums as [number, number, number];
18
+ }
19
+
20
+ /** True when `a` is a valid version strictly greater than valid version `b`. */
21
+ export function semverGt(a: unknown, b: unknown): boolean {
22
+ const pa = parse(a);
23
+ const pb = parse(b);
24
+ if (!pa || !pb) return false;
25
+ for (let i = 0; i < 3; i++) {
26
+ if (pa[i] > pb[i]) return true;
27
+ if (pa[i] < pb[i]) return false;
28
+ }
29
+ return false;
30
+ }