@spooky-sync/core 0.0.1-canary.29 → 0.0.1-canary.291
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/AGENTS.md +81 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2026 -137
- package/dist/index.js +14941 -2433
- package/dist/live.d.ts +141 -0
- package/dist/live.js +709 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +329 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +358 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +1765 -38
- package/dist/worker-edges.js +423 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +46 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +27 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +172 -0
- package/skills/sp00ky-core/SKILL.md +31 -6
- package/skills/sp00ky-core/references/config.md +6 -0
- package/src/boot/auth-flip.saga.test.ts +82 -0
- package/src/boot/auth-flip.saga.ts +62 -0
- package/src/boot/boot.saga.test.ts +158 -0
- package/src/boot/boot.saga.ts +131 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +370 -0
- package/src/client/sp00ky-client.test.ts +303 -0
- package/src/client/sp00ky-client.ts +586 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +161 -0
- package/src/kernel/events.ts +57 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +478 -0
- package/src/live/index.ts +1013 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +141 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.test.ts +355 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +386 -17
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +750 -96
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +217 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +65 -0
- package/src/push/types.ts +174 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +159 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +350 -0
- package/src/query/membership.saga.ts +278 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +293 -0
- package/src/query/register.saga.ts +226 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +63 -0
- package/src/query/sql.ts +93 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +195 -0
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +199 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +146 -61
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +59 -16
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +487 -63
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +467 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +213 -6
- package/src/services/database/sqlite-cache-engine.test.ts +617 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1372 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +139 -0
- package/src/services/database/sqlite-plan-sql.ts +179 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +779 -67
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +58 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +638 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +200 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +304 -0
- package/src/state/reducers.ts +526 -0
- package/src/state/selectors.test.ts +167 -0
- package/src/state/selectors.ts +216 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +659 -0
- package/src/sync/policy.ts +502 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +48 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +624 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +75 -9
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/utils.test.ts +2 -1
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -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,209 @@
|
|
|
1
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
2
|
+
import type { AuthService } from '../auth/index';
|
|
3
|
+
import type { Logger } from '../../services/logger/index';
|
|
4
|
+
import type { QueryTimeToLive } from '../../types';
|
|
5
|
+
import { semverGt } from '../../utils/semver';
|
|
6
|
+
|
|
7
|
+
// One shared LIVE query over every app's release row. `_00_app_release` is
|
|
8
|
+
// world-readable (root-only writes), one row per app keyed by name — written
|
|
9
|
+
// by `spky deploy` / `spky release` / the git-linked builder. A single
|
|
10
|
+
// registration observes every app at once; a handle for an app with no row
|
|
11
|
+
// simply reports no update. Mirrors the FeatureFlagModule design.
|
|
12
|
+
const RELEASE_QUERY = 'SELECT * FROM _00_app_release';
|
|
13
|
+
|
|
14
|
+
interface ReleaseRow {
|
|
15
|
+
app?: string;
|
|
16
|
+
version?: string;
|
|
17
|
+
cache_bust?: boolean | null;
|
|
18
|
+
mandatory?: boolean | null;
|
|
19
|
+
released_at?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AppReleaseSnapshot {
|
|
23
|
+
/** Latest announced version for the app, or undefined when no row exists. */
|
|
24
|
+
version: string | undefined;
|
|
25
|
+
/** Clients should clear SW/caches when reloading onto this version. */
|
|
26
|
+
cacheBust: boolean;
|
|
27
|
+
/** Clients should reload/update immediately instead of asking. */
|
|
28
|
+
mandatory: boolean;
|
|
29
|
+
releasedAt: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const EMPTY_SNAPSHOT: AppReleaseSnapshot = {
|
|
33
|
+
version: undefined,
|
|
34
|
+
cacheBust: false,
|
|
35
|
+
mandatory: false,
|
|
36
|
+
releasedAt: undefined,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface AppReleaseOptions {
|
|
40
|
+
ttl?: QueryTimeToLive;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class AppReleaseHandle {
|
|
44
|
+
private latest: AppReleaseSnapshot = EMPTY_SNAPSHOT;
|
|
45
|
+
private listeners = new Set<(s: AppReleaseSnapshot) => void>();
|
|
46
|
+
private onCloseFn: (() => void) | null = null;
|
|
47
|
+
private closed = false;
|
|
48
|
+
|
|
49
|
+
constructor(public readonly app: string) {}
|
|
50
|
+
|
|
51
|
+
set(snapshot: AppReleaseSnapshot): void {
|
|
52
|
+
if (this.closed) return;
|
|
53
|
+
this.latest = snapshot;
|
|
54
|
+
for (const cb of this.listeners) cb(snapshot);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
snapshot(): AppReleaseSnapshot {
|
|
58
|
+
return this.latest;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
version(): string | undefined {
|
|
62
|
+
return this.latest.version;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** True when the announced version is semver-newer than `currentVersion`. */
|
|
66
|
+
updateAvailable(currentVersion: string): boolean {
|
|
67
|
+
return semverGt(this.latest.version, currentVersion);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
subscribe(cb: (s: AppReleaseSnapshot) => void): () => void {
|
|
71
|
+
this.listeners.add(cb);
|
|
72
|
+
cb(this.latest);
|
|
73
|
+
return () => {
|
|
74
|
+
this.listeners.delete(cb);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
onClose(cb: () => void): void {
|
|
79
|
+
this.onCloseFn = cb;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
close(): void {
|
|
83
|
+
if (this.closed) return;
|
|
84
|
+
this.closed = true;
|
|
85
|
+
this.listeners.clear();
|
|
86
|
+
this.onCloseFn?.();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The slice of the query engine this module drives: register a live query
|
|
91
|
+
* locally, ask for its remote registration, subscribe to its rows. */
|
|
92
|
+
export interface LiveQueryPort {
|
|
93
|
+
query(table: string, surql: string, params: Record<string, unknown>, ttl: QueryTimeToLive): Promise<string>;
|
|
94
|
+
subscribe(hash: string, cb: (records: Record<string, any>[]) => void, options?: { immediate?: boolean }): () => void;
|
|
95
|
+
}
|
|
96
|
+
export interface RemoteRegisterPort {
|
|
97
|
+
enqueueDownEvent(event: { type: 'register'; payload: { hash: string } }): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface AppReleaseModuleDeps<S extends SchemaStructure> {
|
|
101
|
+
dataModule: LiveQueryPort;
|
|
102
|
+
sync: RemoteRegisterPort;
|
|
103
|
+
auth: Pick<AuthService<S>, 'subscribe'>;
|
|
104
|
+
logger: Logger;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class AppReleaseModule<S extends SchemaStructure> {
|
|
108
|
+
private logger: Logger;
|
|
109
|
+
private handles = new Set<AppReleaseHandle>();
|
|
110
|
+
private authUnsubscribe: (() => void) | null = null;
|
|
111
|
+
private lastUserId: string | null = null;
|
|
112
|
+
|
|
113
|
+
private querySubscription: (() => void) | null = null;
|
|
114
|
+
private starting = false;
|
|
115
|
+
private ttl: QueryTimeToLive = '10m';
|
|
116
|
+
private snapshots = new Map<string, AppReleaseSnapshot>();
|
|
117
|
+
private loaded = false;
|
|
118
|
+
|
|
119
|
+
constructor(private deps: AppReleaseModuleDeps<S>) {
|
|
120
|
+
this.logger = deps.logger.child({ service: 'AppReleaseModule' });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
init(): void {
|
|
124
|
+
if (this.authUnsubscribe) return;
|
|
125
|
+
// Auth changes re-register the shared query (a new session invalidates the
|
|
126
|
+
// old SSP plan). The table itself is world-readable, so the data is the
|
|
127
|
+
// same for every user — this is purely plumbing hygiene.
|
|
128
|
+
this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
|
|
129
|
+
if (userId === this.lastUserId) return;
|
|
130
|
+
this.lastUserId = userId;
|
|
131
|
+
void this.refresh();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
release(app: string, options: AppReleaseOptions = {}): AppReleaseHandle {
|
|
136
|
+
const handle = new AppReleaseHandle(app);
|
|
137
|
+
this.handles.add(handle);
|
|
138
|
+
handle.onClose(() => this.handles.delete(handle));
|
|
139
|
+
if (options.ttl) this.ttl = options.ttl;
|
|
140
|
+
if (this.loaded) {
|
|
141
|
+
handle.set(this.snapshots.get(app) ?? EMPTY_SNAPSHOT);
|
|
142
|
+
}
|
|
143
|
+
void this.ensureStarted();
|
|
144
|
+
return handle;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async closeAll(): Promise<void> {
|
|
148
|
+
this.authUnsubscribe?.();
|
|
149
|
+
this.authUnsubscribe = null;
|
|
150
|
+
this.teardownQuery();
|
|
151
|
+
for (const handle of [...this.handles]) handle.close();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async refresh(): Promise<void> {
|
|
155
|
+
this.teardownQuery();
|
|
156
|
+
this.loaded = false;
|
|
157
|
+
this.snapshots.clear();
|
|
158
|
+
await this.ensureStarted();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private teardownQuery(): void {
|
|
162
|
+
this.querySubscription?.();
|
|
163
|
+
this.querySubscription = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async ensureStarted(): Promise<void> {
|
|
167
|
+
if (this.querySubscription || this.starting || this.handles.size === 0) return;
|
|
168
|
+
this.starting = true;
|
|
169
|
+
try {
|
|
170
|
+
const hash = await this.deps.dataModule.query(
|
|
171
|
+
'_00_app_release' as any,
|
|
172
|
+
RELEASE_QUERY,
|
|
173
|
+
{},
|
|
174
|
+
this.ttl,
|
|
175
|
+
);
|
|
176
|
+
this.deps.sync.enqueueDownEvent({ type: 'register', payload: { hash } });
|
|
177
|
+
this.querySubscription = this.deps.dataModule.subscribe(
|
|
178
|
+
hash,
|
|
179
|
+
(records) => this.applyRecords(records as ReleaseRow[]),
|
|
180
|
+
{ immediate: true },
|
|
181
|
+
);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
this.logger.warn(
|
|
184
|
+
{ err, Category: 'sp00ky-client::AppReleaseModule::register' },
|
|
185
|
+
'Failed to register app release query',
|
|
186
|
+
);
|
|
187
|
+
} finally {
|
|
188
|
+
this.starting = false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private applyRecords(records: ReleaseRow[]): void {
|
|
193
|
+
this.snapshots.clear();
|
|
194
|
+
for (const row of records ?? []) {
|
|
195
|
+
if (row && typeof row.app === 'string' && typeof row.version === 'string') {
|
|
196
|
+
this.snapshots.set(row.app, {
|
|
197
|
+
version: row.version,
|
|
198
|
+
cacheBust: row.cache_bust === true,
|
|
199
|
+
mandatory: row.mandatory === true,
|
|
200
|
+
releasedAt: row.released_at,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
this.loaded = true;
|
|
205
|
+
for (const handle of this.handles) {
|
|
206
|
+
handle.set(this.snapshots.get(handle.app) ?? EMPTY_SNAPSHOT);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { AuthService } from './index';
|
|
3
|
+
|
|
4
|
+
// A SurrealDB record-access JWT carries the access method as `AC` and the
|
|
5
|
+
// `$auth.id` record id as `ID`. Only the payload matters here - nothing in the
|
|
6
|
+
// client verifies the signature, the server does.
|
|
7
|
+
function jwt(claims: Record<string, unknown>): string {
|
|
8
|
+
const b64 = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
9
|
+
return `header.${b64}.signature`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const silentLogger = () =>
|
|
13
|
+
({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: () => silentLogger() }) as any;
|
|
14
|
+
|
|
15
|
+
function makeAuth(opts: { token?: string | null; query?: any; authenticate?: any } = {}) {
|
|
16
|
+
const store = new Map<string, unknown>();
|
|
17
|
+
if (opts.token) store.set('sp00ky_auth_token', opts.token);
|
|
18
|
+
const persistence = {
|
|
19
|
+
get: vi.fn(async (k: string) => store.get(k) ?? null),
|
|
20
|
+
set: vi.fn(async (k: string, v: unknown) => void store.set(k, v)),
|
|
21
|
+
remove: vi.fn(async (k: string) => void store.delete(k)),
|
|
22
|
+
} as any;
|
|
23
|
+
const remote = {
|
|
24
|
+
query: opts.query ?? vi.fn(async () => [[]]),
|
|
25
|
+
setAuthToken: vi.fn(),
|
|
26
|
+
getClient: () => ({
|
|
27
|
+
authenticate: opts.authenticate ?? vi.fn(async () => undefined),
|
|
28
|
+
invalidate: vi.fn(async () => undefined),
|
|
29
|
+
}),
|
|
30
|
+
} as any;
|
|
31
|
+
return { auth: new AuthService({} as any, remote, persistence, silentLogger()), remote, persistence, store };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('restoreSessionFromToken', () => {
|
|
35
|
+
it('restores the session from the cached token with NO network', async () => {
|
|
36
|
+
const { auth, remote } = makeAuth({ token: jwt({ AC: 'account', ID: 'user:abc' }) });
|
|
37
|
+
|
|
38
|
+
const userId = await auth.restoreSessionFromToken();
|
|
39
|
+
|
|
40
|
+
expect(userId).toBe('user:abc');
|
|
41
|
+
expect(auth.isAuthenticated).toBe(true);
|
|
42
|
+
expect(auth.currentUser?.id).toBe('user:abc');
|
|
43
|
+
expect(auth.access).toBe('account');
|
|
44
|
+
// The whole point: this is what makes a warm/offline boot paint.
|
|
45
|
+
expect(remote.query).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('notifies subscribers so query routing can be set before any registration', async () => {
|
|
49
|
+
const { auth } = makeAuth({ token: jwt({ AC: 'account', ID: 'user:abc' }) });
|
|
50
|
+
const seen: (string | null)[] = [];
|
|
51
|
+
auth.subscribe((uid) => seen.push(uid));
|
|
52
|
+
|
|
53
|
+
await auth.restoreSessionFromToken();
|
|
54
|
+
|
|
55
|
+
expect(seen).toContain('user:abc');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns null when there is no token, and when the token carries no id', async () => {
|
|
59
|
+
expect(await makeAuth().auth.restoreSessionFromToken()).toBeNull();
|
|
60
|
+
const noId = makeAuth({ token: jwt({ AC: 'account' }) });
|
|
61
|
+
expect(await noId.auth.restoreSessionFromToken()).toBeNull();
|
|
62
|
+
expect(noId.auth.isAuthenticated).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('survives a malformed token rather than throwing', async () => {
|
|
66
|
+
const { auth } = makeAuth({ token: 'not-a-jwt' });
|
|
67
|
+
expect(await auth.restoreSessionFromToken()).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('check() error handling', () => {
|
|
72
|
+
const token = jwt({ AC: 'account', ID: 'user:abc' });
|
|
73
|
+
|
|
74
|
+
it('KEEPS the cached session when the server is unreachable', async () => {
|
|
75
|
+
const { auth, persistence, store } = makeAuth({
|
|
76
|
+
token,
|
|
77
|
+
authenticate: vi.fn(async () => {
|
|
78
|
+
throw new Error('There was a problem with the underlying connection');
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
await auth.restoreSessionFromToken();
|
|
82
|
+
|
|
83
|
+
await auth.check();
|
|
84
|
+
|
|
85
|
+
// A blip must not log the user out - that is what makes offline possible.
|
|
86
|
+
expect(auth.isAuthenticated).toBe(true);
|
|
87
|
+
expect(store.get('sp00ky_auth_token')).toBe(token);
|
|
88
|
+
expect(persistence.remove).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('signs out for real when the server REJECTS the token', async () => {
|
|
92
|
+
const { auth, store } = makeAuth({
|
|
93
|
+
token,
|
|
94
|
+
authenticate: vi.fn(async () => {
|
|
95
|
+
throw new Error('There was a problem with the database: Invalid token');
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
await auth.restoreSessionFromToken();
|
|
99
|
+
|
|
100
|
+
await auth.check();
|
|
101
|
+
|
|
102
|
+
expect(auth.isAuthenticated).toBe(false);
|
|
103
|
+
expect(store.get('sp00ky_auth_token')).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// The transport authenticates a NEW socket from the token it was handed, not
|
|
108
|
+
// from the one the app passed at construction (most apps pass none and sign in
|
|
109
|
+
// later). If sign-in never reaches it, the supervisor's revive loop rebuilds a
|
|
110
|
+
// socket that comes back anonymous and stays that way for the life of the page,
|
|
111
|
+
// while `currentUser` — restored from local storage — keeps the UI signed in.
|
|
112
|
+
// Every view registered after that carries `auth_id = ''`, so `$auth.id`
|
|
113
|
+
// predicates resolve false and the user's own rows silently vanish.
|
|
114
|
+
describe('auth token reaches the transport', () => {
|
|
115
|
+
it('hands the token to the transport when restoring from cache', async () => {
|
|
116
|
+
const token = jwt({ AC: 'account', ID: 'user:abc' });
|
|
117
|
+
const { auth, remote } = makeAuth({ token });
|
|
118
|
+
|
|
119
|
+
await auth.restoreSessionFromToken();
|
|
120
|
+
|
|
121
|
+
expect(remote.setAuthToken).toHaveBeenCalledWith(token);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('hands the token to the transport on check()', async () => {
|
|
125
|
+
const token = jwt({ AC: 'account', ID: 'user:abc' });
|
|
126
|
+
const { auth, remote } = makeAuth({ token });
|
|
127
|
+
|
|
128
|
+
await auth.check();
|
|
129
|
+
|
|
130
|
+
expect(remote.setAuthToken).toHaveBeenCalledWith(token);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('clears it on sign-out so a revived socket does not come back as the old user', async () => {
|
|
134
|
+
const { auth, remote } = makeAuth({ token: jwt({ AC: 'account', ID: 'user:abc' }) });
|
|
135
|
+
await auth.restoreSessionFromToken();
|
|
136
|
+
|
|
137
|
+
await auth.signOut();
|
|
138
|
+
|
|
139
|
+
expect(remote.setAuthToken).toHaveBeenLastCalledWith(null);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { EventDefinition, EventSystem } from '../../../events/index';
|
|
2
|
+
import { createEventSystem } from '../../../events/index';
|
|
3
|
+
import type { ImpersonationInfo } from '../impersonation';
|
|
2
4
|
|
|
3
5
|
export const AuthEventTypes = {
|
|
4
6
|
AuthStateChanged: 'AUTH_STATE_CHANGED',
|
|
7
|
+
ImpersonationChanged: 'IMPERSONATION_CHANGED',
|
|
5
8
|
} as const;
|
|
6
9
|
|
|
7
10
|
export type AuthEventTypeMap = {
|
|
@@ -9,10 +12,14 @@ export type AuthEventTypeMap = {
|
|
|
9
12
|
typeof AuthEventTypes.AuthStateChanged,
|
|
10
13
|
string | null
|
|
11
14
|
>;
|
|
15
|
+
[AuthEventTypes.ImpersonationChanged]: EventDefinition<
|
|
16
|
+
typeof AuthEventTypes.ImpersonationChanged,
|
|
17
|
+
ImpersonationInfo | null
|
|
18
|
+
>;
|
|
12
19
|
};
|
|
13
20
|
|
|
14
21
|
export type AuthEventSystem = EventSystem<AuthEventTypeMap>;
|
|
15
22
|
|
|
16
23
|
export function createAuthEventSystem(): AuthEventSystem {
|
|
17
|
-
return createEventSystem([AuthEventTypes.AuthStateChanged]);
|
|
24
|
+
return createEventSystem([AuthEventTypes.AuthStateChanged, AuthEventTypes.ImpersonationChanged]);
|
|
18
25
|
}
|