@ultimat3/query 1.2.0 → 3.0.0

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/src/stable.ts CHANGED
@@ -13,8 +13,15 @@ export function stableStringify(value: unknown): string {
13
13
  switch (typeof value) {
14
14
  case 'string':
15
15
  return JSON.stringify(value);
16
+ // A bare token, never `'null'` and never a quoted string: this output is only ever hashed, so
17
+ // an unquoted word cannot collide with the `string` branch (which always quotes) while
18
+ // `'null'` collided with JSON `null` itself — `{ n: NaN }`, `{ n: Infinity }` and `{ n: null }`
19
+ // fingerprinted identically and shared one cache entry and one cursor scope. `-0` is spelled
20
+ // out for the same reason: `String(-0)` is `"0"`, so `-0` and `0` were one key too.
16
21
  case 'number':
17
- return Number.isFinite(value) ? String(value) : 'null';
22
+ if (Number.isNaN(value)) return 'NaN';
23
+ if (!Number.isFinite(value)) return value > 0 ? 'Infinity' : '-Infinity';
24
+ return Object.is(value, -0) ? '-0' : String(value);
18
25
  case 'boolean':
19
26
  return String(value);
20
27
  case 'bigint':
@@ -35,18 +42,22 @@ export function stableStringify(value: unknown): string {
35
42
  return `{${entries.join(',')}}`;
36
43
  }
37
44
 
38
- /** FNV-1a/32 as hex. Identity of a query shape, never a security boundary. */
39
- export function fnv1a(input: string): string {
40
- let hash = 0x811c9dc5;
41
- for (let i = 0; i < input.length; i += 1) {
42
- hash ^= input.charCodeAt(i);
43
- hash = Math.imul(hash, 0x01000193) >>> 0;
44
- }
45
- return hash.toString(16).padStart(8, '0');
46
- }
47
-
45
+ /**
46
+ * SHA-256, first 16 hex characters — the same primitive and the same width `@ultimat3/realtime`'s
47
+ * `stableDigest` and `@ultimat3/entity`'s `planScope` already chose, and for the same reason.
48
+ *
49
+ * A fingerprint is a SHARING key, not a checksum: it decides which read-cache entry two callers
50
+ * are served from and which scope a cursor is bound to, over input a client chooses. FNV-1a/32 —
51
+ * what this was — is 4x10^9 values, brute-forceable offline in seconds, so an attacker could mint
52
+ * an input that lands on another read's entry or another page's scope. It identifies, and here
53
+ * identifying IS the boundary.
54
+ *
55
+ * The canonical form above is unchanged, so the only thing that moved is the hash: a cursor issued
56
+ * before this fails its scope check as `X_CURSOR_INVALID` — cleanly, with "request the first page
57
+ * again" as its fix — and a warm read cache is cold once.
58
+ */
48
59
  export function fingerprint(value: unknown): string {
49
- return fnv1a(stableStringify(value));
60
+ return new Bun.CryptoHasher('sha256').update(stableStringify(value)).digest('hex').slice(0, 16);
50
61
  }
51
62
 
52
63
  /** Column read that works for interfaces without an index signature. */
package/src/tags.ts DELETED
@@ -1,17 +0,0 @@
1
- /**
2
- * Cache tags stay opaque here — they belong to @ultimat3/cache. This package only
3
- * needs the wire string per tag so a read's key can be found by the invalidation
4
- * graph an action's `invalidates` drives.
5
- */
6
-
7
- import type { CacheTag } from '@ultimat3/cache';
8
- import { serializeTag } from '@ultimat3/cache';
9
-
10
- export function tagKey(value: CacheTag): string {
11
- return serializeTag(value);
12
- }
13
-
14
- /** Sorted + de-duplicated: descriptor output must not depend on declaration order. */
15
- export function tagKeys(tags: readonly CacheTag[]): readonly string[] {
16
- return [...new Set(tags.map(tagKey))].sort();
17
- }