@spooky-sync/core 0.0.1-canary.227 → 0.0.1-canary.229

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/dist/index.d.ts CHANGED
@@ -2587,6 +2587,28 @@ declare class Sp00kyClient<S extends SchemaStructure> {
2587
2587
  * outbox - and the connection supervisor keeps retrying underneath.
2588
2588
  */
2589
2589
  private initRemote;
2590
+ /**
2591
+ * Write the signed-in user's own row, as the SERVER just returned it, into
2592
+ * the local store.
2593
+ *
2594
+ * `AuthService.check()` (boot verification, sign-in, an app-driven refresh)
2595
+ * runs `SELECT * FROM ONLY $auth.id` and keeps the answer on
2596
+ * `auth.currentUser`; nothing consumed it beyond the id. Meanwhile the app's
2597
+ * own `user` query paints local-first from whatever body the store holds,
2598
+ * and that body is only refreshed when the server's row version reaches
2599
+ * this client through membership (`_00_list_ref`). While the SSP is down or
2600
+ * bootstrapping that never happens, so a device that registered before the
2601
+ * account was verified kept rendering a row without `email_verified` and
2602
+ * the app parked the user on "Verify your email" over a server that said
2603
+ * "already verified". The authoritative row was already in hand.
2604
+ *
2605
+ * MERGE semantics (`CacheModule.saveBatch`) keep local-only fields, and
2606
+ * re-using the memoized version keeps the sync dedup honest: this is a
2607
+ * field refresh, not a version claim. `notifyTableQueries` re-materializes
2608
+ * the `user` queries so the gate flips without waiting for a stream update.
2609
+ * Best-effort: a failure here leaves things exactly as they were.
2610
+ */
2611
+ private persistVerifiedUser;
2590
2612
  private bucketSwitchChain;
2591
2613
  private pendingBucketTarget;
2592
2614
  /**
package/dist/index.js CHANGED
@@ -7764,8 +7764,8 @@ function selfAllowlistedVariant(flag, userId) {
7764
7764
 
7765
7765
  //#endregion
7766
7766
  //#region src/modules/devtools/index.ts
7767
- const CORE_VERSION = "0.0.1-canary.227";
7768
- const WASM_VERSION = "0.0.1-canary.227";
7767
+ const CORE_VERSION = "0.0.1-canary.229";
7768
+ const WASM_VERSION = "0.0.1-canary.229";
7769
7769
  const SURREAL_VERSION = "3.0.3";
7770
7770
  var DevToolsService = class DevToolsService {
7771
7771
  eventsHistory = [];
@@ -12780,7 +12780,7 @@ var Sp00kyClient = class {
12780
12780
  return new TabsCoordinator({
12781
12781
  tabId,
12782
12782
  fingerprint: computeTabsFingerprint({
12783
- coreVersion: "0.0.1-canary.227",
12783
+ coreVersion: "0.0.1-canary.229",
12784
12784
  schemaHash: hash53(this.config.schemaSurql),
12785
12785
  endpoint: this.config.database.endpoint ?? "",
12786
12786
  namespace: this.config.database.namespace,
@@ -12971,6 +12971,7 @@ var Sp00kyClient = class {
12971
12971
  Category: "sp00ky-client::Sp00kyClient::authChange"
12972
12972
  }, "sync.setCurrentUserId failed");
12973
12973
  }
12974
+ await this.persistVerifiedUser();
12974
12975
  });
12975
12976
  await this.sync.init();
12976
12977
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sync initialized");
@@ -13027,6 +13028,51 @@ var Sp00kyClient = class {
13027
13028
  }, "Auth verification failed; keeping the restored session");
13028
13029
  }
13029
13030
  }
13031
+ /**
13032
+ * Write the signed-in user's own row, as the SERVER just returned it, into
13033
+ * the local store.
13034
+ *
13035
+ * `AuthService.check()` (boot verification, sign-in, an app-driven refresh)
13036
+ * runs `SELECT * FROM ONLY $auth.id` and keeps the answer on
13037
+ * `auth.currentUser`; nothing consumed it beyond the id. Meanwhile the app's
13038
+ * own `user` query paints local-first from whatever body the store holds,
13039
+ * and that body is only refreshed when the server's row version reaches
13040
+ * this client through membership (`_00_list_ref`). While the SSP is down or
13041
+ * bootstrapping that never happens, so a device that registered before the
13042
+ * account was verified kept rendering a row without `email_verified` and
13043
+ * the app parked the user on "Verify your email" over a server that said
13044
+ * "already verified". The authoritative row was already in hand.
13045
+ *
13046
+ * MERGE semantics (`CacheModule.saveBatch`) keep local-only fields, and
13047
+ * re-using the memoized version keeps the sync dedup honest: this is a
13048
+ * field refresh, not a version claim. `notifyTableQueries` re-materializes
13049
+ * the `user` queries so the gate flips without waiting for a stream update.
13050
+ * Best-effort: a failure here leaves things exactly as they were.
13051
+ */
13052
+ async persistVerifiedUser() {
13053
+ const row = this.auth.currentUser;
13054
+ if (!row || !(row.id instanceof RecordId) || Object.keys(row).length <= 1) return;
13055
+ const rid = row.id;
13056
+ const table = rid.table.toString();
13057
+ const tableSchema = this.config.schema.tables.find((t) => t.name === table);
13058
+ if (!tableSchema) return;
13059
+ try {
13060
+ const encoded = encodeRecordId(rid);
13061
+ const version = this.cache.lookup(encoded) || 1;
13062
+ await this.cache.saveBatch([{
13063
+ table,
13064
+ op: "UPDATE",
13065
+ record: cleanRecord(tableSchema.columns, row),
13066
+ version
13067
+ }]);
13068
+ await this.dataModule.notifyTableQueries(table);
13069
+ } catch (e) {
13070
+ this.logger.warn({
13071
+ err: e,
13072
+ Category: "sp00ky-client::Sp00kyClient::persistVerifiedUser"
13073
+ }, "Could not persist the verified user row locally");
13074
+ }
13075
+ }
13030
13076
  bucketSwitchChain = Promise.resolve();
13031
13077
  pendingBucketTarget = null;
13032
13078
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.227",
3
+ "version": "0.0.1-canary.229",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.227",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.227",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.229",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.229",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "blurhash": "^2.0.5",
@@ -63,6 +63,26 @@ describe('Sp00kyClient.auth.subscribe ordering invariant', () => {
63
63
  }
64
64
  });
65
65
 
66
+ it('persists the server-verified user row only after the bucket switch', () => {
67
+ // `persistVerifiedUser` writes `auth.currentUser` (the row `check()` got
68
+ // from `SELECT * FROM ONLY $auth.id`) into the local store so the app's
69
+ // `user` query stops rendering a stale, pre-verification body. It must run
70
+ // AFTER `ensureLocalBucket`, or the row lands in the previous user's
71
+ // bucket on a sign-in that switches accounts.
72
+ const match = source.match(
73
+ /this\.auth\.subscribe\(\s*async\s*\(\s*userId\s*[^)]*\)\s*=>\s*\{([\s\S]*?)\n {6}\}\s*\)/
74
+ );
75
+ expect(match).not.toBeNull();
76
+ const stripped = match![1]
77
+ .split('\n')
78
+ .map((line) => line.replace(/\/\/.*$/, ''))
79
+ .join('\n');
80
+ const bucketIdx = stripped.indexOf('this.ensureLocalBucket(userId)');
81
+ const persistIdx = stripped.indexOf('this.persistVerifiedUser()');
82
+ expect(persistIdx, 'persistVerifiedUser() must be called from the auth subscriber').toBeGreaterThanOrEqual(0);
83
+ expect(persistIdx).toBeGreaterThan(bucketIdx);
84
+ });
85
+
66
86
  it('writes the boot-bucket hint synchronously and switches buckets before sync.setCurrentUserId', () => {
67
87
  const match = source.match(
68
88
  /this\.auth\.subscribe\(\s*async\s*\(\s*userId\s*[^)]*\)\s*=>\s*\{([\s\S]*?)\n {6}\}\s*\)/
package/src/sp00ky.ts CHANGED
@@ -53,6 +53,8 @@ import type { AppReleaseOptions } from './modules/app-release/index';
53
53
  import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
54
54
  import { ANON_USER_ID, bucketIdForUser } from './modules/ref-tables';
55
55
  import { parseQueryParams, encodeRecordId, parseDuration } from './utils/index';
56
+ import { cleanRecord } from './utils/parser';
57
+ import { RecordId } from 'surrealdb';
56
58
  import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
57
59
  import { ResilientPersistenceClient } from './services/persistence/resilient';
58
60
  import { detectSharedTabsSupport } from './services/tabs/support';
@@ -976,6 +978,8 @@ export class Sp00kyClient<S extends SchemaStructure> {
976
978
  'sync.setCurrentUserId failed'
977
979
  );
978
980
  }
981
+ // After the bucket switch, so the row lands in THIS user's store.
982
+ await this.persistVerifiedUser();
979
983
  });
980
984
 
981
985
  await this.sync.init();
@@ -1083,6 +1087,54 @@ export class Sp00kyClient<S extends SchemaStructure> {
1083
1087
  }
1084
1088
  }
1085
1089
 
1090
+ /**
1091
+ * Write the signed-in user's own row, as the SERVER just returned it, into
1092
+ * the local store.
1093
+ *
1094
+ * `AuthService.check()` (boot verification, sign-in, an app-driven refresh)
1095
+ * runs `SELECT * FROM ONLY $auth.id` and keeps the answer on
1096
+ * `auth.currentUser`; nothing consumed it beyond the id. Meanwhile the app's
1097
+ * own `user` query paints local-first from whatever body the store holds,
1098
+ * and that body is only refreshed when the server's row version reaches
1099
+ * this client through membership (`_00_list_ref`). While the SSP is down or
1100
+ * bootstrapping that never happens, so a device that registered before the
1101
+ * account was verified kept rendering a row without `email_verified` and
1102
+ * the app parked the user on "Verify your email" over a server that said
1103
+ * "already verified". The authoritative row was already in hand.
1104
+ *
1105
+ * MERGE semantics (`CacheModule.saveBatch`) keep local-only fields, and
1106
+ * re-using the memoized version keeps the sync dedup honest: this is a
1107
+ * field refresh, not a version claim. `notifyTableQueries` re-materializes
1108
+ * the `user` queries so the gate flips without waiting for a stream update.
1109
+ * Best-effort: a failure here leaves things exactly as they were.
1110
+ */
1111
+ private async persistVerifiedUser(): Promise<void> {
1112
+ const row = this.auth.currentUser as Record<string, unknown> | null;
1113
+ if (!row || !(row.id instanceof RecordId) || Object.keys(row).length <= 1) return;
1114
+ const rid = row.id as RecordId;
1115
+ const table = rid.table.toString();
1116
+ const tableSchema = this.config.schema.tables.find((t) => t.name === table);
1117
+ if (!tableSchema) return;
1118
+ try {
1119
+ const encoded = encodeRecordId(rid);
1120
+ const version = this.cache.lookup(encoded) || 1;
1121
+ await this.cache.saveBatch([
1122
+ {
1123
+ table,
1124
+ op: 'UPDATE',
1125
+ record: cleanRecord(tableSchema.columns, row) as RecordWithId,
1126
+ version,
1127
+ },
1128
+ ]);
1129
+ await this.dataModule.notifyTableQueries(table);
1130
+ } catch (e) {
1131
+ this.logger.warn(
1132
+ { err: e, Category: 'sp00ky-client::Sp00kyClient::persistVerifiedUser' },
1133
+ 'Could not persist the verified user row locally'
1134
+ );
1135
+ }
1136
+ }
1137
+
1086
1138
  // Serializes bucket switches from rapid auth flips; `pendingBucketTarget`
1087
1139
  // makes intermediate targets collapse (A→anon→B never opens the anon bucket).
1088
1140
  private bucketSwitchChain: Promise<void> = Promise.resolve();