@objy/server 0.0.0-stage → 0.1.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.
Files changed (55) hide show
  1. package/API.md +1469 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +9 -0
  4. package/README.md +21 -2
  5. package/objydb.config.json +48 -0
  6. package/package.json +42 -4
  7. package/public/index.html +4134 -0
  8. package/public/objy-db-logo.png +0 -0
  9. package/src/aggregate.js +438 -0
  10. package/src/api.js +15 -0
  11. package/src/auth.js +472 -0
  12. package/src/clock.js +18 -0
  13. package/src/cluster-secret.js +25 -0
  14. package/src/cluster-status.js +562 -0
  15. package/src/compression-stats.js +32 -0
  16. package/src/config.js +360 -0
  17. package/src/crdt.js +1076 -0
  18. package/src/derived-state-scheduler.js +128 -0
  19. package/src/files.js +324 -0
  20. package/src/hnsw.js +269 -0
  21. package/src/http-cors.js +31 -0
  22. package/src/http-router.js +162 -0
  23. package/src/index.js +137 -0
  24. package/src/interfaces/auth.js +123 -0
  25. package/src/interfaces/cluster.js +296 -0
  26. package/src/interfaces/public-object.js +2452 -0
  27. package/src/interfaces/ui.js +38 -0
  28. package/src/lib/crypto.cjs +54 -0
  29. package/src/lib/jwt.cjs +231 -0
  30. package/src/local-wal.js +122 -0
  31. package/src/memory-guard.js +152 -0
  32. package/src/ndjson.js +117 -0
  33. package/src/node-runtime.js +29 -0
  34. package/src/object-hooks.js +282 -0
  35. package/src/object-id.js +7 -0
  36. package/src/object-storage.js +263 -0
  37. package/src/object-store.js +288 -0
  38. package/src/patch.js +87 -0
  39. package/src/permissions.js +178 -0
  40. package/src/persistence.js +1918 -0
  41. package/src/plugin-loader.js +21 -0
  42. package/src/query-planner.js +266 -0
  43. package/src/query.js +416 -0
  44. package/src/references.js +349 -0
  45. package/src/replication-scheduler.js +303 -0
  46. package/src/restore-status.js +77 -0
  47. package/src/runtime.js +47 -0
  48. package/src/server.js +242 -0
  49. package/src/shape.js +286 -0
  50. package/src/shard-map-store.js +85 -0
  51. package/src/shard-map.js +333 -0
  52. package/src/shard-router.js +737 -0
  53. package/src/store.js +3410 -0
  54. package/src/system-secrets.js +107 -0
  55. package/src/vector.js +83 -0
@@ -0,0 +1,288 @@
1
+ import { stableKey } from "./query.js";
2
+
3
+ const DEFAULT_VALUE_DIRECTORY = Object.freeze({
4
+ enabled: true,
5
+ minRefs: 2,
6
+ maxEntries: 100_000,
7
+ maxCandidates: 4_096,
8
+ maxCandidatesPerPath: 256,
9
+ maxPaths: 1_024,
10
+ maxStringLength: 256,
11
+ maxKeyBytes: 512,
12
+ poolStructuredValues: false,
13
+ maxNestedContainerEntries: 64,
14
+ });
15
+
16
+ export class ObjectRowStore {
17
+ constructor(options = {}) {
18
+ this.rows = new Map();
19
+ this.valueDirectory = new ValueDirectory(options.valueDirectory);
20
+ }
21
+
22
+ rebuild(id, live, options = {}) {
23
+ const old = this.rows.get(id);
24
+ if (old) this.valueDirectory.releaseAll(old.refs);
25
+ this.rows.delete(id);
26
+
27
+ if (!live.length) return;
28
+ const refs = [];
29
+ const value = {};
30
+ const storeOptions = { trusted: options.trusted === true };
31
+ for (const [key, item] of live) value[key] = this.valueDirectory.store(item, refs, key, storeOptions);
32
+ this.rows.set(id, { value, refs });
33
+ }
34
+
35
+ removeObject(id) {
36
+ const old = this.rows.get(id);
37
+ if (!old) return;
38
+ this.valueDirectory.releaseAll(old.refs);
39
+ this.rows.delete(id);
40
+ }
41
+
42
+ clear() {
43
+ this.rows.clear();
44
+ this.valueDirectory.clear();
45
+ }
46
+
47
+ read(id) {
48
+ const row = this.rows.get(id);
49
+ return row ? cloneValue(row.value) : undefined;
50
+ }
51
+
52
+ readForQuery(id) {
53
+ return this.rows.get(id)?.value;
54
+ }
55
+
56
+ bytes() {
57
+ let total = 0;
58
+ for (const row of this.rows.values()) total += estimateValue(row.value);
59
+ return total + this.valueDirectory.bytes();
60
+ }
61
+
62
+ shapeStats() {
63
+ return [{
64
+ engine: "object",
65
+ rows: this.rows.size,
66
+ bytes: this.bytes(),
67
+ valueDirectory: this.valueDirectory.stats(),
68
+ }];
69
+ }
70
+ }
71
+
72
+ class ValueDirectory {
73
+ constructor(options = {}) {
74
+ const config = { ...DEFAULT_VALUE_DIRECTORY, ...(options || {}) };
75
+ this.enabled = config.enabled !== false;
76
+ this.minRefs = positiveInt(config.minRefs, DEFAULT_VALUE_DIRECTORY.minRefs);
77
+ this.maxEntries = positiveInt(config.maxEntries, DEFAULT_VALUE_DIRECTORY.maxEntries);
78
+ this.maxCandidates = positiveInt(config.maxCandidates, DEFAULT_VALUE_DIRECTORY.maxCandidates);
79
+ this.maxCandidatesPerPath = positiveInt(config.maxCandidatesPerPath, DEFAULT_VALUE_DIRECTORY.maxCandidatesPerPath);
80
+ this.maxPaths = positiveInt(config.maxPaths, DEFAULT_VALUE_DIRECTORY.maxPaths);
81
+ this.maxStringLength = positiveInt(config.maxStringLength, DEFAULT_VALUE_DIRECTORY.maxStringLength);
82
+ this.maxKeyBytes = positiveInt(config.maxKeyBytes, DEFAULT_VALUE_DIRECTORY.maxKeyBytes);
83
+ this.poolStructuredValues = config.poolStructuredValues === true;
84
+ this.maxNestedContainerEntries = positiveInt(config.maxNestedContainerEntries, DEFAULT_VALUE_DIRECTORY.maxNestedContainerEntries);
85
+ this.pool = new Map();
86
+ this.pathCandidates = new Map();
87
+ this.candidateCount = 0;
88
+ }
89
+
90
+ store(value, refs, path = "$", options = {}) {
91
+ if (!this.enabled) return cloneValue(value, options);
92
+
93
+ const key = this.keyFor(value);
94
+ if (key) {
95
+ const pooled = this.pool.get(key);
96
+ if (pooled) {
97
+ pooled.refs++;
98
+ refs.push(key);
99
+ return pooled.value;
100
+ }
101
+ if (this.shouldPromote(path, key)) {
102
+ const stored = deepFreeze(cloneValue(value));
103
+ this.pool.set(key, {
104
+ value: stored,
105
+ refs: 1,
106
+ bytes: estimateValue(stored),
107
+ });
108
+ this.deleteCandidate(path, key);
109
+ refs.push(key);
110
+ return stored;
111
+ }
112
+ }
113
+
114
+ return this.cloneWithNestedPooling(value, refs, path, options);
115
+ }
116
+
117
+ cloneWithNestedPooling(value, refs, path, options = {}) {
118
+ if (options.trusted && !this.poolStructuredValues) return value;
119
+ if (Array.isArray(value)) {
120
+ if (value.length > this.maxNestedContainerEntries) return cloneValue(value, options);
121
+ return value.map((item) => this.store(item, refs, `${path}[]`, options));
122
+ }
123
+ if (isPlainObject(value)) {
124
+ const entries = Object.entries(value);
125
+ if (entries.length > this.maxNestedContainerEntries) return cloneValue(value, options);
126
+ return Object.fromEntries(
127
+ entries.map(([key, item]) => [key, this.store(item, refs, `${path}.${key}`, options)]),
128
+ );
129
+ }
130
+ return cloneValue(value, options);
131
+ }
132
+
133
+ releaseAll(refs) {
134
+ for (const key of refs || []) this.release(key);
135
+ }
136
+
137
+ release(key) {
138
+ const pooled = this.pool.get(key);
139
+ if (!pooled) return;
140
+ pooled.refs--;
141
+ if (pooled.refs <= 0) this.pool.delete(key);
142
+ }
143
+
144
+ shouldPromote(path, key) {
145
+ if (this.pool.size >= this.maxEntries) return false;
146
+ const candidates = this.candidatesForPath(path);
147
+ if (!candidates) return false;
148
+ const count = (candidates.get(key) || 0) + 1;
149
+ if (count >= this.minRefs) return true;
150
+ this.rememberCandidate(candidates, key, count);
151
+ return false;
152
+ }
153
+
154
+ candidatesForPath(path) {
155
+ const normalized = path || "$";
156
+ let candidates = this.pathCandidates.get(normalized);
157
+ if (candidates) return candidates;
158
+ if (this.pathCandidates.size >= this.maxPaths) return null;
159
+ candidates = new Map();
160
+ this.pathCandidates.set(normalized, candidates);
161
+ return candidates;
162
+ }
163
+
164
+ rememberCandidate(candidates, key, count) {
165
+ if (this.maxCandidates <= 0 || this.maxCandidatesPerPath <= 0) return;
166
+ if (candidates.has(key)) {
167
+ candidates.set(key, count);
168
+ return;
169
+ }
170
+ while (this.candidateCount >= this.maxCandidates && this.dropOldestCandidate()) {}
171
+ if (this.candidateCount >= this.maxCandidates) return;
172
+ if (candidates.size >= this.maxCandidatesPerPath) {
173
+ const oldest = candidates.keys().next().value;
174
+ if (oldest !== undefined) {
175
+ candidates.delete(oldest);
176
+ this.candidateCount--;
177
+ }
178
+ }
179
+ if (this.candidateCount < this.maxCandidates && candidates.size < this.maxCandidatesPerPath) {
180
+ candidates.set(key, count);
181
+ this.candidateCount++;
182
+ }
183
+ }
184
+
185
+ deleteCandidate(path, key) {
186
+ const candidates = this.pathCandidates.get(path || "$");
187
+ if (!candidates?.delete(key)) return;
188
+ this.candidateCount--;
189
+ }
190
+
191
+ dropOldestCandidate() {
192
+ for (const candidates of this.pathCandidates.values()) {
193
+ const oldest = candidates.keys().next().value;
194
+ if (oldest === undefined) continue;
195
+ candidates.delete(oldest);
196
+ this.candidateCount--;
197
+ return true;
198
+ }
199
+ return false;
200
+ }
201
+
202
+ clear() {
203
+ this.pool.clear();
204
+ this.pathCandidates.clear();
205
+ this.candidateCount = 0;
206
+ }
207
+
208
+ keyFor(value) {
209
+ if (typeof value === "string") {
210
+ if (value.length > this.maxStringLength) return null;
211
+ return `string:${value}`;
212
+ }
213
+ if (!this.poolStructuredValues) return null;
214
+ if (Array.isArray(value) || isPlainObject(value)) {
215
+ const key = stableKey(value);
216
+ if (!key || key.length > this.maxKeyBytes) return null;
217
+ return `${Array.isArray(value) ? "array" : "object"}:${key}`;
218
+ }
219
+ return null;
220
+ }
221
+
222
+ bytes() {
223
+ let total = 0;
224
+ for (const [key, entry] of this.pool) total += key.length * 2 + entry.bytes;
225
+ for (const candidates of this.pathCandidates.values()) {
226
+ for (const key of candidates.keys()) total += key.length * 2 + 8;
227
+ }
228
+ return total;
229
+ }
230
+
231
+ stats() {
232
+ let refs = 0;
233
+ for (const entry of this.pool.values()) refs += entry.refs;
234
+ return {
235
+ enabled: this.enabled,
236
+ entries: this.pool.size,
237
+ refs,
238
+ candidates: this.candidateCount,
239
+ candidatePaths: this.pathCandidates.size,
240
+ bytes: this.bytes(),
241
+ minRefs: this.minRefs,
242
+ maxEntries: this.maxEntries,
243
+ maxCandidates: this.maxCandidates,
244
+ maxCandidatesPerPath: this.maxCandidatesPerPath,
245
+ maxPaths: this.maxPaths,
246
+ maxStringLength: this.maxStringLength,
247
+ maxKeyBytes: this.maxKeyBytes,
248
+ poolStructuredValues: this.poolStructuredValues,
249
+ maxNestedContainerEntries: this.maxNestedContainerEntries,
250
+ };
251
+ }
252
+ }
253
+
254
+ function cloneValue(value, options = {}) {
255
+ if (value === null || typeof value !== "object") return value;
256
+ if (options.trusted) return value;
257
+ return structuredClone(value);
258
+ }
259
+
260
+ function deepFreeze(value) {
261
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
262
+ Object.freeze(value);
263
+ for (const item of Object.values(value)) deepFreeze(item);
264
+ return value;
265
+ }
266
+
267
+ function estimateValue(value) {
268
+ if (value === null || value === undefined) return 0;
269
+ if (typeof value === "number") return 8;
270
+ if (typeof value === "boolean") return 1;
271
+ if (typeof value === "string") return value.length * 2;
272
+ if (Array.isArray(value)) return 24 + value.reduce((sum, item) => sum + 8 + estimateValue(item), 0);
273
+ if (typeof value === "object") {
274
+ return 32 + Object.entries(value).reduce((sum, [key, item]) => {
275
+ return sum + key.length * 2 + 8 + estimateValue(item);
276
+ }, 0);
277
+ }
278
+ return 0;
279
+ }
280
+
281
+ function isPlainObject(value) {
282
+ return value !== null && typeof value === "object" && !Array.isArray(value);
283
+ }
284
+
285
+ function positiveInt(value, fallback) {
286
+ const n = Number(value);
287
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
288
+ }
package/src/patch.js ADDED
@@ -0,0 +1,87 @@
1
+ const PROTECTED_ROOTS = new Set(["_id"]);
2
+
3
+ export function applyPatchToObject(current, patch) {
4
+ if (!isPlainObject(patch)) throw new TypeError("patch body must be a plain object");
5
+ assertSupportedPatchKeys(patch);
6
+
7
+ const next = clonePatchValue(current || {});
8
+ delete next._id;
9
+
10
+ const set = patch.$set ?? {};
11
+ if (!isPlainObject(set)) throw new TypeError("$set must be a plain object when provided");
12
+ for (const [path, value] of Object.entries(set)) {
13
+ setPath(next, path, clonePatchValue(value), `$set.${path}`);
14
+ }
15
+
16
+ for (const path of unsetPaths(patch.$unset)) {
17
+ unsetPath(next, path, "$unset");
18
+ }
19
+
20
+ return next;
21
+ }
22
+
23
+ export function patchUnsetsPath(patch, path) {
24
+ if (!path || !isPlainObject(patch)) return false;
25
+ return unsetPaths(patch.$unset).includes(path);
26
+ }
27
+
28
+ function assertSupportedPatchKeys(patch) {
29
+ for (const key of Object.keys(patch)) {
30
+ if (key !== "$set" && key !== "$unset") {
31
+ throw new TypeError(`unsupported patch key "${key}"`);
32
+ }
33
+ }
34
+ }
35
+
36
+ function unsetPaths(value) {
37
+ if (value === undefined) return [];
38
+ if (Array.isArray(value)) return value;
39
+ if (isPlainObject(value)) return Object.keys(value);
40
+ throw new TypeError("$unset must be an array of dot-notation paths or an object when provided");
41
+ }
42
+
43
+ function setPath(target, path, value, field) {
44
+ const parts = assertPatchPath(path, field);
45
+ let cursor = target;
46
+ for (let i = 0; i < parts.length - 1; i++) {
47
+ const part = parts[i];
48
+ const next = cursor[part];
49
+ if (!isPlainObject(next)) cursor[part] = {};
50
+ cursor = cursor[part];
51
+ }
52
+ cursor[parts[parts.length - 1]] = value;
53
+ }
54
+
55
+ function unsetPath(target, path, field) {
56
+ const parts = assertPatchPath(path, field);
57
+ let cursor = target;
58
+ for (let i = 0; i < parts.length - 1; i++) {
59
+ cursor = cursor[parts[i]];
60
+ if (!isPlainObject(cursor)) return;
61
+ }
62
+ delete cursor[parts[parts.length - 1]];
63
+ }
64
+
65
+ function assertPatchPath(path, field) {
66
+ if (typeof path !== "string" || path.length === 0) {
67
+ throw new TypeError(`${field} must be a non-empty dot-notation path`);
68
+ }
69
+ const parts = path.split(".");
70
+ if (parts.some((part) => part.length === 0)) {
71
+ throw new TypeError(`${field} must be a valid dot-notation path`);
72
+ }
73
+ if (PROTECTED_ROOTS.has(parts[0])) {
74
+ throw new TypeError(`${parts[0]} cannot be changed`);
75
+ }
76
+ return parts;
77
+ }
78
+
79
+ function clonePatchValue(value) {
80
+ if (!value || typeof value !== "object") return value;
81
+ if (Array.isArray(value)) return value.map(clonePatchValue);
82
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, clonePatchValue(child)]));
83
+ }
84
+
85
+ function isPlainObject(value) {
86
+ return value !== null && typeof value === "object" && !Array.isArray(value);
87
+ }
@@ -0,0 +1,178 @@
1
+ import { matchesQuery } from "./query.js";
2
+
3
+ const OP_CODES = {
4
+ create: "c",
5
+ read: "r",
6
+ update: "u",
7
+ delete: "d",
8
+ };
9
+
10
+ const VALID_PERMS = new Set(["c", "r", "u", "d"]);
11
+ const QUERY_CONTROLS = new Set([
12
+ "$aggregate",
13
+ "$cursor",
14
+ "$limit",
15
+ "$offset",
16
+ "$page",
17
+ "$pageSize",
18
+ "$refDepth",
19
+ "$skip",
20
+ "$sort",
21
+ "$vector",
22
+ ]);
23
+
24
+ export function normalizePermissions(permissions) {
25
+ if (permissions === undefined || permissions === null) return [];
26
+ if (!Array.isArray(permissions)) throw permissionConfigError("permissions must be an array");
27
+
28
+ return permissions.map((rule, index) => normalizeRule(rule, index)).filter(Boolean);
29
+ }
30
+
31
+ export function normalizeCollectionPermissions(permissions) {
32
+ if (permissions === undefined) return { read: [], write: [] };
33
+ if (!isPlainObject(permissions)) throw permissionConfigError("collectionPermissions must be an object");
34
+ for (const key of Object.keys(permissions)) {
35
+ if (key !== "read" && key !== "write") {
36
+ throw permissionConfigError(`collectionPermissions.${key} is not supported`);
37
+ }
38
+ }
39
+ const normalizeNames = (key) => {
40
+ const names = permissions[key] === undefined ? [] : permissions[key];
41
+ if (!Array.isArray(names) || names.some((name) => typeof name !== "string" || !name.trim())) {
42
+ throw permissionConfigError(`collectionPermissions.${key} must be a list of collection names`);
43
+ }
44
+ return [...new Set(names)];
45
+ };
46
+ return { read: normalizeNames("read"), write: normalizeNames("write") };
47
+ }
48
+
49
+ export function mergeReadPermissionQuery(criteria = {}, user = null, collection = null) {
50
+ if (hasCollectionPermission(user, "read", collection)) return { denied: false, query: criteria || {} };
51
+ const permissions = readRules(user);
52
+ if (permissions.some(isAllowAllRule)) return { denied: false, query: criteria || {} };
53
+ if (!permissions.length) return { denied: true, query: null };
54
+
55
+ const authQuery = permissions.length === 1
56
+ ? cloneValue(permissions[0].query || {})
57
+ : { $or: permissions.map((rule) => cloneValue(rule.query || {})) };
58
+ if (!criteria || Object.keys(criteria).length === 0) return { denied: false, query: authQuery };
59
+ return {
60
+ denied: false,
61
+ query: { $and: [criteria, authQuery] },
62
+ };
63
+ }
64
+
65
+ export function canCreate(user, object, collection = null) {
66
+ return can(user, "create", object, collection);
67
+ }
68
+
69
+ export function canRead(user, object, collection = null) {
70
+ return can(user, "read", object, collection);
71
+ }
72
+
73
+ export function canUpdate(user, before, after, collection = null) {
74
+ return can(user, "update", before, collection) && can(user, "update", after, collection);
75
+ }
76
+
77
+ export function canDelete(user, object, collection = null) {
78
+ return can(user, "delete", object, collection);
79
+ }
80
+
81
+ export function can(user, operation, object, collection = null) {
82
+ const code = OP_CODES[operation] || operation;
83
+ const collectionAction = code === "r" ? "read" : ["c", "u", "d"].includes(code) ? "write" : null;
84
+ if (collectionAction && hasCollectionPermission(user, collectionAction, collection)) {
85
+ return true;
86
+ }
87
+ return rulesFor(user).some((rule) => permits(rule, code) && matchesPermissionQuery(rule, object));
88
+ }
89
+
90
+ export function isPermissionBypass(userOrClaims) {
91
+ return Boolean(
92
+ userOrClaims?.admin === true ||
93
+ (userOrClaims?.globalUser === true && userOrClaims?.role === "admin"),
94
+ );
95
+ }
96
+
97
+ export function permissionError(operation, id = null) {
98
+ const err = new Error(`Permission denied: ${operation}${id ? ` ${id}` : ""}`);
99
+ err.status = 403;
100
+ err.code = "E_PERMISSION_DENIED";
101
+ return err;
102
+ }
103
+
104
+ function readRules(user) {
105
+ return rulesFor(user).filter((rule) => permits(rule, OP_CODES.read));
106
+ }
107
+
108
+ function hasCollectionPermission(user, operation, collection) {
109
+ if (!user || isPermissionBypass(user)) return true;
110
+ if (typeof collection !== "string" || !collection) return false;
111
+ const names = user.collectionPermissions?.[operation];
112
+ return Array.isArray(names) && names.includes(collection);
113
+ }
114
+
115
+ function rulesFor(user) {
116
+ if (!user || isPermissionBypass(user)) return [{ query: {}, perm: "*" }];
117
+ try {
118
+ return normalizePermissions(user.permissions);
119
+ } catch {
120
+ return [];
121
+ }
122
+ }
123
+
124
+ function normalizeRule(rule, index) {
125
+ if (!isPlainObject(rule)) throw permissionConfigError(`permissions[${index}] must be an object`);
126
+ const perm = normalizePerm(rule.perm);
127
+ if (!perm) return null;
128
+ const query = rule.query === undefined ? {} : rule.query;
129
+ if (!isPlainObject(query)) throw permissionConfigError(`permissions[${index}].query must be an object`);
130
+ for (const key of Object.keys(query)) {
131
+ if (QUERY_CONTROLS.has(key)) throw permissionConfigError(`permissions[${index}].query must not include ${key}`);
132
+ }
133
+
134
+ return {
135
+ ...(typeof rule.name === "string" && rule.name.length > 0 ? { name: rule.name } : {}),
136
+ query: cloneValue(query),
137
+ perm,
138
+ };
139
+ }
140
+
141
+ function normalizePerm(value) {
142
+ const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
143
+ if (raw === "*") return "*";
144
+ const chars = [];
145
+ for (const ch of raw) {
146
+ if (!VALID_PERMS.has(ch) || chars.includes(ch)) continue;
147
+ chars.push(ch);
148
+ }
149
+ return chars.join("");
150
+ }
151
+
152
+ function permits(rule, code) {
153
+ return rule.perm === "*" || (typeof rule.perm === "string" && rule.perm.includes(code));
154
+ }
155
+
156
+ function matchesPermissionQuery(rule, object) {
157
+ if (isAllowAllRule(rule)) return true;
158
+ return Boolean(object) && matchesQuery(object, rule.query || {});
159
+ }
160
+
161
+ function isAllowAllRule(rule) {
162
+ return rule?.perm === "*" && Object.keys(rule.query || {}).length === 0;
163
+ }
164
+
165
+ function permissionConfigError(message) {
166
+ const err = new TypeError(message);
167
+ err.status = 400;
168
+ return err;
169
+ }
170
+
171
+ function cloneValue(value) {
172
+ if (value === null || typeof value !== "object") return value;
173
+ return structuredClone(value);
174
+ }
175
+
176
+ function isPlainObject(value) {
177
+ return value !== null && typeof value === "object" && !Array.isArray(value);
178
+ }