@spooky-sync/core 0.0.1-canary.241 → 0.0.1-canary.243

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.js CHANGED
@@ -1073,8 +1073,8 @@ function selfAllowlistedVariant(flag, userId) {
1073
1073
 
1074
1074
  //#endregion
1075
1075
  //#region src/modules/devtools/index.ts
1076
- const CORE_VERSION = "0.0.1-canary.241";
1077
- const WASM_VERSION = "0.0.1-canary.241";
1076
+ const CORE_VERSION = "0.0.1-canary.243";
1077
+ const WASM_VERSION = "0.0.1-canary.243";
1078
1078
  const SURREAL_VERSION = "3.0.3";
1079
1079
  var DevToolsService = class DevToolsService {
1080
1080
  eventsHistory = [];
@@ -12473,7 +12473,7 @@ function createTabsCoordinator(config, s, host) {
12473
12473
  return new TabsCoordinator({
12474
12474
  tabId: s.tabId,
12475
12475
  fingerprint: computeTabsFingerprint({
12476
- coreVersion: "0.0.1-canary.241",
12476
+ coreVersion: "0.0.1-canary.243",
12477
12477
  schemaHash: hash53(config.schemaSurql),
12478
12478
  endpoint: config.database.endpoint ?? "",
12479
12479
  namespace: config.database.namespace,
@@ -205,6 +205,15 @@ function reviveRow(json) {
205
205
  * than as text. `COALESCE` covers a row sharing none of the requested keys,
206
206
  * where the subquery yields NULL and `reviveRow` would throw.
207
207
  *
208
+ * The one value `je.value` does NOT carry back is a TOP-LEVEL boolean. SQLite
209
+ * has no boolean type, so `json_each` hands `true`/`false` out as the integers
210
+ * 1/0 and `json_group_object` writes integers back. A projected row therefore
211
+ * read `published: 1` where the same row read whole read `published: true`,
212
+ * and every `=== true` in an app turned false the moment a screen painted from
213
+ * the local cache. (A boolean NESTED in an object or array survives: those come
214
+ * back as whole JSON documents, untouched.) `je.type` still knows what it was,
215
+ * so the two boolean types are rebuilt as JSON literals on the way out.
216
+ *
208
217
  * Deliberately unaliased, so the emitted statement keeps the exact shape the
209
218
  * callers already produce (`FROM "t" WHERE id IN (…)`).
210
219
  *
@@ -214,7 +223,7 @@ function reviveRow(json) {
214
223
  function projectedDataSql(fields, bind) {
215
224
  const keys = ["id", ...fields];
216
225
  for (const k of keys) bind.push(k);
217
- return `COALESCE((SELECT json_group_object(je.key, je.value) FROM json_each(data) je WHERE je.key IN (${keys.map(() => "?").join(", ")})), '{}') AS data`;
226
+ return `COALESCE((SELECT json_group_object(je.key, CASE je.type WHEN 'true' THEN json('true') WHEN 'false' THEN json('false') ELSE je.value END) FROM json_each(data) je WHERE je.key IN (${keys.map(() => "?").join(", ")})), '{}') AS data`;
218
227
  }
219
228
  function project(row, fields) {
220
229
  const out = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.241",
3
+ "version": "0.0.1-canary.243",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -63,8 +63,8 @@
63
63
  }
64
64
  },
65
65
  "dependencies": {
66
- "@spooky-sync/query-builder": "0.0.1-canary.241",
67
- "@spooky-sync/ssp-wasm": "0.0.1-canary.241",
66
+ "@spooky-sync/query-builder": "0.0.1-canary.243",
67
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.243",
68
68
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
69
69
  "@surrealdb/wasm": "^3.0.3",
70
70
  "blurhash": "^2.0.5",
@@ -99,6 +99,15 @@ export function reviveRow(json: string): Row {
99
99
  * than as text. `COALESCE` covers a row sharing none of the requested keys,
100
100
  * where the subquery yields NULL and `reviveRow` would throw.
101
101
  *
102
+ * The one value `je.value` does NOT carry back is a TOP-LEVEL boolean. SQLite
103
+ * has no boolean type, so `json_each` hands `true`/`false` out as the integers
104
+ * 1/0 and `json_group_object` writes integers back. A projected row therefore
105
+ * read `published: 1` where the same row read whole read `published: true`,
106
+ * and every `=== true` in an app turned false the moment a screen painted from
107
+ * the local cache. (A boolean NESTED in an object or array survives: those come
108
+ * back as whole JSON documents, untouched.) `je.type` still knows what it was,
109
+ * so the two boolean types are rebuilt as JSON literals on the way out.
110
+ *
102
111
  * Deliberately unaliased, so the emitted statement keeps the exact shape the
103
112
  * callers already produce (`FROM "t" WHERE id IN (…)`).
104
113
  *
@@ -109,8 +118,14 @@ export function projectedDataSql(fields: string[], bind: unknown[]): string {
109
118
  const keys = ['id', ...fields];
110
119
  for (const k of keys) bind.push(k);
111
120
  const placeholders = keys.map(() => '?').join(', ');
121
+ // `json('true')` / `json('false')` return JSON-subtyped values, so
122
+ // `json_group_object` embeds them as the literals `true` / `false` rather
123
+ // than as quoted text.
124
+ const value =
125
+ `CASE je.type WHEN 'true' THEN json('true') ` +
126
+ `WHEN 'false' THEN json('false') ELSE je.value END`;
112
127
  return (
113
- `COALESCE((SELECT json_group_object(je.key, je.value) ` +
128
+ `COALESCE((SELECT json_group_object(je.key, ${value}) ` +
114
129
  `FROM json_each(data) je WHERE je.key IN (${placeholders})), '{}') AS data`
115
130
  );
116
131
  }
@@ -12,7 +12,9 @@ import type { Row } from './cache-engine';
12
12
  * over it, which is what both engine paths did before. The traps it has to
13
13
  * clear are an ABSENT key (must stay absent, not become an explicit null — the
14
14
  * difference `json_object`/`json_extract` would have introduced), a STORED
15
- * null, nested objects/arrays, and the `{__u8}` tag that carries binary.
15
+ * null, nested objects/arrays, the `{__u8}` tag that carries binary, and a
16
+ * TOP-LEVEL boolean (SQLite has no boolean type, so an unguarded projection
17
+ * hands `true` back as the integer 1).
16
18
  */
17
19
 
18
20
  function makeDb(rows: Row[]) {
@@ -49,9 +51,10 @@ const FIXTURE: Row[] = [
49
51
  { id: 'game:2', white: null, pgn: 'heavy', sort_index: -3 }, // STORED null
50
52
  { id: 'game:3', pgn: 'heavy', sort_index: -4 }, // 'white' ABSENT
51
53
  { id: 'game:4', white: 'w', pgn: 'heavy', sort_index: -1, meta: { a: 1, b: [2, 3] } },
54
+ { id: 'game:5', white: 'w', pgn: 'heavy', sort_index: 0, rated: true, draw: false },
52
55
  ];
53
- const IDS = ['game:1', 'game:2', 'game:3', 'game:4'];
54
- const FIELDS = ['white', 'sort_index', 'meta'];
56
+ const IDS = ['game:1', 'game:2', 'game:3', 'game:4', 'game:5'];
57
+ const FIELDS = ['white', 'sort_index', 'meta', 'rated', 'draw'];
55
58
 
56
59
  describe('projectedDataSql against real SQLite', () => {
57
60
  it('is byte-identical to parse-then-project', () => {
@@ -91,6 +94,25 @@ describe('projectedDataSql against real SQLite', () => {
91
94
  for (const row of projectedRows(db, IDS, ['sort_index'])) expect(row.id).toBeTruthy();
92
95
  });
93
96
 
97
+ it('round-trips a top-level boolean as a boolean, not as 1/0', () => {
98
+ // The bug this guards: `json_each` yields SQLite integers for JSON booleans,
99
+ // so a projected row read `rated: 1` and every `row.rated === true` in a
100
+ // consuming app went false as soon as the screen painted from the cache.
101
+ // WhitePawn's /explore detail page read exactly that as "this collection was
102
+ // unpublished or deleted by its author".
103
+ const db = makeDb(FIXTURE);
104
+ const row = projectedRows(db, IDS, FIELDS).find((r) => r.id === 'game:5')!;
105
+ expect(row.rated).toBe(true);
106
+ expect(row.draw).toBe(false);
107
+ });
108
+
109
+ it('leaves a boolean nested in an object or array alone', () => {
110
+ const db = makeDb([{ id: 'game:7', flags: { on: true, off: false }, list: [true, false] }]);
111
+ const [row] = projectedRows(db, ['game:7'], ['flags', 'list']);
112
+ expect(row!.flags).toEqual({ on: true, off: false });
113
+ expect(row!.list).toEqual([true, false]);
114
+ });
115
+
94
116
  it('yields an empty object, not NULL, for a row sharing none of the keys', () => {
95
117
  // Without COALESCE the subquery returns NULL here and reviveRow throws.
96
118
  const db = makeDb([{ id: 'game:8', pgn: 'heavy' }]);