@spooky-sync/core 0.0.1-canary.8 → 0.0.1-canary.81
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 +854 -339
- package/dist/index.js +2633 -396
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/types.d.ts +494 -0
- package/package.json +39 -9
- 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 +9 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +41 -29
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +281 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +352 -0
- package/src/modules/data/data.status.test.ts +108 -0
- package/src/modules/data/index.ts +726 -108
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +130 -0
- package/src/modules/devtools/index.ts +89 -21
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.ts +166 -0
- package/src/modules/ref-tables.test.ts +56 -0
- package/src/modules/ref-tables.ts +57 -0
- package/src/modules/sync/engine.ts +97 -37
- package/src/modules/sync/events/index.ts +3 -2
- package/src/modules/sync/queue/queue-down.ts +5 -4
- package/src/modules/sync/queue/queue-up.ts +14 -13
- package/src/modules/sync/scheduler.ts +2 -2
- package/src/modules/sync/sync.ts +676 -58
- 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/database.ts +11 -11
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/local-migrator.ts +21 -21
- package/src/services/database/local.test.ts +33 -0
- package/src/services/database/local.ts +192 -32
- package/src/services/database/remote.ts +13 -13
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +41 -0
- package/src/services/persistence/surrealdb.ts +9 -9
- package/src/services/stream-processor/index.ts +248 -37
- 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.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +65 -0
- package/src/sp00ky.ts +625 -0
- package/src/types.ts +140 -13
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +55 -1
- package/src/spooky.ts +0 -392
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sp00ky-core
|
|
3
|
+
description: >-
|
|
4
|
+
Core client for the Sp00ky reactive local-first SurrealDB framework. Use when
|
|
5
|
+
initializing Sp00kyClient, registering queries and subscriptions, performing
|
|
6
|
+
mutations (create/update/delete), handling auth (signUp/signIn/signOut),
|
|
7
|
+
working with file buckets, or using backend runs (HTTP outbox pattern).
|
|
8
|
+
metadata:
|
|
9
|
+
author: sp00ky-sync
|
|
10
|
+
version: "0.0.1"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Sp00ky Core
|
|
14
|
+
|
|
15
|
+
`@spooky-sync/core` provides `Sp00kyClient`, the main entry point for the Sp00ky framework. It handles local-first data sync with SurrealDB, reactive queries, optimistic mutations, authentication, file storage, and backend runs.
|
|
16
|
+
|
|
17
|
+
## Initialization
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { Sp00kyClient } from '@spooky-sync/core';
|
|
21
|
+
import { schema } from './generated/schema'; // Generated by `spooky generate`
|
|
22
|
+
import schemaSurql from './generated/schema.surql?raw';
|
|
23
|
+
|
|
24
|
+
const client = new Sp00kyClient({
|
|
25
|
+
database: {
|
|
26
|
+
endpoint: 'ws://localhost:8000',
|
|
27
|
+
namespace: 'my_ns',
|
|
28
|
+
database: 'my_db',
|
|
29
|
+
store: 'indexeddb', // or 'memory'
|
|
30
|
+
},
|
|
31
|
+
schema,
|
|
32
|
+
schemaSurql,
|
|
33
|
+
logLevel: 'info',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// IMPORTANT: You must call init() before using the client
|
|
37
|
+
await client.init();
|
|
38
|
+
|
|
39
|
+
// When done (e.g., app teardown):
|
|
40
|
+
await client.close();
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Sp00kyConfig Options
|
|
44
|
+
|
|
45
|
+
See [references/config.md](references/config.md) for all configuration options.
|
|
46
|
+
|
|
47
|
+
Key fields:
|
|
48
|
+
- `database.endpoint` — SurrealDB WebSocket URL
|
|
49
|
+
- `database.namespace` / `database.database` — SurrealDB namespace and database
|
|
50
|
+
- `database.store` — `'indexeddb'` (persistent) or `'memory'` (transient)
|
|
51
|
+
- `schema` — The `SchemaStructure` object (use `as const satisfies SchemaStructure`)
|
|
52
|
+
- `schemaSurql` — The compiled SURQL schema string for local DB provisioning
|
|
53
|
+
- `logLevel` — `'debug' | 'info' | 'warn' | 'error'`
|
|
54
|
+
- `persistenceClient` — `'surrealdb'` (default), `'localstorage'`, or a custom `PersistenceClient`
|
|
55
|
+
- `streamDebounceTime` — Debounce ms for stream updates (default: 100)
|
|
56
|
+
|
|
57
|
+
## Querying
|
|
58
|
+
|
|
59
|
+
Queries are registered with the client and return a hash. You subscribe to the hash to receive reactive updates.
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// Register a query — returns a QueryBuilder
|
|
63
|
+
const finalQuery = client.query('post', {})
|
|
64
|
+
.where({ author: 'user:alice' })
|
|
65
|
+
.orderBy('createdAt', 'desc')
|
|
66
|
+
.limit(20)
|
|
67
|
+
.build();
|
|
68
|
+
|
|
69
|
+
// Run it to get the hash
|
|
70
|
+
const { hash } = await finalQuery.run();
|
|
71
|
+
|
|
72
|
+
// Subscribe to reactive updates
|
|
73
|
+
const unsubscribe = await client.subscribe(hash, (records) => {
|
|
74
|
+
console.log('Posts:', records);
|
|
75
|
+
}, { immediate: true });
|
|
76
|
+
|
|
77
|
+
// Later: unsubscribe
|
|
78
|
+
unsubscribe();
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Query TTL
|
|
82
|
+
|
|
83
|
+
Queries have a time-to-live. Supported values: `'1m'`, `'5m'`, `'10m'` (default), `'15m'`, `'20m'`, `'25m'`, `'30m'`, `'1h'`–`'12h'`, `'1d'`.
|
|
84
|
+
|
|
85
|
+
## Mutations
|
|
86
|
+
|
|
87
|
+
Mutations are optimistic and automatically synced to the remote SurrealDB instance.
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// Create — pass a full "table:id" string and the record data
|
|
91
|
+
await client.create('post:abc123', {
|
|
92
|
+
title: 'Hello World',
|
|
93
|
+
body: 'My first post',
|
|
94
|
+
author: 'user:alice',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Update — pass table name, record ID, and partial data
|
|
98
|
+
await client.update('post', 'post:abc123', { title: 'Updated Title' });
|
|
99
|
+
|
|
100
|
+
// Update with debounce (useful for text fields)
|
|
101
|
+
await client.update('post', 'post:abc123', { body: 'typing...' }, {
|
|
102
|
+
debounced: { key: 'recordId_x_fields', delay: 300 },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Delete
|
|
106
|
+
await client.delete('post', 'post:abc123');
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Debounced Updates
|
|
110
|
+
|
|
111
|
+
Use `debounced` option for frequent updates (e.g., live typing):
|
|
112
|
+
- `key: 'recordId'` — Debounce by record ID only (latest write wins, no merge)
|
|
113
|
+
- `key: 'recordId_x_fields'` — Debounce by record ID + changed fields (recommended)
|
|
114
|
+
|
|
115
|
+
## Authentication
|
|
116
|
+
|
|
117
|
+
See [references/auth.md](references/auth.md) for the full auth API.
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
// Sign up
|
|
121
|
+
await client.auth.signUp('user_access', {
|
|
122
|
+
email: 'alice@example.com',
|
|
123
|
+
password: 'secret',
|
|
124
|
+
name: 'Alice',
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Sign in
|
|
128
|
+
await client.auth.signIn('user_access', {
|
|
129
|
+
email: 'alice@example.com',
|
|
130
|
+
password: 'secret',
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Subscribe to auth state changes
|
|
134
|
+
const unsub = client.auth.subscribe((userId) => {
|
|
135
|
+
console.log('Auth state:', userId); // string ID or null
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Sign out
|
|
139
|
+
await client.auth.signOut();
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Auth state properties: `client.auth.token`, `client.auth.currentUser`, `client.auth.isAuthenticated`, `client.auth.isLoading`.
|
|
143
|
+
|
|
144
|
+
## Buckets (File Storage)
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
const avatars = client.bucket('avatars');
|
|
148
|
+
|
|
149
|
+
await avatars.put('alice/profile.png', fileBytes);
|
|
150
|
+
const content = await avatars.get('alice/profile.png');
|
|
151
|
+
const exists = await avatars.exists('alice/profile.png');
|
|
152
|
+
await avatars.delete('alice/profile.png');
|
|
153
|
+
await avatars.copy('alice/profile.png', 'alice/backup.png');
|
|
154
|
+
await avatars.rename('old.png', 'new.png');
|
|
155
|
+
const files = await avatars.list('alice/');
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Buckets must be defined in the schema under the `buckets` array.
|
|
159
|
+
|
|
160
|
+
## Backend Runs (HTTP Outbox)
|
|
161
|
+
|
|
162
|
+
Backend runs let you trigger server-side HTTP operations via an **outbox pattern**. When you call `db.run()`, it creates a job record in a local outbox table. That record syncs to remote SurrealDB, where the Sp00ky Sync Platform (SSP) picks it up and calls your backend HTTP API. The result status is written back to the job record and syncs to the client reactively.
|
|
163
|
+
|
|
164
|
+
### `db.run()` Signature
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
db.run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
168
|
+
backend: B, // Backend name from sp00ky.yml (e.g., 'api')
|
|
169
|
+
route: R, // Route path from OpenAPI spec (e.g., '/spookify')
|
|
170
|
+
payload: RoutePayload<S, B, R>, // Typed args for the route
|
|
171
|
+
options?: RunOptions
|
|
172
|
+
): Promise<void>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
All parameters are type-safe — `backend`, `route`, and `payload` are inferred from the generated schema.
|
|
176
|
+
|
|
177
|
+
### RunOptions
|
|
178
|
+
|
|
179
|
+
| Option | Type | Default | Description |
|
|
180
|
+
|--------|------|---------|-------------|
|
|
181
|
+
| `assignedTo` | `string` | `undefined` | Record ID to link the job to an entity (e.g., `'thread:abc'`) |
|
|
182
|
+
| `max_retries` | `number` | `3` | Maximum retry attempts on failure |
|
|
183
|
+
| `retry_strategy` | `'linear' \| 'exponential'` | `'linear'` | Backoff strategy between retries |
|
|
184
|
+
|
|
185
|
+
### Entity Linking via `assignedTo`
|
|
186
|
+
|
|
187
|
+
The `assignedTo` option is how you link a job to an entity. Passing a record ID:
|
|
188
|
+
|
|
189
|
+
- Sets the `assigned_to` field on the job record (typed as `record<thread>` etc. in the outbox schema)
|
|
190
|
+
- Enables **permission scoping** — e.g., `WHERE assigned_to.author.id = $auth.id` restricts job visibility to the entity's owner
|
|
191
|
+
- Allows **querying jobs via relationships** — use `.related('jobs')` on the parent entity to reactively track job status
|
|
192
|
+
|
|
193
|
+
### Full Example
|
|
194
|
+
|
|
195
|
+
**1. Configure the backend in `sp00ky.yml`:**
|
|
196
|
+
|
|
197
|
+
```yaml
|
|
198
|
+
backends:
|
|
199
|
+
api:
|
|
200
|
+
type: http
|
|
201
|
+
spec: ../api/openapi.yml
|
|
202
|
+
baseUrl: http://host.docker.internal:3660
|
|
203
|
+
auth:
|
|
204
|
+
type: token
|
|
205
|
+
token: MY_SECRET
|
|
206
|
+
method:
|
|
207
|
+
type: outbox
|
|
208
|
+
table: job
|
|
209
|
+
schema: ./src/outbox/api.surql
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**2. Define the outbox table in SurrealQL** (`src/outbox/api.surql`):
|
|
213
|
+
|
|
214
|
+
```sql
|
|
215
|
+
DEFINE TABLE job SCHEMAFULL
|
|
216
|
+
PERMISSIONS
|
|
217
|
+
FOR select, create, update, delete
|
|
218
|
+
WHERE $access = "account" AND assigned_to.author.id = $auth.id;
|
|
219
|
+
|
|
220
|
+
DEFINE FIELD assigned_to ON TABLE job TYPE record<thread>;
|
|
221
|
+
DEFINE FIELD path ON TABLE job TYPE string;
|
|
222
|
+
DEFINE FIELD payload ON TABLE job TYPE any;
|
|
223
|
+
DEFINE FIELD retries ON TABLE job TYPE int DEFAULT ALWAYS 0;
|
|
224
|
+
DEFINE FIELD max_retries ON TABLE job TYPE int DEFAULT ALWAYS 3;
|
|
225
|
+
DEFINE FIELD retry_strategy ON TABLE job TYPE string DEFAULT ALWAYS "linear"
|
|
226
|
+
ASSERT $value IN ["linear", "exponential"];
|
|
227
|
+
DEFINE FIELD status ON TABLE job TYPE string DEFAULT ALWAYS "pending"
|
|
228
|
+
ASSERT $value IN ["pending", "processing", "success", "failed"];
|
|
229
|
+
DEFINE FIELD errors ON TABLE job TYPE array<object> DEFAULT ALWAYS [];
|
|
230
|
+
DEFINE FIELD created_at ON TABLE job TYPE datetime VALUE time::now();
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**3. Run `spooky generate`** — this produces the typed `backends` block in `schema.gen.ts`.
|
|
234
|
+
|
|
235
|
+
**4. Call `db.run()` with entity linking:**
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
// Trigger the /spookify backend route, linked to a thread
|
|
239
|
+
await client.run('api', '/spookify', { id: threadId }, {
|
|
240
|
+
assignedTo: threadId, // Links job to the thread entity
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### How It Works Under the Hood
|
|
245
|
+
|
|
246
|
+
When you call `db.run('api', '/spookify', payload, options)`:
|
|
247
|
+
|
|
248
|
+
1. Validates the route exists in the schema and all required args are present
|
|
249
|
+
2. Creates a record in the outbox table (`job`) with fields: `path`, `payload` (JSON-stringified), `max_retries`, `retry_strategy`, and optionally `assigned_to`
|
|
250
|
+
3. The record is created via the standard `client.create()` path — optimistic, synced to remote
|
|
251
|
+
4. SSP detects the new job, calls your backend HTTP API, and updates the job's `status` field (`pending` → `processing` → `success`/`failed`)
|
|
252
|
+
|
|
253
|
+
## Common Pitfalls
|
|
254
|
+
|
|
255
|
+
1. **Must call `init()` before using the client** — All operations will fail if you skip initialization.
|
|
256
|
+
2. **Schema is generated** — Use `spooky generate` to produce the schema TypeScript file and SURQL file from your `.surql` definitions.
|
|
257
|
+
3. **RecordId format** — SurrealDB uses `table:id` format. When creating records, pass the full ID (e.g., `'post:abc123'`). The framework auto-converts string IDs to `RecordId` objects internally.
|
|
258
|
+
4. **Queries are hash-based** — The same query always produces the same hash, enabling deduplication and caching.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Authentication Reference
|
|
2
|
+
|
|
3
|
+
## AuthService API
|
|
4
|
+
|
|
5
|
+
The `AuthService` is available on `Sp00kyClient` as `client.auth`.
|
|
6
|
+
|
|
7
|
+
### Properties
|
|
8
|
+
|
|
9
|
+
| Property | Type | Description |
|
|
10
|
+
|----------|------|-------------|
|
|
11
|
+
| `token` | `string \| null` | Current auth token |
|
|
12
|
+
| `currentUser` | `any \| null` | Current authenticated user record |
|
|
13
|
+
| `isAuthenticated` | `boolean` | Whether a user is authenticated |
|
|
14
|
+
| `isLoading` | `boolean` | Whether auth state is being validated |
|
|
15
|
+
|
|
16
|
+
### Methods
|
|
17
|
+
|
|
18
|
+
#### `signUp(accessName, params)`
|
|
19
|
+
|
|
20
|
+
Create a new account using the named access definition from the schema.
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
await client.auth.signUp('user_access', {
|
|
24
|
+
email: 'alice@example.com',
|
|
25
|
+
password: 'secret123',
|
|
26
|
+
name: 'Alice',
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The access definition in the schema determines what parameters are required:
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
access: {
|
|
34
|
+
user_access: {
|
|
35
|
+
signup: {
|
|
36
|
+
params: {
|
|
37
|
+
email: { type: 'string', optional: false },
|
|
38
|
+
password: { type: 'string', optional: false },
|
|
39
|
+
name: { type: 'string', optional: true },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
signIn: {
|
|
43
|
+
params: {
|
|
44
|
+
email: { type: 'string', optional: false },
|
|
45
|
+
password: { type: 'string', optional: false },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Parameters are fully type-safe — TypeScript will infer the correct types from the schema.
|
|
53
|
+
|
|
54
|
+
#### `signIn(accessName, params)`
|
|
55
|
+
|
|
56
|
+
Authenticate with existing credentials.
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
await client.auth.signIn('user_access', {
|
|
60
|
+
email: 'alice@example.com',
|
|
61
|
+
password: 'secret123',
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
#### `signOut()`
|
|
66
|
+
|
|
67
|
+
Sign out, clear the session token, and notify subscribers.
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
await client.auth.signOut();
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
#### `subscribe(callback)`
|
|
74
|
+
|
|
75
|
+
Subscribe to auth state changes. The callback fires immediately with the current state, then on every change.
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
const unsub = client.auth.subscribe((userId: string | null) => {
|
|
79
|
+
if (userId) {
|
|
80
|
+
console.log('Logged in as', userId);
|
|
81
|
+
} else {
|
|
82
|
+
console.log('Not authenticated');
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Cleanup
|
|
87
|
+
unsub();
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
#### `check(accessToken?)`
|
|
91
|
+
|
|
92
|
+
Manually re-validate the current session. Called automatically during `init()`.
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
await client.auth.check();
|
|
96
|
+
// Or with a specific token:
|
|
97
|
+
await client.auth.check('eyJhbGciOiJIUzI1NiJ9...');
|
|
98
|
+
```
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Sp00kyConfig Reference
|
|
2
|
+
|
|
3
|
+
```typescript
|
|
4
|
+
interface Sp00kyConfig<S extends SchemaStructure> {
|
|
5
|
+
database: {
|
|
6
|
+
/** SurrealDB WebSocket endpoint URL */
|
|
7
|
+
endpoint?: string;
|
|
8
|
+
/** SurrealDB namespace */
|
|
9
|
+
namespace: string;
|
|
10
|
+
/** SurrealDB database name */
|
|
11
|
+
database: string;
|
|
12
|
+
/** Local store type: 'memory' (transient) or 'indexeddb' (persistent) */
|
|
13
|
+
store?: 'memory' | 'indexeddb';
|
|
14
|
+
/** Authentication token */
|
|
15
|
+
token?: string;
|
|
16
|
+
};
|
|
17
|
+
/** Unique client identifier. Auto-generated if not provided. */
|
|
18
|
+
clientId?: string;
|
|
19
|
+
/** The schema object (must use `as const satisfies SchemaStructure`) */
|
|
20
|
+
schema: S;
|
|
21
|
+
/** Compiled SURQL schema string for local DB provisioning */
|
|
22
|
+
schemaSurql: string;
|
|
23
|
+
/** Logging level */
|
|
24
|
+
logLevel: 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent' | 'trace';
|
|
25
|
+
/**
|
|
26
|
+
* Persistence client for local storage.
|
|
27
|
+
* - 'surrealdb': Uses the local SurrealDB instance (default)
|
|
28
|
+
* - 'localstorage': Uses browser localStorage
|
|
29
|
+
* - Custom: Provide an object implementing PersistenceClient
|
|
30
|
+
*/
|
|
31
|
+
persistenceClient?: PersistenceClient | 'surrealdb' | 'localstorage';
|
|
32
|
+
/** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel) */
|
|
33
|
+
otelTransmit?: PinoTransmit;
|
|
34
|
+
/** Debounce time in ms for stream updates (default: 100) */
|
|
35
|
+
streamDebounceTime?: number;
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## PersistenceClient Interface
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
interface PersistenceClient {
|
|
43
|
+
set<T>(key: string, value: T): Promise<void>;
|
|
44
|
+
get<T>(key: string): Promise<T | null>;
|
|
45
|
+
remove(key: string): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## QueryTimeToLive Values
|
|
50
|
+
|
|
51
|
+
Supported TTL values for `client.query()`:
|
|
52
|
+
|
|
53
|
+
`'1m'`, `'5m'`, `'10m'`, `'15m'`, `'20m'`, `'25m'`, `'30m'`, `'1h'`, `'2h'`, `'3h'`, `'4h'`, `'5h'`, `'6h'`, `'7h'`, `'8h'`, `'9h'`, `'10h'`, `'11h'`, `'12h'`, `'1d'`
|
|
54
|
+
|
|
55
|
+
## RunOptions
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
interface RunOptions {
|
|
59
|
+
assignedTo?: string;
|
|
60
|
+
max_retries?: number;
|
|
61
|
+
retry_strategy?: 'linear' | 'exponential';
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## UpdateOptions
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
interface UpdateOptions {
|
|
69
|
+
debounced?: boolean | {
|
|
70
|
+
/** 'recordId': latest write wins. 'recordId_x_fields': merge by field (recommended). */
|
|
71
|
+
key?: 'recordId' | 'recordId_x_fields';
|
|
72
|
+
/** Debounce delay in ms */
|
|
73
|
+
delay?: number;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Build-time string constants injected by tsdown's `define` (see
|
|
2
|
+
// tsdown.config.ts). They carry the real bundled frontend package versions so
|
|
3
|
+
// the DevTools can surface frontend/backend version drift.
|
|
4
|
+
//
|
|
5
|
+
// Typed as `string | undefined` on purpose: the substitution only happens when
|
|
6
|
+
// `@spooky-sync/core` is built with our tsdown plugin. A downstream app that
|
|
7
|
+
// bundles core from source never runs that plugin, so these identifiers must be
|
|
8
|
+
// guarded with `typeof` (see modules/devtools/index.ts) to avoid a runtime
|
|
9
|
+
// ReferenceError that would break DB initialization.
|
|
10
|
+
declare const __SP00KY_CORE_VERSION__: string | undefined;
|
|
11
|
+
declare const __SP00KY_WASM_VERSION__: string | undefined;
|
|
12
|
+
declare const __SP00KY_SURREAL_VERSION__: string | undefined;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import type { EventDefinition } from './index';
|
|
3
|
+
import { EventSystem } from './index';
|
|
3
4
|
|
|
4
5
|
// Define test event types
|
|
5
6
|
type TestEvents = {
|
package/src/events/index.ts
CHANGED
|
@@ -172,6 +172,9 @@ export class EventSystem<E extends EventTypeMap> {
|
|
|
172
172
|
* @param payload The data associated with the event.
|
|
173
173
|
*/
|
|
174
174
|
emit<T extends EventType<E>, P extends EventPayload<E, T>>(type: T, payload: P): void {
|
|
175
|
+
// `{ type, payload }` structurally matches `Event<E, T>` (= the indexed
|
|
176
|
+
// access `E[T]`), but TS can't verify assignability to an indexed-access
|
|
177
|
+
// type over the generic `E`/`T`, so the constructed event is asserted.
|
|
175
178
|
const event = {
|
|
176
179
|
type,
|
|
177
180
|
payload,
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
export * from './types';
|
|
2
|
-
export * from './
|
|
2
|
+
export * from './sp00ky';
|
|
3
3
|
export * from './modules/auth/index';
|
|
4
|
-
export {
|
|
4
|
+
export { CrdtField, CrdtManager, cursorColorFromName, CURSOR_COLORS } from './modules/crdt/index';
|
|
5
|
+
export {
|
|
6
|
+
FeatureFlagModule,
|
|
7
|
+
FeatureFlagHandle,
|
|
8
|
+
type FeatureFlagOptions,
|
|
9
|
+
type FeatureFlagSnapshot,
|
|
10
|
+
} from './modules/feature-flag/index';
|
|
11
|
+
export { fileToUint8Array, textToHtml } from './utils/index';
|
|
@@ -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
|
|