@happyvertical/smrt-core 0.57.51 → 0.57.52

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.
@@ -0,0 +1,118 @@
1
+ //#region src/utils/json-equal.ts
2
+ /**
3
+ * Structural equality for JSON field values (#4604).
4
+ *
5
+ * Diff guards (`SmrtCollection.getOrUpsert()` / `getDiff()`, sync-apply
6
+ * no-op detection) must not report an unchanged JSON field as changed just
7
+ * because the incoming object or array is a different instance, or because
8
+ * its keys arrive in a different order than the stored row's. Doing so
9
+ * re-saves the row, moves `updated_at`, and appends a change-feed entry on
10
+ * every call.
11
+ *
12
+ * Equality is decided in the value's JSON (persisted) form:
13
+ * - plain objects compare key-order-insensitively; a key whose value is
14
+ * `undefined` is absent (JSON drops it), but `null` is a real value;
15
+ * - arrays compare element by element, in order;
16
+ * - a `Date` equals another `Date` at the same instant, or the ISO string
17
+ * `JSON.stringify` would write for it;
18
+ * - everything else compares with `===` (no numeric/string coercion).
19
+ *
20
+ * Errs toward "changed": anything it cannot prove equal is unequal.
21
+ */
22
+ var JSON_NUMBER = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
23
+ /**
24
+ * Whether every number literal in JSON text survives `JSON.parse` exactly.
25
+ *
26
+ * `JSON.parse` reads numbers as doubles, so `9007199254740993` and
27
+ * `9007199254740992` parse to the same value. A literal is exact when the
28
+ * double it parses to prints back as the same text; anything else (unsafe
29
+ * integers, more digits than a double holds, `1.0`, `1e2`) makes the text
30
+ * unprovable. Digits inside JSON strings are not numbers and are skipped.
31
+ */
32
+ function jsonNumbersRoundTrip(text) {
33
+ let inString = false;
34
+ for (let index = 0; index < text.length; index += 1) {
35
+ const char = text[index];
36
+ if (inString) {
37
+ if (char === "\\") index += 1;
38
+ else if (char === "\"") inString = false;
39
+ continue;
40
+ }
41
+ if (char === "\"") {
42
+ inString = true;
43
+ continue;
44
+ }
45
+ if (char === "-" || char >= "0" && char <= "9") {
46
+ JSON_NUMBER.lastIndex = index;
47
+ const match = JSON_NUMBER.exec(text);
48
+ if (!match) return false;
49
+ const literal = match[0];
50
+ if (JSON.stringify(Number(literal)) !== literal) return false;
51
+ index += literal.length - 1;
52
+ }
53
+ }
54
+ return true;
55
+ }
56
+ function isPlainObject(value) {
57
+ if (value === null || typeof value !== "object") return false;
58
+ const proto = Object.getPrototypeOf(value);
59
+ return proto === Object.prototype || proto === null;
60
+ }
61
+ function dateTime(value) {
62
+ const time = value.getTime();
63
+ return Number.isNaN(time) ? null : time;
64
+ }
65
+ function definedKeys(value) {
66
+ return Object.keys(value).filter((key) => value[key] !== void 0);
67
+ }
68
+ /**
69
+ * Whether two values are equal in their JSON form.
70
+ *
71
+ * Bounded by `depth` so a cyclic value (which cannot be persisted as JSON)
72
+ * reports unequal instead of overflowing the stack.
73
+ */
74
+ function jsonValuesEqual(a, b, depth = 0) {
75
+ if (a === b) return true;
76
+ if (depth > 256) return false;
77
+ if (a instanceof Date || b instanceof Date) {
78
+ if (a instanceof Date && b instanceof Date) {
79
+ const timeA = dateTime(a);
80
+ return timeA !== null && timeA === dateTime(b);
81
+ }
82
+ const [date, other] = a instanceof Date ? [a, b] : [b, a];
83
+ return typeof other === "string" && dateTime(date) !== null && date.toISOString() === other;
84
+ }
85
+ if (Array.isArray(a) || Array.isArray(b)) {
86
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
87
+ for (let index = 0; index < a.length; index += 1) if (!jsonValuesEqual(a[index], b[index], depth + 1)) return false;
88
+ return true;
89
+ }
90
+ if (isPlainObject(a) && isPlainObject(b)) {
91
+ const keysA = definedKeys(a);
92
+ const keysB = definedKeys(b);
93
+ if (keysA.length !== keysB.length) return false;
94
+ return keysA.every((key) => Object.hasOwn(b, key) && b[key] !== void 0 && jsonValuesEqual(a[key], b[key], depth + 1));
95
+ }
96
+ return false;
97
+ }
98
+ /**
99
+ * Parse `value` when it is JSON text for an object or array; otherwise
100
+ * return it unchanged. Used to compare a JSON field hydrated (or supplied)
101
+ * as text against the same value supplied as an object.
102
+ */
103
+ function parseJsonContainerText(value) {
104
+ if (typeof value !== "string") return value;
105
+ const trimmed = value.trim();
106
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;
107
+ if (!jsonNumbersRoundTrip(trimmed)) return value;
108
+ try {
109
+ const parsed = JSON.parse(trimmed);
110
+ return parsed !== null && typeof parsed === "object" ? parsed : value;
111
+ } catch {
112
+ return value;
113
+ }
114
+ }
115
+ //#endregion
116
+ export { jsonNumbersRoundTrip, jsonValuesEqual, parseJsonContainerText };
117
+
118
+ //# sourceMappingURL=json-equal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"json-equal.js","names":[],"sources":["../../src/utils/json-equal.ts"],"sourcesContent":["/**\n * Structural equality for JSON field values (#4604).\n *\n * Diff guards (`SmrtCollection.getOrUpsert()` / `getDiff()`, sync-apply\n * no-op detection) must not report an unchanged JSON field as changed just\n * because the incoming object or array is a different instance, or because\n * its keys arrive in a different order than the stored row's. Doing so\n * re-saves the row, moves `updated_at`, and appends a change-feed entry on\n * every call.\n *\n * Equality is decided in the value's JSON (persisted) form:\n * - plain objects compare key-order-insensitively; a key whose value is\n * `undefined` is absent (JSON drops it), but `null` is a real value;\n * - arrays compare element by element, in order;\n * - a `Date` equals another `Date` at the same instant, or the ISO string\n * `JSON.stringify` would write for it;\n * - everything else compares with `===` (no numeric/string coercion).\n *\n * Errs toward \"changed\": anything it cannot prove equal is unequal.\n */\n\n// Import-free on purpose: generated sync-apply routes bundle this module.\n\nconst JSON_NUMBER = /-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?/y;\n\n/**\n * Whether every number literal in JSON text survives `JSON.parse` exactly.\n *\n * `JSON.parse` reads numbers as doubles, so `9007199254740993` and\n * `9007199254740992` parse to the same value. A literal is exact when the\n * double it parses to prints back as the same text; anything else (unsafe\n * integers, more digits than a double holds, `1.0`, `1e2`) makes the text\n * unprovable. Digits inside JSON strings are not numbers and are skipped.\n */\nexport function jsonNumbersRoundTrip(text: string): boolean {\n let inString = false;\n for (let index = 0; index < text.length; index += 1) {\n const char = text[index];\n if (inString) {\n if (char === '\\\\') index += 1;\n else if (char === '\"') inString = false;\n continue;\n }\n if (char === '\"') {\n inString = true;\n continue;\n }\n if (char === '-' || (char >= '0' && char <= '9')) {\n JSON_NUMBER.lastIndex = index;\n const match = JSON_NUMBER.exec(text);\n if (!match) return false;\n const literal = match[0];\n if (JSON.stringify(Number(literal)) !== literal) return false;\n index += literal.length - 1;\n }\n }\n return true;\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n if (value === null || typeof value !== 'object') return false;\n const proto = Object.getPrototypeOf(value);\n return proto === Object.prototype || proto === null;\n}\n\nfunction dateTime(value: Date): number | null {\n const time = value.getTime();\n return Number.isNaN(time) ? null : time;\n}\n\nfunction definedKeys(value: Record<string, unknown>): string[] {\n return Object.keys(value).filter((key) => value[key] !== undefined);\n}\n\n/**\n * Whether two values are equal in their JSON form.\n *\n * Bounded by `depth` so a cyclic value (which cannot be persisted as JSON)\n * reports unequal instead of overflowing the stack.\n */\nexport function jsonValuesEqual(a: unknown, b: unknown, depth = 0): boolean {\n if (a === b) return true;\n if (depth > 256) return false;\n\n if (a instanceof Date || b instanceof Date) {\n if (a instanceof Date && b instanceof Date) {\n const timeA = dateTime(a);\n return timeA !== null && timeA === dateTime(b);\n }\n const [date, other] = a instanceof Date ? [a, b] : [b as Date, a];\n return (\n typeof other === 'string' &&\n dateTime(date) !== null &&\n date.toISOString() === other\n );\n }\n\n if (Array.isArray(a) || Array.isArray(b)) {\n if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {\n return false;\n }\n // Index loop, not `every()`: `every()` skips holes, so a sparse array\n // would match any value at that index.\n for (let index = 0; index < a.length; index += 1) {\n if (!jsonValuesEqual(a[index], b[index], depth + 1)) return false;\n }\n return true;\n }\n\n if (isPlainObject(a) && isPlainObject(b)) {\n const keysA = definedKeys(a);\n const keysB = definedKeys(b);\n if (keysA.length !== keysB.length) return false;\n // Own properties only: `b[key]` alone would resolve `__proto__` (an own\n // key of parsed JSON) through the prototype chain.\n return keysA.every(\n (key) =>\n Object.hasOwn(b, key) &&\n b[key] !== undefined &&\n jsonValuesEqual(a[key], b[key], depth + 1),\n );\n }\n\n return false;\n}\n\n/**\n * Parse `value` when it is JSON text for an object or array; otherwise\n * return it unchanged. Used to compare a JSON field hydrated (or supplied)\n * as text against the same value supplied as an object.\n */\nexport function parseJsonContainerText(value: unknown): unknown {\n if (typeof value !== 'string') return value;\n const trimmed = value.trim();\n if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return value;\n // Text whose numbers a double cannot hold exactly stays unparsed (unequal).\n if (!jsonNumbersRoundTrip(trimmed)) return value;\n try {\n const parsed: unknown = JSON.parse(trimmed);\n return parsed !== null && typeof parsed === 'object' ? parsed : value;\n } catch {\n return value;\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-core",
3
- "version": "0.57.51",
3
+ "version": "0.57.52",
4
4
  "description": "Core SMRT framework for business objects, persistence, and generated REST, CLI, MCP, and AI interfaces",
5
5
  "author": "HappyVertical",
6
6
  "type": "module",
@@ -199,9 +199,9 @@
199
199
  "@happyvertical/files": "^0.111.3",
200
200
  "@happyvertical/json": "^0.111.3",
201
201
  "@happyvertical/logger": "^0.111.3",
202
- "@happyvertical/smrt-config": "0.57.51",
203
- "@happyvertical/smrt-scanner": "0.57.51",
204
- "@happyvertical/smrt-types": "0.57.51",
202
+ "@happyvertical/smrt-config": "0.57.52",
203
+ "@happyvertical/smrt-scanner": "0.57.52",
204
+ "@happyvertical/smrt-types": "0.57.52",
205
205
  "@happyvertical/sql": "^0.111.3",
206
206
  "@happyvertical/utils": "^0.111.3",
207
207
  "@noble/hashes": "^2.4.0",