@spooky-sync/core 0.0.1-canary.25 → 0.0.1-canary.255
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 +78 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2187 -80
- package/dist/index.js +12653 -2581
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +331 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +787 -46
- package/package.json +16 -8
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- 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 +151 -0
- package/src/boot/boot.saga.ts +128 -0
- package/src/boot/bucket-switch.saga.test.ts +158 -0
- package/src/boot/bucket-switch.saga.ts +115 -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 +56 -0
- package/src/client/router.ts +94 -0
- package/src/client/runtime.test.ts +192 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +382 -0
- package/src/client/sp00ky-client.test.ts +264 -0
- package/src/client/sp00ky-client.ts +499 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +37 -2
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +54 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +159 -0
- package/src/kernel/events.ts +55 -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/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 +2 -1
- package/src/modules/auth/index.ts +138 -25
- 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 +459 -47
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +149 -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/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/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +215 -0
- package/src/mutation/rows.test.ts +201 -0
- package/src/mutation/rows.ts +377 -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 +163 -0
- package/src/mutation/write.saga.ts +166 -0
- package/src/otel/index.ts +13 -10
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +156 -0
- package/src/query/fetch.saga.ts +115 -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 +122 -0
- package/src/query/lifecycle.saga.ts +101 -0
- package/src/query/materialize.saga.test.ts +82 -0
- package/src/query/materialize.saga.ts +75 -0
- package/src/query/materialize.test.ts +50 -0
- package/src/query/materialize.ts +44 -0
- package/src/query/membership.saga.test.ts +246 -0
- package/src/query/membership.saga.ts +208 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +158 -0
- package/src/query/register.saga.test.ts +285 -0
- package/src/query/register.saga.ts +216 -0
- package/src/query/render-set.test.ts +36 -0
- package/src/query/render-set.ts +60 -0
- package/src/query/sql.test.ts +62 -0
- package/src/query/sql.ts +79 -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 +193 -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 +145 -62
- 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/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +484 -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 +467 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +222 -15
- 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 +1349 -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 +104 -0
- package/src/services/database/sqlite-plan-sql.ts +153 -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.ts +3 -2
- 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 +796 -84
- 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 +59 -3
- 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 +365 -0
- package/src/services/tabs/coordinator.ts +633 -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 -0
- package/src/state/client-state.ts +184 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +280 -0
- package/src/state/reducers.ts +476 -0
- package/src/state/selectors.test.ts +149 -0
- package/src/state/selectors.ts +184 -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 +74 -0
- package/src/sync/tabs.saga.ts +93 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +61 -0
- package/src/testing/fake-services.ts +34 -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 +591 -14
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -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/utils.test.ts +2 -1
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- 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 -864
- package/src/modules/sync/engine.ts +0 -166
- 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
- package/src/spooky.ts +0 -395
|
@@ -1,17 +1,15 @@
|
|
|
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';
|
|
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>('
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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('
|
|
275
|
+
await this.persistenceClient.remove('sp00ky_auth_token');
|
|
175
276
|
|
|
176
277
|
try {
|
|
177
278
|
await this.remote.getClient().invalidate();
|
|
178
|
-
} catch (
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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: '
|
|
369
|
+
{ accessName, Category: 'sp00ky-client::AuthService::signIn' },
|
|
257
370
|
'Attempting signin'
|
|
258
371
|
);
|
|
259
372
|
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import type { LoroDoc } from 'loro-crdt';
|
|
2
|
+
import type { LocalStore, RemoteDatabaseService } from '../../services/database/index';
|
|
3
|
+
import type { Logger } from '../../services/logger/index';
|
|
4
|
+
import { parseRecordIdString } from '../../utils/index';
|
|
5
|
+
|
|
6
|
+
// ==================== CURSOR UTILITIES ====================
|
|
7
|
+
|
|
8
|
+
export const CURSOR_COLORS = [
|
|
9
|
+
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b',
|
|
10
|
+
'#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function cursorColorFromName(name: string): string {
|
|
14
|
+
let hash = 0;
|
|
15
|
+
for (let i = 0; i < name.length; i++) {
|
|
16
|
+
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
|
|
17
|
+
}
|
|
18
|
+
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ==================== CRDT FIELD ====================
|
|
22
|
+
|
|
23
|
+
export class CrdtField {
|
|
24
|
+
private doc: LoroDoc;
|
|
25
|
+
private pushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
26
|
+
private local: LocalStore | null = null;
|
|
27
|
+
private remote: RemoteDatabaseService | null = null;
|
|
28
|
+
private recordId: string | null = null;
|
|
29
|
+
private sessionId: string = '';
|
|
30
|
+
private unsubscribe: (() => void) | null = null;
|
|
31
|
+
private lastPushTime = 0;
|
|
32
|
+
private lastCursorPushTime = 0;
|
|
33
|
+
private loadedFromCrdt = false;
|
|
34
|
+
private pushRetryCount = 0;
|
|
35
|
+
private logger: Logger | null;
|
|
36
|
+
private cursorsEnabled: boolean;
|
|
37
|
+
/** Remote-push debounce. Local writes happen immediately on every Loro
|
|
38
|
+
* update; the remote UPSERT is coalesced over this window. Configured
|
|
39
|
+
* via `Sp00kyConfig.crdtDebounceMs`, default 500. */
|
|
40
|
+
private remoteDebounceMs: number = 500;
|
|
41
|
+
|
|
42
|
+
private _onCursorUpdate: ((data: Uint8Array) => void) | null = null;
|
|
43
|
+
private pendingCursorUpdate: Uint8Array | null = null;
|
|
44
|
+
|
|
45
|
+
/** Callback set by the editor to receive remote cursor updates.
|
|
46
|
+
* Any cursor data that arrived before this callback was set will be replayed. */
|
|
47
|
+
set onCursorUpdate(cb: ((data: Uint8Array) => void) | null) {
|
|
48
|
+
this._onCursorUpdate = cb;
|
|
49
|
+
if (cb && this.pendingCursorUpdate) {
|
|
50
|
+
try { cb(this.pendingCursorUpdate); } catch (e) {
|
|
51
|
+
this.logger?.warn(
|
|
52
|
+
{ error: e, Category: 'sp00ky-client::CrdtField::onCursorUpdate' },
|
|
53
|
+
'Failed to replay pending cursor update'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
this.pendingCursorUpdate = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get onCursorUpdate() { return this._onCursorUpdate; }
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param LoroDocClass the `LoroDoc` constructor, injected by the caller after
|
|
64
|
+
* awaiting {@link loadLoro} — keeps `loro-crdt` out of this module's static
|
|
65
|
+
* import graph so it only ships to apps that use CRDT fields.
|
|
66
|
+
*/
|
|
67
|
+
constructor(
|
|
68
|
+
private fieldName: string,
|
|
69
|
+
cursorsEnabled: boolean,
|
|
70
|
+
LoroDocClass: typeof LoroDoc,
|
|
71
|
+
initialState?: Uint8Array,
|
|
72
|
+
logger?: Logger | null,
|
|
73
|
+
) {
|
|
74
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(fieldName)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`CrdtField: refusing unsafe field identifier '${fieldName}' — must match [a-zA-Z_][a-zA-Z0-9_]*`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
this.logger = logger ?? null;
|
|
80
|
+
this.cursorsEnabled = cursorsEnabled;
|
|
81
|
+
this.doc = new LoroDocClass();
|
|
82
|
+
if (initialState && initialState.length > 0) {
|
|
83
|
+
// Tolerance: catch bad-snapshot data (corrupt blob, stale legacy
|
|
84
|
+
// value left over from a pre-`bytes` migration) so the editor still
|
|
85
|
+
// mounts. Without this guard the rejection bubbles through
|
|
86
|
+
// `useCrdtField` → permanent fallback `<p>`, with no cursor.
|
|
87
|
+
try {
|
|
88
|
+
this.doc.import(initialState);
|
|
89
|
+
this.loadedFromCrdt = true;
|
|
90
|
+
} catch (e) {
|
|
91
|
+
this.logger?.warn(
|
|
92
|
+
{
|
|
93
|
+
error: e,
|
|
94
|
+
fieldName,
|
|
95
|
+
Category: 'sp00ky-client::CrdtField::constructor',
|
|
96
|
+
},
|
|
97
|
+
'Initial CRDT state is not a valid LoroDoc snapshot — starting empty and will seed from fallback text'
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
getDoc(): LoroDoc { return this.doc; }
|
|
104
|
+
|
|
105
|
+
/** Whether the LoroDoc was loaded from saved CRDT state */
|
|
106
|
+
hasContent(): boolean {
|
|
107
|
+
return this.loadedFromCrdt;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
startSync(
|
|
111
|
+
local: LocalStore,
|
|
112
|
+
remote: RemoteDatabaseService,
|
|
113
|
+
recordId: string,
|
|
114
|
+
sessionId: string,
|
|
115
|
+
debounceMs: number,
|
|
116
|
+
): void {
|
|
117
|
+
this.local = local;
|
|
118
|
+
this.remote = remote;
|
|
119
|
+
this.recordId = recordId;
|
|
120
|
+
this.sessionId = sessionId;
|
|
121
|
+
this.remoteDebounceMs = debounceMs;
|
|
122
|
+
// Every local Loro update writes the snapshot to the local cache
|
|
123
|
+
// *immediately* (so reload/offline see the latest text), then
|
|
124
|
+
// schedules a debounced push to remote. The local UPSERT is cheap —
|
|
125
|
+
// it's an in-memory SurrealKV write — but errors are swallowed so a
|
|
126
|
+
// bad write never blocks user input.
|
|
127
|
+
this.unsubscribe = this.doc.subscribeLocalUpdates(() => {
|
|
128
|
+
void this.persistLocal();
|
|
129
|
+
this.scheduleRemotePush();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Stop syncing this field. Flushes one final remote push by default so the
|
|
135
|
+
* last keystrokes aren't lost. Pass `{ flush: false }` on a bucket switch —
|
|
136
|
+
* the remote session already belongs to the NEXT user, and pushing this
|
|
137
|
+
* (previous user's) snapshot under it would clobber the record remotely.
|
|
138
|
+
*/
|
|
139
|
+
stopSync(options: { flush?: boolean } = {}): void {
|
|
140
|
+
const flush = options.flush ?? true;
|
|
141
|
+
if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; }
|
|
142
|
+
if (this.pushTimer) { clearTimeout(this.pushTimer); this.pushTimer = null; }
|
|
143
|
+
if (flush && this.remote && this.recordId) { void this.pushToRemote(); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
importRemote(state: Uint8Array): void {
|
|
147
|
+
// Echo suppression: skip imports within `remoteDebounceMs + 200` of
|
|
148
|
+
// our own push. The +200 guards against round-trip jitter where our
|
|
149
|
+
// own write echoes back from the LIVE feed before the debounce
|
|
150
|
+
// window closes.
|
|
151
|
+
if (Date.now() - this.lastPushTime < this.remoteDebounceMs + 200) return;
|
|
152
|
+
try {
|
|
153
|
+
this.doc.import(state);
|
|
154
|
+
// Persist the merged snapshot locally so the next reload/offline
|
|
155
|
+
// open sees the freshest converged state without waiting for the
|
|
156
|
+
// LIVE feed.
|
|
157
|
+
void this.persistLocal();
|
|
158
|
+
} catch (e) {
|
|
159
|
+
this.logger?.warn(
|
|
160
|
+
{ error: e, Category: 'sp00ky-client::CrdtField::importRemote' },
|
|
161
|
+
'Failed to import remote CRDT state'
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
exportSnapshot(): Uint8Array {
|
|
167
|
+
return this.doc.export({ mode: 'snapshot' });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Push this session's cursor blob into the parent row at
|
|
171
|
+
* `<field>.cursors[$sid]`. No-op when cursors aren't enabled on this
|
|
172
|
+
* field — the editor still calls this method optimistically, but
|
|
173
|
+
* without `@cursor` on the schema there's nowhere to store the blob.
|
|
174
|
+
* The UPDATE itself fires the parent table's LIVE feed, so other
|
|
175
|
+
* browsers receive the cursor change without a separate `_00_rv` bump. */
|
|
176
|
+
async pushCursorState(encoded: Uint8Array): Promise<void> {
|
|
177
|
+
if (!this.remote || !this.recordId) return;
|
|
178
|
+
if (!this.cursorsEnabled) return;
|
|
179
|
+
this.lastCursorPushTime = Date.now();
|
|
180
|
+
try {
|
|
181
|
+
const state = encodeBase64(encoded);
|
|
182
|
+
await this.remote.query(
|
|
183
|
+
`UPDATE $id SET ${this.fieldName}.cursors[$sid] = $state RETURN NONE;`,
|
|
184
|
+
{
|
|
185
|
+
id: parseRecordIdString(this.recordId),
|
|
186
|
+
sid: this.sessionId,
|
|
187
|
+
state,
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
this.logger?.warn(
|
|
192
|
+
{ error: e, Category: 'sp00ky-client::CrdtField::pushCursorState' },
|
|
193
|
+
'Failed to push cursor state'
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Import remote cursor state (called by CrdtManager from LIVE SELECT) */
|
|
199
|
+
importRemoteCursor(base64State: string): void {
|
|
200
|
+
if (Date.now() - this.lastCursorPushTime < 300) return; // echo suppression
|
|
201
|
+
try {
|
|
202
|
+
const data = decodeBase64(base64State);
|
|
203
|
+
if (this._onCursorUpdate) {
|
|
204
|
+
this._onCursorUpdate(data);
|
|
205
|
+
} else {
|
|
206
|
+
// Only keep the latest cursor state — older positions are useless
|
|
207
|
+
this.pendingCursorUpdate = data;
|
|
208
|
+
}
|
|
209
|
+
} catch (e) {
|
|
210
|
+
this.logger?.warn(
|
|
211
|
+
{ error: e, Category: 'sp00ky-client::CrdtField::importRemoteCursor' },
|
|
212
|
+
'Failed to apply remote cursor data'
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private scheduleRemotePush(): void {
|
|
218
|
+
if (this.pushTimer) clearTimeout(this.pushTimer);
|
|
219
|
+
this.pushTimer = setTimeout(() => void this.pushToRemote(), this.remoteDebounceMs);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** SET path inside a parent row for the current snapshot. `@crdt`-only
|
|
223
|
+
* fields hold the snapshot directly (`<field>`); `@crdt @cursor`
|
|
224
|
+
* fields hold a `{ state, cursors }` object so the snapshot lives at
|
|
225
|
+
* `<field>.state` next to per-session cursor blobs. */
|
|
226
|
+
private statePath(): string {
|
|
227
|
+
return this.cursorsEnabled ? `${this.fieldName}.state` : this.fieldName;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Mirror the LoroDoc snapshot into the parent row locally. Runs on
|
|
231
|
+
* every local update and every remote import so reloads (online or
|
|
232
|
+
* offline) see the freshest content immediately. Failures are
|
|
233
|
+
* swallowed — a stale local write must never block user input. */
|
|
234
|
+
private async persistLocal(): Promise<void> {
|
|
235
|
+
if (!this.local || !this.recordId) return;
|
|
236
|
+
try {
|
|
237
|
+
await this.local.query(
|
|
238
|
+
`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`,
|
|
239
|
+
{ id: parseRecordIdString(this.recordId), state: this.exportSnapshot() }
|
|
240
|
+
);
|
|
241
|
+
} catch (e) {
|
|
242
|
+
this.logger?.debug(
|
|
243
|
+
{ error: e, Category: 'sp00ky-client::CrdtField::persistLocal' },
|
|
244
|
+
'Local CRDT persist failed (best-effort)'
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async pushToRemote(): Promise<void> {
|
|
250
|
+
if (!this.remote || !this.recordId) return;
|
|
251
|
+
this.lastPushTime = Date.now();
|
|
252
|
+
try {
|
|
253
|
+
// The UPDATE on the parent fires the parent table's LIVE feed,
|
|
254
|
+
// so cross-browser receivers see this change directly in the LIVE
|
|
255
|
+
// payload — no separate `_00_rv` bump or sidecar UPSERT.
|
|
256
|
+
await this.remote.query(
|
|
257
|
+
`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`,
|
|
258
|
+
{ id: parseRecordIdString(this.recordId), state: this.exportSnapshot() }
|
|
259
|
+
);
|
|
260
|
+
this.pushRetryCount = 0;
|
|
261
|
+
} catch (e) {
|
|
262
|
+
this.logger?.warn(
|
|
263
|
+
{ error: e, Category: 'sp00ky-client::CrdtField::pushToRemote' },
|
|
264
|
+
'Failed to push CRDT state to remote'
|
|
265
|
+
);
|
|
266
|
+
// Bounded retry. Offline first-loads will exhaust this and stop
|
|
267
|
+
// hammering; the next user keystroke (or the next time a remote
|
|
268
|
+
// event lands once we're back online) will kick off another push.
|
|
269
|
+
if (this.pushRetryCount < 2) {
|
|
270
|
+
this.pushRetryCount++;
|
|
271
|
+
this.scheduleRemotePush();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function decodeBase64(b64: string): Uint8Array {
|
|
278
|
+
if (typeof atob === 'function') {
|
|
279
|
+
const binary = atob(b64);
|
|
280
|
+
const bytes = new Uint8Array(binary.length);
|
|
281
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
282
|
+
return bytes;
|
|
283
|
+
}
|
|
284
|
+
return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function encodeBase64(bytes: Uint8Array): string {
|
|
288
|
+
if (typeof btoa === 'function') {
|
|
289
|
+
let binary = '';
|
|
290
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
291
|
+
return btoa(binary);
|
|
292
|
+
}
|
|
293
|
+
return Buffer.from(bytes).toString('base64');
|
|
294
|
+
}
|