@spooky-sync/core 0.0.1-canary.16 → 0.0.1-canary.161
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 +56 -0
- package/dist/index.d.ts +1406 -364
- package/dist/index.js +7773 -1391
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-open.js +276 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +420 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +434 -0
- package/dist/types.d.ts +874 -0
- package/package.json +20 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +16 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +112 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +361 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1151 -129
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +154 -0
- package/src/modules/devtools/index.ts +325 -37
- package/src/modules/devtools/notify-throttle.test.ts +149 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +143 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.ts +12 -5
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +216 -56
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +77 -9
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1333 -92
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/cache-engine.ts +160 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +28 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +478 -67
- 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 +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +13 -13
- package/src/services/database/sqlite-cache-engine.test.ts +464 -0
- package/src/services/database/sqlite-cache-engine.ts +1154 -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 +104 -0
- package/src/services/database/sqlite-plan-sql.ts +106 -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 +113 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +219 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +141 -0
- package/src/services/logger/index.ts +6 -109
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +444 -52
- 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 +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +23 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +278 -0
- package/src/services/tabs/coordinator.test.ts +192 -0
- package/src/services/tabs/coordinator.ts +567 -0
- package/src/services/tabs/fake-ports.fixture.ts +70 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +239 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +581 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.ts +1225 -0
- package/src/types.ts +342 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { AppReleaseModule } from './index';
|
|
3
|
+
|
|
4
|
+
// Mirrors the FeatureFlagModule test rig: the DataModule mock captures the
|
|
5
|
+
// single subscribe callback so tests can push live results, and counts
|
|
6
|
+
// query() calls to assert the query is SHARED across apps.
|
|
7
|
+
function makeDeps() {
|
|
8
|
+
let subCb: ((records: unknown[]) => void) | null = null;
|
|
9
|
+
const calls: Array<{ sql: string; params: unknown }> = [];
|
|
10
|
+
let authCb: ((userId: string | null) => void) | null = null;
|
|
11
|
+
|
|
12
|
+
const dataModule = {
|
|
13
|
+
query: async (_table: string, sql: string, params: unknown) => {
|
|
14
|
+
calls.push({ sql, params });
|
|
15
|
+
return `hash:${calls.length}`;
|
|
16
|
+
},
|
|
17
|
+
subscribe: (_hash: string, cb: (records: unknown[]) => void) => {
|
|
18
|
+
subCb = cb;
|
|
19
|
+
return () => {
|
|
20
|
+
subCb = null;
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const sync = { enqueueDownEvent: () => {} };
|
|
25
|
+
const auth = {
|
|
26
|
+
subscribe: (cb: (userId: string | null) => void) => {
|
|
27
|
+
authCb = cb;
|
|
28
|
+
return () => {
|
|
29
|
+
authCb = null;
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const logger = { child: () => ({ warn: () => {} }) };
|
|
34
|
+
|
|
35
|
+
const deps = { dataModule, sync, auth, logger } as any;
|
|
36
|
+
return {
|
|
37
|
+
deps,
|
|
38
|
+
calls,
|
|
39
|
+
push: (records: unknown[]) => subCb?.(records),
|
|
40
|
+
setUser: (id: string | null) => authCb?.(id),
|
|
41
|
+
hasSub: () => subCb !== null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
46
|
+
|
|
47
|
+
describe('AppReleaseModule', () => {
|
|
48
|
+
let env: ReturnType<typeof makeDeps>;
|
|
49
|
+
let mod: AppReleaseModule<any>;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
env = makeDeps();
|
|
53
|
+
mod = new AppReleaseModule(env.deps);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('registers ONE shared, unfiltered query for many apps', async () => {
|
|
57
|
+
mod.release('web');
|
|
58
|
+
mod.release('admin');
|
|
59
|
+
await tick();
|
|
60
|
+
|
|
61
|
+
expect(env.calls.length).toBe(1);
|
|
62
|
+
expect(env.calls[0].sql).not.toContain('WHERE');
|
|
63
|
+
expect(env.calls[0].sql).toContain('FROM _00_app_release');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('fans the shared result out to each handle by app', async () => {
|
|
67
|
+
const web = mod.release('web');
|
|
68
|
+
const missing = mod.release('missing');
|
|
69
|
+
await tick();
|
|
70
|
+
|
|
71
|
+
env.push([{ app: 'web', version: '1.2.0', cache_bust: true, mandatory: null }]);
|
|
72
|
+
|
|
73
|
+
expect(web.version()).toBe('1.2.0');
|
|
74
|
+
expect(web.snapshot().cacheBust).toBe(true);
|
|
75
|
+
expect(web.snapshot().mandatory).toBe(false);
|
|
76
|
+
expect(missing.version()).toBeUndefined();
|
|
77
|
+
expect(missing.updateAvailable('1.0.0')).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('updateAvailable compares semver against the running build', async () => {
|
|
81
|
+
const web = mod.release('web');
|
|
82
|
+
await tick();
|
|
83
|
+
|
|
84
|
+
env.push([{ app: 'web', version: '1.2.0' }]);
|
|
85
|
+
expect(web.updateAvailable('1.1.9')).toBe(true);
|
|
86
|
+
expect(web.updateAvailable('1.2.0')).toBe(false);
|
|
87
|
+
expect(web.updateAvailable('1.3.0')).toBe(false);
|
|
88
|
+
expect(web.updateAvailable('garbage')).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('observes row updates live without re-registering', async () => {
|
|
92
|
+
const web = mod.release('web');
|
|
93
|
+
await tick();
|
|
94
|
+
|
|
95
|
+
env.push([{ app: 'web', version: '1.0.0' }]);
|
|
96
|
+
expect(web.updateAvailable('1.0.0')).toBe(false);
|
|
97
|
+
|
|
98
|
+
env.push([{ app: 'web', version: '1.0.1', mandatory: true }]);
|
|
99
|
+
expect(web.updateAvailable('1.0.0')).toBe(true);
|
|
100
|
+
expect(web.snapshot().mandatory).toBe(true);
|
|
101
|
+
|
|
102
|
+
expect(env.calls.length).toBe(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('seeds a late-created handle from the already-loaded snapshot', async () => {
|
|
106
|
+
mod.release('web');
|
|
107
|
+
await tick();
|
|
108
|
+
env.push([{ app: 'web', version: '2.0.0' }]);
|
|
109
|
+
|
|
110
|
+
const late = mod.release('web');
|
|
111
|
+
expect(late.version()).toBe('2.0.0');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('re-registers on user change', async () => {
|
|
115
|
+
mod.init();
|
|
116
|
+
const web = mod.release('web');
|
|
117
|
+
await tick();
|
|
118
|
+
env.push([{ app: 'web', version: '1.0.0' }]);
|
|
119
|
+
expect(web.version()).toBe('1.0.0');
|
|
120
|
+
|
|
121
|
+
env.setUser('user:other');
|
|
122
|
+
await tick();
|
|
123
|
+
expect(env.hasSub()).toBe(true); // re-registered under the new session
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
2
|
+
import type { DataModule } from '../data/index';
|
|
3
|
+
import type { Sp00kySync } from '../sync/index';
|
|
4
|
+
import type { AuthService } from '../auth/index';
|
|
5
|
+
import type { Logger } from '../../services/logger/index';
|
|
6
|
+
import type { QueryTimeToLive } from '../../types';
|
|
7
|
+
import { semverGt } from '../../utils/semver';
|
|
8
|
+
|
|
9
|
+
// One shared LIVE query over every app's release row. `_00_app_release` is
|
|
10
|
+
// world-readable (root-only writes), one row per app keyed by name — written
|
|
11
|
+
// by `spky deploy` / `spky release` / the git-linked builder. A single
|
|
12
|
+
// registration observes every app at once; a handle for an app with no row
|
|
13
|
+
// simply reports no update. Mirrors the FeatureFlagModule design.
|
|
14
|
+
const RELEASE_QUERY = 'SELECT * FROM _00_app_release';
|
|
15
|
+
|
|
16
|
+
interface ReleaseRow {
|
|
17
|
+
app?: string;
|
|
18
|
+
version?: string;
|
|
19
|
+
cache_bust?: boolean | null;
|
|
20
|
+
mandatory?: boolean | null;
|
|
21
|
+
released_at?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AppReleaseSnapshot {
|
|
25
|
+
/** Latest announced version for the app, or undefined when no row exists. */
|
|
26
|
+
version: string | undefined;
|
|
27
|
+
/** Clients should clear SW/caches when reloading onto this version. */
|
|
28
|
+
cacheBust: boolean;
|
|
29
|
+
/** Clients should reload/update immediately instead of asking. */
|
|
30
|
+
mandatory: boolean;
|
|
31
|
+
releasedAt: string | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const EMPTY_SNAPSHOT: AppReleaseSnapshot = {
|
|
35
|
+
version: undefined,
|
|
36
|
+
cacheBust: false,
|
|
37
|
+
mandatory: false,
|
|
38
|
+
releasedAt: undefined,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface AppReleaseOptions {
|
|
42
|
+
ttl?: QueryTimeToLive;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class AppReleaseHandle {
|
|
46
|
+
private latest: AppReleaseSnapshot = EMPTY_SNAPSHOT;
|
|
47
|
+
private listeners = new Set<(s: AppReleaseSnapshot) => void>();
|
|
48
|
+
private onCloseFn: (() => void) | null = null;
|
|
49
|
+
private closed = false;
|
|
50
|
+
|
|
51
|
+
constructor(public readonly app: string) {}
|
|
52
|
+
|
|
53
|
+
set(snapshot: AppReleaseSnapshot): void {
|
|
54
|
+
if (this.closed) return;
|
|
55
|
+
this.latest = snapshot;
|
|
56
|
+
for (const cb of this.listeners) cb(snapshot);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
snapshot(): AppReleaseSnapshot {
|
|
60
|
+
return this.latest;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
version(): string | undefined {
|
|
64
|
+
return this.latest.version;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when the announced version is semver-newer than `currentVersion`. */
|
|
68
|
+
updateAvailable(currentVersion: string): boolean {
|
|
69
|
+
return semverGt(this.latest.version, currentVersion);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
subscribe(cb: (s: AppReleaseSnapshot) => void): () => void {
|
|
73
|
+
this.listeners.add(cb);
|
|
74
|
+
cb(this.latest);
|
|
75
|
+
return () => {
|
|
76
|
+
this.listeners.delete(cb);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onClose(cb: () => void): void {
|
|
81
|
+
this.onCloseFn = cb;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
close(): void {
|
|
85
|
+
if (this.closed) return;
|
|
86
|
+
this.closed = true;
|
|
87
|
+
this.listeners.clear();
|
|
88
|
+
this.onCloseFn?.();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface AppReleaseModuleDeps<S extends SchemaStructure> {
|
|
93
|
+
dataModule: DataModule<S>;
|
|
94
|
+
sync: Sp00kySync<S>;
|
|
95
|
+
auth: AuthService<S>;
|
|
96
|
+
logger: Logger;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class AppReleaseModule<S extends SchemaStructure> {
|
|
100
|
+
private logger: Logger;
|
|
101
|
+
private handles = new Set<AppReleaseHandle>();
|
|
102
|
+
private authUnsubscribe: (() => void) | null = null;
|
|
103
|
+
private lastUserId: string | null = null;
|
|
104
|
+
|
|
105
|
+
private querySubscription: (() => void) | null = null;
|
|
106
|
+
private starting = false;
|
|
107
|
+
private ttl: QueryTimeToLive = '10m';
|
|
108
|
+
private snapshots = new Map<string, AppReleaseSnapshot>();
|
|
109
|
+
private loaded = false;
|
|
110
|
+
|
|
111
|
+
constructor(private deps: AppReleaseModuleDeps<S>) {
|
|
112
|
+
this.logger = deps.logger.child({ service: 'AppReleaseModule' });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
init(): void {
|
|
116
|
+
if (this.authUnsubscribe) return;
|
|
117
|
+
// Auth changes re-register the shared query (a new session invalidates the
|
|
118
|
+
// old SSP plan). The table itself is world-readable, so the data is the
|
|
119
|
+
// same for every user — this is purely plumbing hygiene.
|
|
120
|
+
this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
|
|
121
|
+
if (userId === this.lastUserId) return;
|
|
122
|
+
this.lastUserId = userId;
|
|
123
|
+
void this.refresh();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
release(app: string, options: AppReleaseOptions = {}): AppReleaseHandle {
|
|
128
|
+
const handle = new AppReleaseHandle(app);
|
|
129
|
+
this.handles.add(handle);
|
|
130
|
+
handle.onClose(() => this.handles.delete(handle));
|
|
131
|
+
if (options.ttl) this.ttl = options.ttl;
|
|
132
|
+
if (this.loaded) {
|
|
133
|
+
handle.set(this.snapshots.get(app) ?? EMPTY_SNAPSHOT);
|
|
134
|
+
}
|
|
135
|
+
void this.ensureStarted();
|
|
136
|
+
return handle;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async closeAll(): Promise<void> {
|
|
140
|
+
this.authUnsubscribe?.();
|
|
141
|
+
this.authUnsubscribe = null;
|
|
142
|
+
this.teardownQuery();
|
|
143
|
+
for (const handle of [...this.handles]) handle.close();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async refresh(): Promise<void> {
|
|
147
|
+
this.teardownQuery();
|
|
148
|
+
this.loaded = false;
|
|
149
|
+
this.snapshots.clear();
|
|
150
|
+
await this.ensureStarted();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private teardownQuery(): void {
|
|
154
|
+
this.querySubscription?.();
|
|
155
|
+
this.querySubscription = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async ensureStarted(): Promise<void> {
|
|
159
|
+
if (this.querySubscription || this.starting || this.handles.size === 0) return;
|
|
160
|
+
this.starting = true;
|
|
161
|
+
try {
|
|
162
|
+
const hash = await this.deps.dataModule.query(
|
|
163
|
+
'_00_app_release' as any,
|
|
164
|
+
RELEASE_QUERY,
|
|
165
|
+
{},
|
|
166
|
+
this.ttl,
|
|
167
|
+
);
|
|
168
|
+
this.deps.sync.enqueueDownEvent({ type: 'register', payload: { hash } });
|
|
169
|
+
this.querySubscription = this.deps.dataModule.subscribe(
|
|
170
|
+
hash,
|
|
171
|
+
(records) => this.applyRecords(records as ReleaseRow[]),
|
|
172
|
+
{ immediate: true },
|
|
173
|
+
);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
this.logger.warn(
|
|
176
|
+
{ err, Category: 'sp00ky-client::AppReleaseModule::register' },
|
|
177
|
+
'Failed to register app release query',
|
|
178
|
+
);
|
|
179
|
+
} finally {
|
|
180
|
+
this.starting = false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private applyRecords(records: ReleaseRow[]): void {
|
|
185
|
+
this.snapshots.clear();
|
|
186
|
+
for (const row of records ?? []) {
|
|
187
|
+
if (row && typeof row.app === 'string' && typeof row.version === 'string') {
|
|
188
|
+
this.snapshots.set(row.app, {
|
|
189
|
+
version: row.version,
|
|
190
|
+
cacheBust: row.cache_bust === true,
|
|
191
|
+
mandatory: row.mandatory === true,
|
|
192
|
+
releasedAt: row.released_at,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
this.loaded = true;
|
|
197
|
+
for (const handle of this.handles) {
|
|
198
|
+
handle.set(this.snapshots.get(handle.app) ?? EMPTY_SNAPSHOT);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { EventDefinition, EventSystem } from '../../../events/index';
|
|
2
|
+
import { createEventSystem } from '../../../events/index';
|
|
2
3
|
|
|
3
4
|
export const AuthEventTypes = {
|
|
4
5
|
AuthStateChanged: 'AUTH_STATE_CHANGED',
|
|
@@ -1,17 +1,14 @@
|
|
|
1
|
-
import { RemoteDatabaseService } from '../../services/database/remote';
|
|
2
|
-
import {
|
|
3
|
-
import { DataModule } from '../data/index';
|
|
4
|
-
import {
|
|
1
|
+
import type { RemoteDatabaseService } from '../../services/database/remote';
|
|
2
|
+
import type {
|
|
5
3
|
SchemaStructure,
|
|
6
4
|
AccessDefinition,
|
|
7
5
|
ColumnSchema,
|
|
8
6
|
TypeNameToTypeMap,
|
|
9
7
|
} from '@spooky-sync/query-builder';
|
|
10
|
-
import { Logger } from '../../services/logger/index';
|
|
11
|
-
import { encodeRecordId } from '../../utils/index';
|
|
8
|
+
import type { Logger } from '../../services/logger/index';
|
|
12
9
|
export * from './events/index';
|
|
13
10
|
import { AuthEventTypes, createAuthEventSystem } from './events/index';
|
|
14
|
-
import { PersistenceClient } from '../../types';
|
|
11
|
+
import type { PersistenceClient } from '../../types';
|
|
15
12
|
|
|
16
13
|
// Helper to pretty print types
|
|
17
14
|
type Prettify<T> = {
|
|
@@ -38,11 +35,41 @@ type ExtractAccessParams<
|
|
|
38
35
|
}>
|
|
39
36
|
: never;
|
|
40
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Read the `AC` (access-method name) claim from a SurrealDB record-access
|
|
40
|
+
* JWT without verifying it — we only need the claim, the server enforces the
|
|
41
|
+
* token. Returns null on any malformed input. The in-browser SSP needs this
|
|
42
|
+
* to resolve `$access` in table permission predicates (mirrors the session's
|
|
43
|
+
* `$access` that the server's `fn::query::register` reads).
|
|
44
|
+
*/
|
|
45
|
+
function decodeAccessFromToken(token: string): string | null {
|
|
46
|
+
try {
|
|
47
|
+
const payload = token.split('.')[1];
|
|
48
|
+
if (!payload) return null;
|
|
49
|
+
let b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
|
50
|
+
b64 += '='.repeat((4 - (b64.length % 4)) % 4);
|
|
51
|
+
const json =
|
|
52
|
+
typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
|
|
53
|
+
const claims = JSON.parse(json) as Record<string, unknown>;
|
|
54
|
+
const ac = claims.AC ?? claims.ac;
|
|
55
|
+
return typeof ac === 'string' ? ac : null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
export class AuthService<S extends SchemaStructure> {
|
|
42
62
|
// State
|
|
43
63
|
public token: string | null = null;
|
|
44
64
|
public currentUser: any | null = null;
|
|
45
65
|
public isAuthenticated: boolean = false;
|
|
66
|
+
/**
|
|
67
|
+
* The record-access method name for the current session (e.g. `"account"`),
|
|
68
|
+
* derived from the token's `AC` claim. Consumed by the in-browser SSP's
|
|
69
|
+
* permission injection so `$access`-gated table predicates resolve locally,
|
|
70
|
+
* mirroring the server's `$access`. Null when logged out.
|
|
71
|
+
*/
|
|
72
|
+
public access: string | null = null;
|
|
46
73
|
public isLoading: boolean = true;
|
|
47
74
|
|
|
48
75
|
private events = createAuthEventSystem();
|
|
@@ -95,11 +122,11 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
95
122
|
this.isLoading = true;
|
|
96
123
|
|
|
97
124
|
try {
|
|
98
|
-
const token = accessToken || (await this.persistenceClient.get<string>('
|
|
125
|
+
const token = accessToken || (await this.persistenceClient.get<string>('sp00ky_auth_token'));
|
|
99
126
|
|
|
100
127
|
if (!token) {
|
|
101
128
|
this.logger.debug(
|
|
102
|
-
{ Category: '
|
|
129
|
+
{ Category: 'sp00ky-client::AuthService::check' },
|
|
103
130
|
'No token found in storage or arguments'
|
|
104
131
|
);
|
|
105
132
|
this.isLoading = false;
|
|
@@ -119,13 +146,13 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
119
146
|
|
|
120
147
|
if (user && user.id) {
|
|
121
148
|
this.logger.info(
|
|
122
|
-
{ user, Category: '
|
|
149
|
+
{ user, Category: 'sp00ky-client::AuthService::check' },
|
|
123
150
|
'Auth check complete (via $auth.id)'
|
|
124
151
|
);
|
|
125
152
|
await this.setSession(token, user);
|
|
126
153
|
} else {
|
|
127
154
|
this.logger.warn(
|
|
128
|
-
{ Category: '
|
|
155
|
+
{ Category: 'sp00ky-client::AuthService::check' },
|
|
129
156
|
'$auth.id empty, attempting manual user fetch'
|
|
130
157
|
);
|
|
131
158
|
|
|
@@ -140,13 +167,13 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
140
167
|
|
|
141
168
|
if (manualUser && manualUser.id) {
|
|
142
169
|
this.logger.info(
|
|
143
|
-
{ user: manualUser, Category: '
|
|
170
|
+
{ user: manualUser, Category: 'sp00ky-client::AuthService::check' },
|
|
144
171
|
'Auth check complete (via manual fetch)'
|
|
145
172
|
);
|
|
146
173
|
await this.setSession(token, manualUser);
|
|
147
174
|
} else {
|
|
148
175
|
this.logger.warn(
|
|
149
|
-
{ Category: '
|
|
176
|
+
{ Category: 'sp00ky-client::AuthService::check' },
|
|
150
177
|
'Token valid but user not found via fallback'
|
|
151
178
|
);
|
|
152
179
|
await this.signOut();
|
|
@@ -154,7 +181,7 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
154
181
|
}
|
|
155
182
|
} catch (error) {
|
|
156
183
|
this.logger.error(
|
|
157
|
-
{ error, stack: (error as Error).stack, Category: '
|
|
184
|
+
{ error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
|
|
158
185
|
'Auth check failed'
|
|
159
186
|
);
|
|
160
187
|
await this.signOut();
|
|
@@ -170,12 +197,13 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
170
197
|
this.token = null;
|
|
171
198
|
this.currentUser = null;
|
|
172
199
|
this.isAuthenticated = false;
|
|
200
|
+
this.access = null;
|
|
173
201
|
|
|
174
|
-
await this.persistenceClient.remove('
|
|
202
|
+
await this.persistenceClient.remove('sp00ky_auth_token');
|
|
175
203
|
|
|
176
204
|
try {
|
|
177
205
|
await this.remote.getClient().invalidate();
|
|
178
|
-
} catch (
|
|
206
|
+
} catch (_e) {
|
|
179
207
|
// Ignore invalidation errors
|
|
180
208
|
}
|
|
181
209
|
|
|
@@ -186,10 +214,21 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
186
214
|
this.token = token;
|
|
187
215
|
this.currentUser = user;
|
|
188
216
|
this.isAuthenticated = true;
|
|
189
|
-
|
|
217
|
+
// Resolve the access-method name (e.g. "account") for in-browser SSP
|
|
218
|
+
// permission injection. Prefer the token's `AC` claim; fall back to the
|
|
219
|
+
// schema's sole record-access method if the claim is absent.
|
|
220
|
+
this.access = decodeAccessFromToken(token) ?? this.defaultAccessName();
|
|
221
|
+
await this.persistenceClient.set('sp00ky_auth_token', token);
|
|
190
222
|
this.notifyListeners();
|
|
191
223
|
}
|
|
192
224
|
|
|
225
|
+
/** Fallback when the token carries no `AC` claim: if the schema defines
|
|
226
|
+
* exactly one record-access method, assume the session used it. */
|
|
227
|
+
private defaultAccessName(): string | null {
|
|
228
|
+
const names = Object.keys(this.schema.access ?? {});
|
|
229
|
+
return names.length === 1 ? names[0] : null;
|
|
230
|
+
}
|
|
231
|
+
|
|
193
232
|
async signUp<Name extends keyof S['access'] & string>(
|
|
194
233
|
accessName: Name,
|
|
195
234
|
params: ExtractAccessParams<S, Name, 'signup'>
|
|
@@ -212,7 +251,7 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
212
251
|
}
|
|
213
252
|
|
|
214
253
|
this.logger.info(
|
|
215
|
-
{ accessName, runtimeParams, Category: '
|
|
254
|
+
{ accessName, runtimeParams, Category: 'sp00ky-client::AuthService::signUp' },
|
|
216
255
|
'Attempting signup'
|
|
217
256
|
);
|
|
218
257
|
|
|
@@ -222,7 +261,7 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
222
261
|
});
|
|
223
262
|
|
|
224
263
|
this.logger.info(
|
|
225
|
-
{ Category: '
|
|
264
|
+
{ Category: 'sp00ky-client::AuthService::signUp' },
|
|
226
265
|
'Signup successful, token received'
|
|
227
266
|
);
|
|
228
267
|
|
|
@@ -253,7 +292,7 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
253
292
|
}
|
|
254
293
|
|
|
255
294
|
this.logger.info(
|
|
256
|
-
{ accessName, Category: '
|
|
295
|
+
{ accessName, Category: 'sp00ky-client::AuthService::signIn' },
|
|
257
296
|
'Attempting signin'
|
|
258
297
|
);
|
|
259
298
|
|