@spooky-sync/core 0.0.1-canary.22 → 0.0.1-canary.221

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2615 -58
  3. package/dist/index.js +12714 -2408
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +303 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +439 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +472 -0
  11. package/dist/types.d.ts +751 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +36 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/auth.local-first.test.ts +141 -0
  25. package/src/modules/auth/events/index.ts +2 -1
  26. package/src/modules/auth/index.ts +138 -25
  27. package/src/modules/cache/cache.relay.test.ts +95 -0
  28. package/src/modules/cache/index.ts +163 -43
  29. package/src/modules/cache/types.ts +2 -2
  30. package/src/modules/crdt/crdt-field.ts +294 -0
  31. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  32. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  33. package/src/modules/crdt/index.ts +463 -0
  34. package/src/modules/crdt/loro-loader.ts +25 -0
  35. package/src/modules/data/data.hydration.test.ts +142 -0
  36. package/src/modules/data/data.membership.test.ts +523 -0
  37. package/src/modules/data/data.notify-table.test.ts +41 -0
  38. package/src/modules/data/data.pending-ids.test.ts +199 -0
  39. package/src/modules/data/data.rebind.test.ts +170 -0
  40. package/src/modules/data/data.rematerialize.test.ts +114 -0
  41. package/src/modules/data/data.run.test.ts +113 -0
  42. package/src/modules/data/data.settled-writes.test.ts +206 -0
  43. package/src/modules/data/data.status.test.ts +249 -0
  44. package/src/modules/data/id-set-plan.test.ts +122 -0
  45. package/src/modules/data/index.ts +1815 -151
  46. package/src/modules/data/mutation-id.test.ts +25 -0
  47. package/src/modules/data/mutation-id.ts +35 -0
  48. package/src/modules/data/window-query.test.ts +52 -0
  49. package/src/modules/data/window-query.ts +194 -0
  50. package/src/modules/devtools/flags.ts +349 -0
  51. package/src/modules/devtools/index.ts +450 -46
  52. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  53. package/src/modules/devtools/state-shape.test.ts +146 -0
  54. package/src/modules/devtools/storage-info.test.ts +79 -0
  55. package/src/modules/devtools/storage-info.ts +168 -0
  56. package/src/modules/devtools/versions.test.ts +74 -0
  57. package/src/modules/devtools/versions.ts +110 -0
  58. package/src/modules/feature-flag/index.test.ts +251 -0
  59. package/src/modules/feature-flag/index.ts +308 -0
  60. package/src/modules/ref-tables.test.ts +91 -0
  61. package/src/modules/ref-tables.ts +88 -0
  62. package/src/modules/sync/engine.ts +165 -86
  63. package/src/modules/sync/events/index.ts +9 -2
  64. package/src/modules/sync/queue/queue-down.test.ts +228 -0
  65. package/src/modules/sync/queue/queue-down.ts +108 -13
  66. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  67. package/src/modules/sync/queue/queue-up.ts +241 -57
  68. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  69. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  70. package/src/modules/sync/scheduler.ts +215 -13
  71. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  72. package/src/modules/sync/sync.health.test.ts +149 -0
  73. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  74. package/src/modules/sync/sync.live-removal.test.ts +175 -0
  75. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  76. package/src/modules/sync/sync.subquery.test.ts +82 -0
  77. package/src/modules/sync/sync.tabs.test.ts +249 -0
  78. package/src/modules/sync/sync.ts +1851 -98
  79. package/src/modules/sync/utils.test.ts +269 -2
  80. package/src/modules/sync/utils.ts +201 -16
  81. package/src/otel/index.ts +13 -10
  82. package/src/services/blobs/blob-cache.test.ts +359 -0
  83. package/src/services/blobs/blob-cache.ts +603 -0
  84. package/src/services/blobs/blob-manifest.ts +227 -0
  85. package/src/services/blobs/blob-store.test.ts +77 -0
  86. package/src/services/blobs/blob-store.ts +359 -0
  87. package/src/services/blobs/blob.fixture.ts +90 -0
  88. package/src/services/blobs/index.ts +70 -0
  89. package/src/services/database/cache-engine.ts +193 -0
  90. package/src/services/database/connection-supervisor.test.ts +289 -0
  91. package/src/services/database/connection-supervisor.ts +415 -0
  92. package/src/services/database/database.query-timeout.test.ts +83 -0
  93. package/src/services/database/database.ts +41 -12
  94. package/src/services/database/engine-factory.ts +33 -0
  95. package/src/services/database/errors.ts +34 -0
  96. package/src/services/database/events/index.ts +2 -1
  97. package/src/services/database/index.ts +7 -0
  98. package/src/services/database/local-migrator.ts +30 -27
  99. package/src/services/database/local.test.ts +64 -0
  100. package/src/services/database/local.ts +484 -67
  101. package/src/services/database/plan-render.test.ts +159 -0
  102. package/src/services/database/plan-render.ts +108 -0
  103. package/src/services/database/relation-resolver.test.ts +413 -0
  104. package/src/services/database/relation-resolver.ts +0 -0
  105. package/src/services/database/remote.ts +192 -15
  106. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  107. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  108. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  109. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  110. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  111. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  112. package/src/services/database/sqlite-lock-verify.ts +45 -0
  113. package/src/services/database/sqlite-open.test.ts +150 -0
  114. package/src/services/database/sqlite-open.ts +164 -0
  115. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  116. package/src/services/database/sqlite-plan-sql.ts +138 -0
  117. package/src/services/database/sqlite-projection.test.ts +99 -0
  118. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  119. package/src/services/database/sqlite-select.test.ts +246 -0
  120. package/src/services/database/sqlite-select.ts +131 -0
  121. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  122. package/src/services/database/sqlite-transport.ts +224 -0
  123. package/src/services/database/sqlite-worker.ts +437 -0
  124. package/src/services/database/surql-translate.ts +416 -0
  125. package/src/services/database/surreal-cache-engine.ts +161 -0
  126. package/src/services/logger/index.ts +3 -2
  127. package/src/services/persistence/localstorage.ts +2 -2
  128. package/src/services/persistence/resilient.ts +11 -4
  129. package/src/services/persistence/surrealdb.ts +10 -10
  130. package/src/services/stream-processor/index.ts +796 -84
  131. package/src/services/stream-processor/permissions.test.ts +47 -0
  132. package/src/services/stream-processor/permissions.ts +53 -0
  133. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  134. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  135. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  136. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  137. package/src/services/stream-processor/wasm-types.ts +59 -3
  138. package/src/services/tabs/broker-client.ts +283 -0
  139. package/src/services/tabs/broker.test.ts +327 -0
  140. package/src/services/tabs/coordinator.test.ts +365 -0
  141. package/src/services/tabs/coordinator.ts +633 -0
  142. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  143. package/src/services/tabs/leader-locks.ts +75 -0
  144. package/src/services/tabs/protocol.ts +258 -0
  145. package/src/services/tabs/support.ts +36 -0
  146. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  147. package/src/sp00ky.auth-order.test.ts +92 -0
  148. package/src/sp00ky.init-query.test.ts +183 -0
  149. package/src/sp00ky.local-first.test.ts +60 -0
  150. package/src/sp00ky.ts +1720 -0
  151. package/src/types.ts +528 -13
  152. package/src/utils/blurhash.ts +90 -0
  153. package/src/utils/error-classification.test.ts +44 -0
  154. package/src/utils/error-classification.ts +7 -0
  155. package/src/utils/index.ts +79 -13
  156. package/src/utils/parser.test.ts +49 -120
  157. package/src/utils/parser.ts +32 -2
  158. package/src/utils/semver.test.ts +32 -0
  159. package/src/utils/semver.ts +30 -0
  160. package/src/utils/surql.ts +30 -18
  161. package/src/utils/withRetry.test.ts +1 -1
  162. package/tsdown.config.ts +86 -1
  163. package/src/spooky.ts +0 -395
@@ -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
+ }
@@ -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,4 +1,5 @@
1
- import { createEventSystem, EventDefinition, EventSystem } from '../../../events/index';
1
+ import type { EventDefinition, EventSystem } from '../../../events/index';
2
+ import { createEventSystem } from '../../../events/index';
2
3
 
3
4
  export const AuthEventTypes = {
4
5
  AuthStateChanged: 'AUTH_STATE_CHANGED',
@@ -1,17 +1,15 @@
1
- import { RemoteDatabaseService } from '../../services/database/remote';
2
- import { LocalDatabaseService } from '../../services/database/local';
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';
12
+ import { classifySyncError } from '../../utils/error-classification';
15
13
 
16
14
  // Helper to pretty print types
17
15
  type Prettify<T> = {
@@ -38,11 +36,54 @@ type ExtractAccessParams<
38
36
  }>
39
37
  : never;
40
38
 
39
+ /**
40
+ * Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
41
+ * server still enforces the token on every request; this is only so the client
42
+ * can act on what it already holds before a round trip completes.
43
+ *
44
+ * `AC` is the access-method name — the in-browser SSP needs it to resolve
45
+ * `$access` in table permission predicates (mirrors the session's `$access`
46
+ * that the server's `fn::query::register` reads). `ID` is the `$auth.id` record
47
+ * id, which is what lets a warm boot restore a session locally.
48
+ *
49
+ * Returns nulls on any malformed input.
50
+ */
51
+ function decodeTokenClaims(token: string): { access: string | null; userId: string | null } {
52
+ try {
53
+ const payload = token.split('.')[1];
54
+ if (!payload) return { access: null, userId: null };
55
+ let b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
56
+ b64 += '='.repeat((4 - (b64.length % 4)) % 4);
57
+ const json =
58
+ typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
59
+ const claims = JSON.parse(json) as Record<string, unknown>;
60
+ const ac = claims.AC ?? claims.ac;
61
+ const id = claims.ID ?? claims.id;
62
+ return {
63
+ access: typeof ac === 'string' ? ac : null,
64
+ userId: typeof id === 'string' ? id : null,
65
+ };
66
+ } catch {
67
+ return { access: null, userId: null };
68
+ }
69
+ }
70
+
71
+ function decodeAccessFromToken(token: string): string | null {
72
+ return decodeTokenClaims(token).access;
73
+ }
74
+
41
75
  export class AuthService<S extends SchemaStructure> {
42
76
  // State
43
77
  public token: string | null = null;
44
78
  public currentUser: any | null = null;
45
79
  public isAuthenticated: boolean = false;
80
+ /**
81
+ * The record-access method name for the current session (e.g. `"account"`),
82
+ * derived from the token's `AC` claim. Consumed by the in-browser SSP's
83
+ * permission injection so `$access`-gated table predicates resolve locally,
84
+ * mirroring the server's `$access`. Null when logged out.
85
+ */
86
+ public access: string | null = null;
46
87
  public isLoading: boolean = true;
47
88
 
48
89
  private events = createAuthEventSystem();
@@ -88,6 +129,49 @@ export class AuthService<S extends SchemaStructure> {
88
129
  this.events.emit(AuthEventTypes.AuthStateChanged, userId);
89
130
  }
90
131
 
132
+ /**
133
+ * Restore a session from the locally cached JWT, with NO network.
134
+ *
135
+ * This is what makes a warm boot paint instantly and what makes an offline
136
+ * boot possible at all: the token is in local storage, and it already carries
137
+ * both the access method and the `$auth.id` record id. Everything the client
138
+ * needs to route queries (`setCurrentUserId`) and to satisfy `$auth`-gated
139
+ * permission predicates in the in-browser SSP (`setSessionAuth`) is therefore
140
+ * available before a socket exists.
141
+ *
142
+ * The session is OPTIMISTIC: the token is unverified here. `check()` runs
143
+ * afterwards in the background and downgrades to a real sign-out if the
144
+ * server rejects it. Nothing is trusted that the server has not also seen -
145
+ * the local store only ever holds rows the server previously sent.
146
+ *
147
+ * Returns the restored user id, or null when there is no usable token.
148
+ */
149
+ async restoreSessionFromToken(): Promise<string | null> {
150
+ const token = await this.persistenceClient.get<string>('sp00ky_auth_token');
151
+ if (!token) return null;
152
+ const { access, userId } = decodeTokenClaims(token);
153
+ if (!userId) return null;
154
+
155
+ this.token = token;
156
+ // Hand it to the transport too, so a socket rebuilt from scratch later
157
+ // (the supervisor's revive loop) comes back authenticated. Without this the
158
+ // page kept reporting this user while its session was anonymous, and every
159
+ // view registered afterwards was stamped with an empty identity.
160
+ this.remote.setAuthToken(token);
161
+ // Only the id: the full row is not in the token. It lands from the local
162
+ // cache when the app's own `user` query paints, and is replaced wholesale
163
+ // by `check()` once the server answers.
164
+ this.currentUser = { id: userId };
165
+ this.isAuthenticated = true;
166
+ this.access = access ?? this.defaultAccessName();
167
+ this.notifyListeners();
168
+ this.logger.debug(
169
+ { userId, Category: 'sp00ky-client::AuthService::restoreSessionFromToken' },
170
+ 'Session restored optimistically from cached token'
171
+ );
172
+ return userId;
173
+ }
174
+
91
175
  /**
92
176
  * Check for existing session and validate
93
177
  */
@@ -95,11 +179,11 @@ export class AuthService<S extends SchemaStructure> {
95
179
  this.isLoading = true;
96
180
 
97
181
  try {
98
- const token = accessToken || (await this.persistenceClient.get<string>('spooky_auth_token'));
182
+ const token = accessToken || (await this.persistenceClient.get<string>('sp00ky_auth_token'));
99
183
 
100
184
  if (!token) {
101
185
  this.logger.debug(
102
- { Category: 'spooky-client::AuthService::check' },
186
+ { Category: 'sp00ky-client::AuthService::check' },
103
187
  'No token found in storage or arguments'
104
188
  );
105
189
  this.isLoading = false;
@@ -108,7 +192,10 @@ export class AuthService<S extends SchemaStructure> {
108
192
  return;
109
193
  }
110
194
 
111
- // Authenticate with the token
195
+ // Authenticate with the token, and record it for future connects so a
196
+ // socket rebuilt from scratch reproduces this identity (see
197
+ // `RemoteDatabaseService.setAuthToken`).
198
+ this.remote.setAuthToken(token);
112
199
  await this.remote.getClient().authenticate(token);
113
200
 
114
201
  // Verify the session by fetching the full user record using $auth.id
@@ -119,13 +206,13 @@ export class AuthService<S extends SchemaStructure> {
119
206
 
120
207
  if (user && user.id) {
121
208
  this.logger.info(
122
- { user, Category: 'spooky-client::AuthService::check' },
209
+ { user, Category: 'sp00ky-client::AuthService::check' },
123
210
  'Auth check complete (via $auth.id)'
124
211
  );
125
212
  await this.setSession(token, user);
126
213
  } else {
127
214
  this.logger.warn(
128
- { Category: 'spooky-client::AuthService::check' },
215
+ { Category: 'sp00ky-client::AuthService::check' },
129
216
  '$auth.id empty, attempting manual user fetch'
130
217
  );
131
218
 
@@ -140,24 +227,36 @@ export class AuthService<S extends SchemaStructure> {
140
227
 
141
228
  if (manualUser && manualUser.id) {
142
229
  this.logger.info(
143
- { user: manualUser, Category: 'spooky-client::AuthService::check' },
230
+ { user: manualUser, Category: 'sp00ky-client::AuthService::check' },
144
231
  'Auth check complete (via manual fetch)'
145
232
  );
146
233
  await this.setSession(token, manualUser);
147
234
  } else {
148
235
  this.logger.warn(
149
- { Category: 'spooky-client::AuthService::check' },
236
+ { Category: 'sp00ky-client::AuthService::check' },
150
237
  'Token valid but user not found via fallback'
151
238
  );
152
239
  await this.signOut();
153
240
  }
154
241
  }
155
242
  } catch (error) {
156
- this.logger.error(
157
- { error, stack: (error as Error).stack, Category: 'spooky-client::AuthService::check' },
158
- 'Auth check failed'
159
- );
160
- await this.signOut();
243
+ // A REACHABILITY failure is not a rejected token. This catch used to call
244
+ // signOut() unconditionally, which deletes `sp00ky_auth_token` - so a
245
+ // blip on boot silently logged the user out, and an offline boot could
246
+ // never stay signed in. Only an application error (the server answered,
247
+ // and the answer was "no") ends the session.
248
+ if (classifySyncError(error) === 'network') {
249
+ this.logger.warn(
250
+ { error, Category: 'sp00ky-client::AuthService::check' },
251
+ 'Auth check unreachable; keeping the cached session and retrying later'
252
+ );
253
+ } else {
254
+ this.logger.error(
255
+ { error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
256
+ 'Auth check failed'
257
+ );
258
+ await this.signOut();
259
+ }
161
260
  } finally {
162
261
  this.isLoading = false;
163
262
  }
@@ -168,14 +267,16 @@ export class AuthService<S extends SchemaStructure> {
168
267
  */
169
268
  async signOut() {
170
269
  this.token = null;
270
+ this.remote.setAuthToken(null);
171
271
  this.currentUser = null;
172
272
  this.isAuthenticated = false;
273
+ this.access = null;
173
274
 
174
- await this.persistenceClient.remove('spooky_auth_token');
275
+ await this.persistenceClient.remove('sp00ky_auth_token');
175
276
 
176
277
  try {
177
278
  await this.remote.getClient().invalidate();
178
- } catch (e) {
279
+ } catch (_e) {
179
280
  // Ignore invalidation errors
180
281
  }
181
282
 
@@ -184,12 +285,24 @@ export class AuthService<S extends SchemaStructure> {
184
285
 
185
286
  private async setSession(token: string, user: any) {
186
287
  this.token = token;
288
+ this.remote.setAuthToken(token);
187
289
  this.currentUser = user;
188
290
  this.isAuthenticated = true;
189
- await this.persistenceClient.set('spooky_auth_token', token);
291
+ // Resolve the access-method name (e.g. "account") for in-browser SSP
292
+ // permission injection. Prefer the token's `AC` claim; fall back to the
293
+ // schema's sole record-access method if the claim is absent.
294
+ this.access = decodeAccessFromToken(token) ?? this.defaultAccessName();
295
+ await this.persistenceClient.set('sp00ky_auth_token', token);
190
296
  this.notifyListeners();
191
297
  }
192
298
 
299
+ /** Fallback when the token carries no `AC` claim: if the schema defines
300
+ * exactly one record-access method, assume the session used it. */
301
+ private defaultAccessName(): string | null {
302
+ const names = Object.keys(this.schema.access ?? {});
303
+ return names.length === 1 ? names[0] : null;
304
+ }
305
+
193
306
  async signUp<Name extends keyof S['access'] & string>(
194
307
  accessName: Name,
195
308
  params: ExtractAccessParams<S, Name, 'signup'>
@@ -212,7 +325,7 @@ export class AuthService<S extends SchemaStructure> {
212
325
  }
213
326
 
214
327
  this.logger.info(
215
- { accessName, runtimeParams, Category: 'spooky-client::AuthService::signUp' },
328
+ { accessName, runtimeParams, Category: 'sp00ky-client::AuthService::signUp' },
216
329
  'Attempting signup'
217
330
  );
218
331
 
@@ -222,7 +335,7 @@ export class AuthService<S extends SchemaStructure> {
222
335
  });
223
336
 
224
337
  this.logger.info(
225
- { Category: 'spooky-client::AuthService::signUp' },
338
+ { Category: 'sp00ky-client::AuthService::signUp' },
226
339
  'Signup successful, token received'
227
340
  );
228
341
 
@@ -253,7 +366,7 @@ export class AuthService<S extends SchemaStructure> {
253
366
  }
254
367
 
255
368
  this.logger.info(
256
- { accessName, Category: 'spooky-client::AuthService::signIn' },
369
+ { accessName, Category: 'sp00ky-client::AuthService::signIn' },
257
370
  'Attempting signin'
258
371
  );
259
372