@spooky-sync/core 0.0.1-canary.361 → 0.0.1-canary.363

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 CHANGED
@@ -45,7 +45,7 @@ re-materialized from state, never patched in place.
45
45
  - `services/blobs/` — durable cache for bucket bytes: OPFS holds the files, `_00_blob` holds a manifest that reconcile rebuilds from disk (so a wiped local store costs metadata, not the offline cache). Nothing expires on a timer; eviction is LRU under a byte budget only, skipping pinned and on-screen entries. Config: `blobCache` in `types.ts`.
46
46
  - `AuthService` — token management, sign-in/sign-out events. `subscribe(cb, { immediate? })` calls `cb` at once with the current user unless `immediate: false`, then on every change.
47
47
  - `CrdtManager`, `CrdtField`, `cursorColorFromName`, `CURSOR_COLORS`: collaborative `@crdt` fields on Loro. Client methods: `openCrdtField(table, recordId, field, draft?)` -> `Promise<CrdtField>` (every open of one field in a tab shares one `LoroDoc`; pair each with a close), `closeCrdtField(table, recordId, field)` -> `Promise<void>` (resolves once unsent edits are in the outbox; offline, once they are saved on the device, never waiting on the network), `createCrdtDraft(table, field)` -> a detached `CrdtField` for a record not created yet (`db.create` first, then pass it to `openCrdtField` as `draft`). `CrdtField`: `getDoc()`, `hasContent()`, `pushCursorState(bytes)`, `onCursorUpdate = cb` (a setter). Config: `crdt: true` preloads Loro, `crdtDebounceMs` (the first edit after a quiet spell waits this long, default 50), `crdtSendIntervalMs` (shortest gap between two sends of one field while typing, default 200). Design: `docs/crdt-v2.md` at the repo root.
48
- - **Peer to peer channels** (`-- @channel` tables, experimental): `client.channel(query, peers)` (a query builder, a built query, or a list, on `@channel` tables; typed from the query; own id skipped; both sides must list each other) opens WebRTC data channels to the peers' tabs and returns `Sp00kyChannel` (`state()`, `subscribe(cb)`, `setPeers`, `setQueries`, `close`, `tables()`, plus scoped `update` / `create` / `delete`). Only the query's `where` counts (`matchesWhere`, pure JS over the plan; `compileScope` throws for `.related()`, link-following paths, `$params`, unknown ops); it is never registered. `orderBy` / `limit` / `offset` / `select` / `.one()` warn once per shape (`unusedArgs`) unless the builder has `.ignoreUnusedArgs()`. Writes go to the peers of every open channel whose filter the row matches: a create checks the new row, an update or delete the stored row (`readRow` saga) before and after the patch. A peer's write is taken only if the sender is listed by a channel whose filter the row matches (an update/delete of a row not held is ignored). Peers fold updates into every subscription showing the row (output only, never the store): normal fields retire once the synced row holds them (or after `channels.confirmTimeoutMs`, default 5000); `-- @ephemeral` fields (optional, never filtered on) never reach the database, last writer wins, vanish when their author disconnects. A peer's create goes into the store and circuit like an own optimistic create minus the outbox (`peerCreate` saga, `state.peerWrites` folded into `overlay()`) and is undone at `confirmTimeoutMs` unless the server body landed (`versions`); a peer's delete hides the row through the overlay until it expires (`peerDelete`, `PeerWriteExpired`). Signaling is raw CREATE/LIVE on the generated server-only `_00_channel_signal` (sender stamped by the DB). Everything beyond identity, filter and field types is `channels.accept: { [table]: ({ kind, from, table, id, row, patch }) => boolean }`. Config also `iceServers`, `transport`. TS only (no Dart). Code: `modules/channel/`. Docs: `/docs/client/channels`.
48
+ - **Peer to peer channels** (`-- @channel` tables, experimental): `client.channel(query, peers)` (a query builder, a built query, or a list, on `@channel` tables; typed from the query; own id skipped; both sides must list each other) opens WebRTC data channels to the peers' tabs and returns `Sp00kyChannel` (`state()`, `subscribe(cb)`, `setPeers`, `setQueries`, `close`, `tables()`, plus scoped `update` / `create` / `delete`). Only the query's `where` counts (`matchesWhere`, pure JS over the plan; `compileScope` throws for `.related()`, link-following paths, `$params`, unknown ops); it is never registered. `orderBy` / `limit` / `offset` / `select` / `.one()` warn once per shape (`unusedArgs`) unless the builder has `.ignoreUnusedArgs()`. Writes go to the peers of every open channel whose filter the row matches: a create checks the new row, an update or delete the stored row (`readRow` saga) before and after the patch. A peer's write is taken only if the sender is listed by a channel whose filter the row matches (an update/delete of a row not held is ignored). Peers fold updates into every subscription showing the row (output only, never the store): normal fields retire once the synced row holds them (or after `channels.confirmTimeoutMs`, default 5000); `-- @ephemeral` fields (optional, never filtered on) never reach the database, last writer wins, vanish when their author disconnects. A peer's create goes into the store and circuit like an own optimistic create minus the outbox (`peerCreate` saga, `state.peerWrites` folded into `overlay()`) and is undone at `confirmTimeoutMs` unless the server body landed (`versions`); a peer's delete hides the row through the overlay until it expires (`peerDelete`, `PeerWriteExpired`). Signaling is raw CREATE/LIVE on the generated server-only `_00_channel_signal` (sender stamped by the DB). Everything beyond identity, filter and field types is `channels.accept: { [table]: ({ kind, from, table, id, row, patch }) => boolean }`. Config also `iceServers`, `transport`. Dart has the same feature on the same wire protocol (`spooky_core` `modules/channel/`, `client.channel(...)`, WebRTC via `spooky_flutter`'s `flutterWebRtc`), so web and Flutter peers interoperate: keep the message shape, signaling rows and offer/answer ordering in step across both. Code: `modules/channel/`. Docs: `/docs/client/channels`.
49
49
  - Types: `Sp00kyConfig` (client-solid wraps it as `SyncedDbConfig`), `QueryTimeToLive`, `PersistenceClient`, `StoreType`, `UpdateOptions`, `RunOptions`, `PreloadOptions`.
50
50
  - Subpath: `@spooky-sync/core/otel` — `createOtelTransmit(endpoint)` for piping pino logs to OpenTelemetry.
51
51
  - `client.webPush` (`WebPushModule`, `db.webPush` in the Solid bindings) - Web Push on the page: `support()`, `info()`, `subscribe(opts)` (call from a click), `unsubscribe(opts)`, `sync()`, `isSubscribed()`, `update(opts)`, `devices()`, `notify(msg)`, `cancel(id)`, `test()`, `bridge()`, `onMessage(cb)`. Server calls are `fn::push::*`; typed `WebPushError`; config `webPush` in `types.ts`. Sign-out runs through `AuthService.onBeforeSignOut`.
package/dist/index.js CHANGED
@@ -3650,8 +3650,8 @@ function trimHistory(history, cap) {
3650
3650
 
3651
3651
  //#endregion
3652
3652
  //#region src/modules/devtools/index.ts
3653
- const CORE_VERSION = "0.0.1-canary.361";
3654
- const WASM_VERSION = "0.0.1-canary.361";
3653
+ const CORE_VERSION = "0.0.1-canary.363";
3654
+ const WASM_VERSION = "0.0.1-canary.363";
3655
3655
  const SURREAL_VERSION = "3.1.0";
3656
3656
  var DevToolsService = class DevToolsService {
3657
3657
  version = CORE_VERSION;
@@ -14018,7 +14018,7 @@ var AuthService = class {
14018
14018
  const info = this.impersonation;
14019
14019
  if (!info) return;
14020
14020
  if (!opts.serverEnded) try {
14021
- await this.remote.query("RETURN fn::_00_impersonate::stop($session)", { session: info.session });
14021
+ await this.remote.query("RETURN fn::_00_impersonate::stop($sid)", { sid: info.session });
14022
14022
  } catch (error) {
14023
14023
  this.logger.warn({
14024
14024
  error,
@@ -14228,7 +14228,7 @@ var AuthService = class {
14228
14228
  if (impersonating) {
14229
14229
  this.endedImpersonationTarget = impersonating.target;
14230
14230
  try {
14231
- await this.remote.query("RETURN fn::_00_impersonate::stop($session)", { session: impersonating.session });
14231
+ await this.remote.query("RETURN fn::_00_impersonate::stop($sid)", { sid: impersonating.session });
14232
14232
  } catch (_e) {}
14233
14233
  }
14234
14234
  await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
@@ -17609,7 +17609,7 @@ function createTabsCoordinator(config, s, host) {
17609
17609
  return new TabsCoordinator({
17610
17610
  tabId: s.tabId,
17611
17611
  fingerprint: computeTabsFingerprint({
17612
- coreVersion: "0.0.1-canary.361",
17612
+ coreVersion: "0.0.1-canary.363",
17613
17613
  schemaHash: hash53(config.schemaSurql),
17614
17614
  endpoint: config.database.endpoint ?? "",
17615
17615
  namespace: config.database.namespace,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.361",
3
+ "version": "0.0.1-canary.363",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -75,8 +75,8 @@
75
75
  }
76
76
  },
77
77
  "dependencies": {
78
- "@spooky-sync/query-builder": "0.0.1-canary.361",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.361",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.363",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.363",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.1.0",
82
82
  "@surrealdb/wasm-native": "^3.3.4",
@@ -64,8 +64,8 @@ DEFINE FUNCTION fn::_00_impersonate::start($target: string, $reason: string) {
64
64
  UPSERT _00_impersonation:s1 SET target = type::record($target), admin = $auth.id, reason = $reason, ended_at = NONE;
65
65
  RETURN { token: $test_imp_token, session: '_00_impersonation:s1' };
66
66
  } PERMISSIONS FULL;
67
- DEFINE FUNCTION fn::_00_impersonate::stop($session: string) {
68
- UPDATE type::record($session) SET ended_at = time::now();
67
+ DEFINE FUNCTION fn::_00_impersonate::stop($session_id: string) {
68
+ UPDATE type::record($session_id) SET ended_at = time::now();
69
69
  RETURN NONE;
70
70
  } PERMISSIONS FULL;
71
71
  DEFINE FUNCTION fn::_00_impersonate::renew() {
@@ -149,6 +149,10 @@ describe('impersonation on the SurrealDB-WASM engine', () => {
149
149
  expect(String(auth.currentUser.id)).toBe('user:alice');
150
150
  expect(store.get('sp00ky_auth_token')).toBe(adminToken);
151
151
  expect(store.has(ORIGIN_TOKEN_KEY)).toBe(false);
152
+ // The server session ended too. Binding the id as `session` (a protected
153
+ // variable) failed silently here, and only the token's expiry ended it.
154
+ const [ended] = await db.query<[unknown]>('SELECT VALUE ended_at FROM ONLY _00_impersonation:s1');
155
+ expect(ended).toBeTruthy();
152
156
  });
153
157
 
154
158
  it('an explicit stop racing a server-ended stop still returns to the admin', async () => {
@@ -49,7 +49,7 @@ function makeServer(opts: { refuse?: Set<string>; unreachable?: boolean } = {})
49
49
  }
50
50
  if (sql.includes('fn::_00_impersonate::current')) return [state.ended ? null : { session: '_00_impersonation:s1' }];
51
51
  if (sql.includes('fn::_00_impersonate::stop')) {
52
- state.stops.push(vars?.session);
52
+ state.stops.push(vars?.sid);
53
53
  state.ended = true;
54
54
  return [null];
55
55
  }
@@ -382,7 +382,7 @@ export class AuthService<S extends SchemaStructure> {
382
382
  if (!info) return;
383
383
  if (!opts.serverEnded) {
384
384
  try {
385
- await this.remote.query('RETURN fn::_00_impersonate::stop($session)', { session: info.session });
385
+ await this.remote.query('RETURN fn::_00_impersonate::stop($sid)', { sid: info.session });
386
386
  } catch (error) {
387
387
  // The token expires on its own; the local switch below is what matters.
388
388
  this.logger.warn(
@@ -659,8 +659,8 @@ export class AuthService<S extends SchemaStructure> {
659
659
  if (impersonating) {
660
660
  this.endedImpersonationTarget = impersonating.target;
661
661
  try {
662
- await this.remote.query('RETURN fn::_00_impersonate::stop($session)', {
663
- session: impersonating.session,
662
+ await this.remote.query('RETURN fn::_00_impersonate::stop($sid)', {
663
+ sid: impersonating.session,
664
664
  });
665
665
  } catch (_e) {
666
666
  // The token expires on its own.