@spooky-sync/core 0.0.1-canary.140 → 0.0.1-canary.142
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 +70 -1
- package/dist/index.js +183 -5
- package/package.json +3 -3
- package/src/index.ts +7 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/data/index.ts +10 -1
- package/src/services/database/sqlite-cache-engine.ts +6 -0
- package/src/sp00ky.ts +30 -0
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
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
|
@@ -1469,7 +1469,9 @@ const SYSTEM_TABLES = [
|
|
|
1469
1469
|
"_00_query",
|
|
1470
1470
|
"_00_preload",
|
|
1471
1471
|
"_00_schema",
|
|
1472
|
-
"_00_pending_mutations"
|
|
1472
|
+
"_00_pending_mutations",
|
|
1473
|
+
"_00_user_feature",
|
|
1474
|
+
"_00_app_release"
|
|
1473
1475
|
];
|
|
1474
1476
|
function pureWriteOpResult(op) {
|
|
1475
1477
|
switch (op.kind) {
|
|
@@ -3361,7 +3363,10 @@ var DataModule = class {
|
|
|
3361
3363
|
return hash;
|
|
3362
3364
|
}
|
|
3363
3365
|
async createNewQuery({ recordId, surql: surqlString, params, ttl, tableName, plan }) {
|
|
3364
|
-
const tableSchema = this.schema.tables.find((t) => t.name === tableName)
|
|
3366
|
+
const tableSchema = this.schema.tables.find((t) => t.name === tableName) ?? (String(tableName).startsWith("_00_") ? {
|
|
3367
|
+
name: tableName,
|
|
3368
|
+
columns: {}
|
|
3369
|
+
} : void 0);
|
|
3365
3370
|
if (!tableSchema) throw new Error(`Table ${tableName} not found`);
|
|
3366
3371
|
let [configRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: recordId }));
|
|
3367
3372
|
if (!configRecord) {
|
|
@@ -5107,8 +5112,8 @@ function parseBackendInfo(raw) {
|
|
|
5107
5112
|
|
|
5108
5113
|
//#endregion
|
|
5109
5114
|
//#region src/modules/devtools/index.ts
|
|
5110
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
5111
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
5115
|
+
const CORE_VERSION = "0.0.1-canary.142";
|
|
5116
|
+
const WASM_VERSION = "0.0.1-canary.142";
|
|
5112
5117
|
const SURREAL_VERSION = "3.0.3";
|
|
5113
5118
|
var DevToolsService = class {
|
|
5114
5119
|
eventsHistory = [];
|
|
@@ -6908,6 +6913,159 @@ var FeatureFlagModule = class {
|
|
|
6908
6913
|
}
|
|
6909
6914
|
};
|
|
6910
6915
|
|
|
6916
|
+
//#endregion
|
|
6917
|
+
//#region src/utils/semver.ts
|
|
6918
|
+
function parse(v) {
|
|
6919
|
+
const parts = String(v ?? "").trim().split(".");
|
|
6920
|
+
if (parts.length === 0 || parts.length > 3 || parts[0] === "") return null;
|
|
6921
|
+
const nums = [];
|
|
6922
|
+
for (let i = 0; i < 3; i++) {
|
|
6923
|
+
const raw = parts[i] ?? "0";
|
|
6924
|
+
if (!/^\d+$/.test(raw)) return null;
|
|
6925
|
+
nums.push(parseInt(raw, 10));
|
|
6926
|
+
}
|
|
6927
|
+
return nums;
|
|
6928
|
+
}
|
|
6929
|
+
/** True when `a` is a valid version strictly greater than valid version `b`. */
|
|
6930
|
+
function semverGt(a, b) {
|
|
6931
|
+
const pa = parse(a);
|
|
6932
|
+
const pb = parse(b);
|
|
6933
|
+
if (!pa || !pb) return false;
|
|
6934
|
+
for (let i = 0; i < 3; i++) {
|
|
6935
|
+
if (pa[i] > pb[i]) return true;
|
|
6936
|
+
if (pa[i] < pb[i]) return false;
|
|
6937
|
+
}
|
|
6938
|
+
return false;
|
|
6939
|
+
}
|
|
6940
|
+
|
|
6941
|
+
//#endregion
|
|
6942
|
+
//#region src/modules/app-release/index.ts
|
|
6943
|
+
const RELEASE_QUERY = "SELECT * FROM _00_app_release";
|
|
6944
|
+
const EMPTY_SNAPSHOT = {
|
|
6945
|
+
version: void 0,
|
|
6946
|
+
cacheBust: false,
|
|
6947
|
+
mandatory: false,
|
|
6948
|
+
releasedAt: void 0
|
|
6949
|
+
};
|
|
6950
|
+
var AppReleaseHandle = class {
|
|
6951
|
+
latest = EMPTY_SNAPSHOT;
|
|
6952
|
+
listeners = /* @__PURE__ */ new Set();
|
|
6953
|
+
onCloseFn = null;
|
|
6954
|
+
closed = false;
|
|
6955
|
+
constructor(app) {
|
|
6956
|
+
this.app = app;
|
|
6957
|
+
}
|
|
6958
|
+
set(snapshot) {
|
|
6959
|
+
if (this.closed) return;
|
|
6960
|
+
this.latest = snapshot;
|
|
6961
|
+
for (const cb of this.listeners) cb(snapshot);
|
|
6962
|
+
}
|
|
6963
|
+
snapshot() {
|
|
6964
|
+
return this.latest;
|
|
6965
|
+
}
|
|
6966
|
+
version() {
|
|
6967
|
+
return this.latest.version;
|
|
6968
|
+
}
|
|
6969
|
+
/** True when the announced version is semver-newer than `currentVersion`. */
|
|
6970
|
+
updateAvailable(currentVersion) {
|
|
6971
|
+
return semverGt(this.latest.version, currentVersion);
|
|
6972
|
+
}
|
|
6973
|
+
subscribe(cb) {
|
|
6974
|
+
this.listeners.add(cb);
|
|
6975
|
+
cb(this.latest);
|
|
6976
|
+
return () => {
|
|
6977
|
+
this.listeners.delete(cb);
|
|
6978
|
+
};
|
|
6979
|
+
}
|
|
6980
|
+
onClose(cb) {
|
|
6981
|
+
this.onCloseFn = cb;
|
|
6982
|
+
}
|
|
6983
|
+
close() {
|
|
6984
|
+
if (this.closed) return;
|
|
6985
|
+
this.closed = true;
|
|
6986
|
+
this.listeners.clear();
|
|
6987
|
+
this.onCloseFn?.();
|
|
6988
|
+
}
|
|
6989
|
+
};
|
|
6990
|
+
var AppReleaseModule = class {
|
|
6991
|
+
logger;
|
|
6992
|
+
handles = /* @__PURE__ */ new Set();
|
|
6993
|
+
authUnsubscribe = null;
|
|
6994
|
+
lastUserId = null;
|
|
6995
|
+
querySubscription = null;
|
|
6996
|
+
starting = false;
|
|
6997
|
+
ttl = "10m";
|
|
6998
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
6999
|
+
loaded = false;
|
|
7000
|
+
constructor(deps) {
|
|
7001
|
+
this.deps = deps;
|
|
7002
|
+
this.logger = deps.logger.child({ service: "AppReleaseModule" });
|
|
7003
|
+
}
|
|
7004
|
+
init() {
|
|
7005
|
+
if (this.authUnsubscribe) return;
|
|
7006
|
+
this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
|
|
7007
|
+
if (userId === this.lastUserId) return;
|
|
7008
|
+
this.lastUserId = userId;
|
|
7009
|
+
this.refresh();
|
|
7010
|
+
});
|
|
7011
|
+
}
|
|
7012
|
+
release(app, options = {}) {
|
|
7013
|
+
const handle = new AppReleaseHandle(app);
|
|
7014
|
+
this.handles.add(handle);
|
|
7015
|
+
handle.onClose(() => this.handles.delete(handle));
|
|
7016
|
+
if (options.ttl) this.ttl = options.ttl;
|
|
7017
|
+
if (this.loaded) handle.set(this.snapshots.get(app) ?? EMPTY_SNAPSHOT);
|
|
7018
|
+
this.ensureStarted();
|
|
7019
|
+
return handle;
|
|
7020
|
+
}
|
|
7021
|
+
async closeAll() {
|
|
7022
|
+
this.authUnsubscribe?.();
|
|
7023
|
+
this.authUnsubscribe = null;
|
|
7024
|
+
this.teardownQuery();
|
|
7025
|
+
for (const handle of [...this.handles]) handle.close();
|
|
7026
|
+
}
|
|
7027
|
+
async refresh() {
|
|
7028
|
+
this.teardownQuery();
|
|
7029
|
+
this.loaded = false;
|
|
7030
|
+
this.snapshots.clear();
|
|
7031
|
+
await this.ensureStarted();
|
|
7032
|
+
}
|
|
7033
|
+
teardownQuery() {
|
|
7034
|
+
this.querySubscription?.();
|
|
7035
|
+
this.querySubscription = null;
|
|
7036
|
+
}
|
|
7037
|
+
async ensureStarted() {
|
|
7038
|
+
if (this.querySubscription || this.starting || this.handles.size === 0) return;
|
|
7039
|
+
this.starting = true;
|
|
7040
|
+
try {
|
|
7041
|
+
const hash = await this.deps.dataModule.query("_00_app_release", RELEASE_QUERY, {}, this.ttl);
|
|
7042
|
+
this.deps.sync.enqueueDownEvent({
|
|
7043
|
+
type: "register",
|
|
7044
|
+
payload: { hash }
|
|
7045
|
+
});
|
|
7046
|
+
this.querySubscription = this.deps.dataModule.subscribe(hash, (records) => this.applyRecords(records), { immediate: true });
|
|
7047
|
+
} catch (err) {
|
|
7048
|
+
this.logger.warn({
|
|
7049
|
+
err,
|
|
7050
|
+
Category: "sp00ky-client::AppReleaseModule::register"
|
|
7051
|
+
}, "Failed to register app release query");
|
|
7052
|
+
} finally {
|
|
7053
|
+
this.starting = false;
|
|
7054
|
+
}
|
|
7055
|
+
}
|
|
7056
|
+
applyRecords(records) {
|
|
7057
|
+
this.snapshots.clear();
|
|
7058
|
+
for (const row of records ?? []) if (row && typeof row.app === "string" && typeof row.version === "string") this.snapshots.set(row.app, {
|
|
7059
|
+
version: row.version,
|
|
7060
|
+
cacheBust: row.cache_bust === true,
|
|
7061
|
+
mandatory: row.mandatory === true,
|
|
7062
|
+
releasedAt: row.released_at
|
|
7063
|
+
});
|
|
7064
|
+
this.loaded = true;
|
|
7065
|
+
for (const handle of this.handles) handle.set(this.snapshots.get(handle.app) ?? EMPTY_SNAPSHOT);
|
|
7066
|
+
}
|
|
7067
|
+
};
|
|
7068
|
+
|
|
6911
7069
|
//#endregion
|
|
6912
7070
|
//#region src/services/persistence/localstorage.ts
|
|
6913
7071
|
var LocalStoragePersistenceClient = class {
|
|
@@ -7085,6 +7243,7 @@ var Sp00kyClient = class {
|
|
|
7085
7243
|
devTools;
|
|
7086
7244
|
crdtManager;
|
|
7087
7245
|
featureFlags;
|
|
7246
|
+
appReleases;
|
|
7088
7247
|
preloadedHashes = /* @__PURE__ */ new Set();
|
|
7089
7248
|
pendingQueryInits = /* @__PURE__ */ new Map();
|
|
7090
7249
|
logger;
|
|
@@ -7158,6 +7317,12 @@ var Sp00kyClient = class {
|
|
|
7158
7317
|
auth: this.auth,
|
|
7159
7318
|
logger
|
|
7160
7319
|
});
|
|
7320
|
+
this.appReleases = new AppReleaseModule({
|
|
7321
|
+
dataModule: this.dataModule,
|
|
7322
|
+
sync: this.sync,
|
|
7323
|
+
auth: this.auth,
|
|
7324
|
+
logger
|
|
7325
|
+
});
|
|
7161
7326
|
this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
|
|
7162
7327
|
this.streamProcessor.addReceiver(this.devTools);
|
|
7163
7328
|
this.setupCallbacks();
|
|
@@ -7266,6 +7431,8 @@ var Sp00kyClient = class {
|
|
|
7266
7431
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sync initialized");
|
|
7267
7432
|
this.featureFlags.init();
|
|
7268
7433
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "FeatureFlagModule initialized");
|
|
7434
|
+
this.appReleases.init();
|
|
7435
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "AppReleaseModule initialized");
|
|
7269
7436
|
this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization completed successfully");
|
|
7270
7437
|
} catch (e) {
|
|
7271
7438
|
this.logger.error({
|
|
@@ -7363,6 +7530,7 @@ var Sp00kyClient = class {
|
|
|
7363
7530
|
}
|
|
7364
7531
|
async close() {
|
|
7365
7532
|
await this.featureFlags.closeAll();
|
|
7533
|
+
await this.appReleases.closeAll();
|
|
7366
7534
|
this.crdtManager.closeAll();
|
|
7367
7535
|
await this.local.close();
|
|
7368
7536
|
await this.remote.close();
|
|
@@ -7379,6 +7547,16 @@ var Sp00kyClient = class {
|
|
|
7379
7547
|
feature(key, options) {
|
|
7380
7548
|
return this.featureFlags.feature(key, options);
|
|
7381
7549
|
}
|
|
7550
|
+
/**
|
|
7551
|
+
* Observe the announced release of an app (`_00_app_release:<app>`, written
|
|
7552
|
+
* by `spky deploy` / `spky release`). The handle's `snapshot()` carries the
|
|
7553
|
+
* announced version plus the cache-bust/mandatory flags, and
|
|
7554
|
+
* `updateAvailable(currentVersion)` compares it semver-wise against the
|
|
7555
|
+
* running build. World-readable; writes are root-only.
|
|
7556
|
+
*/
|
|
7557
|
+
appRelease(app, options) {
|
|
7558
|
+
return this.appReleases.release(app, options);
|
|
7559
|
+
}
|
|
7382
7560
|
authenticate(token) {
|
|
7383
7561
|
return this.remote.getClient().authenticate(token);
|
|
7384
7562
|
}
|
|
@@ -7601,4 +7779,4 @@ var Sp00kyClient = class {
|
|
|
7601
7779
|
};
|
|
7602
7780
|
|
|
7603
7781
|
//#endregion
|
|
7604
|
-
export { AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
|
|
7782
|
+
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.
|
|
3
|
+
"version": "0.0.1-canary.142",
|
|
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.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
63
|
+
"@spooky-sync/query-builder": "0.0.1-canary.142",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.142",
|
|
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
|
+
}
|
|
@@ -1779,7 +1779,16 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1779
1779
|
tableName: T;
|
|
1780
1780
|
plan?: QueryPlan;
|
|
1781
1781
|
}): Promise<QueryState> {
|
|
1782
|
-
|
|
1782
|
+
// `_00_*` meta tables (feature flags, app releases) are framework-owned:
|
|
1783
|
+
// they exist in the client db schema by construction but are never part of
|
|
1784
|
+
// the app's generated `schema.tables`, so give them an empty column map
|
|
1785
|
+
// instead of the not-found error (which silently broke every meta-table
|
|
1786
|
+
// live query, e.g. feature flags never updating on this path).
|
|
1787
|
+
const tableSchema =
|
|
1788
|
+
this.schema.tables.find((t) => t.name === tableName) ??
|
|
1789
|
+
(String(tableName).startsWith('_00_')
|
|
1790
|
+
? ({ name: tableName, columns: {} } as any)
|
|
1791
|
+
: undefined);
|
|
1783
1792
|
if (!tableSchema) {
|
|
1784
1793
|
throw new Error(`Table ${tableName} not found`);
|
|
1785
1794
|
}
|
|
@@ -47,6 +47,12 @@ const SYSTEM_TABLES = [
|
|
|
47
47
|
'_00_preload',
|
|
48
48
|
'_00_schema',
|
|
49
49
|
'_00_pending_mutations',
|
|
50
|
+
// Server-written, synced-down meta tables (see meta_tables_client.surql).
|
|
51
|
+
// DEFINE is a noop on this engine, so without seeding them here their synced
|
|
52
|
+
// rows have no local table to land in: feature flags silently fall back to
|
|
53
|
+
// defaults and app-release update notifications never show.
|
|
54
|
+
'_00_user_feature',
|
|
55
|
+
'_00_app_release',
|
|
50
56
|
] as const;
|
|
51
57
|
|
|
52
58
|
export function pureWriteOpResult(op: SqlOp): unknown {
|
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
|
+
}
|