ponder 0.18.0 → 0.18.2

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 (46) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/esm/indexing-store/index.js +8 -4
  3. package/dist/esm/indexing-store/index.js.map +1 -1
  4. package/dist/esm/indexing-store/utils.js +3 -1
  5. package/dist/esm/indexing-store/utils.js.map +1 -1
  6. package/dist/esm/rpc/actions.js +2 -16
  7. package/dist/esm/rpc/actions.js.map +1 -1
  8. package/dist/esm/runtime/filter.js +80 -32
  9. package/dist/esm/runtime/filter.js.map +1 -1
  10. package/dist/esm/sync-historical/in-memory.js +1 -2
  11. package/dist/esm/sync-historical/in-memory.js.map +1 -1
  12. package/dist/esm/sync-historical/index.js +1 -2
  13. package/dist/esm/sync-historical/index.js.map +1 -1
  14. package/dist/esm/sync-realtime/bloom.js +72 -64
  15. package/dist/esm/sync-realtime/bloom.js.map +1 -1
  16. package/dist/esm/sync-realtime/index.js +79 -50
  17. package/dist/esm/sync-realtime/index.js.map +1 -1
  18. package/dist/esm/utils/copy.js +77 -56
  19. package/dist/esm/utils/copy.js.map +1 -1
  20. package/dist/esm/utils/decodeEventLog.js +20 -6
  21. package/dist/esm/utils/decodeEventLog.js.map +1 -1
  22. package/dist/types/indexing-store/index.d.ts.map +1 -1
  23. package/dist/types/indexing-store/utils.d.ts.map +1 -1
  24. package/dist/types/rpc/actions.d.ts +2 -3
  25. package/dist/types/rpc/actions.d.ts.map +1 -1
  26. package/dist/types/runtime/filter.d.ts +24 -1
  27. package/dist/types/runtime/filter.d.ts.map +1 -1
  28. package/dist/types/sync-historical/in-memory.d.ts.map +1 -1
  29. package/dist/types/sync-historical/index.d.ts.map +1 -1
  30. package/dist/types/sync-realtime/bloom.d.ts +52 -8
  31. package/dist/types/sync-realtime/bloom.d.ts.map +1 -1
  32. package/dist/types/sync-realtime/index.d.ts.map +1 -1
  33. package/dist/types/utils/copy.d.ts +10 -2
  34. package/dist/types/utils/copy.d.ts.map +1 -1
  35. package/dist/types/utils/decodeEventLog.d.ts.map +1 -1
  36. package/package.json +5 -5
  37. package/src/indexing-store/index.ts +8 -4
  38. package/src/indexing-store/utils.ts +3 -1
  39. package/src/rpc/actions.ts +1 -21
  40. package/src/runtime/filter.ts +116 -52
  41. package/src/sync-historical/in-memory.ts +0 -2
  42. package/src/sync-historical/index.ts +0 -2
  43. package/src/sync-realtime/bloom.ts +129 -79
  44. package/src/sync-realtime/index.ts +114 -72
  45. package/src/utils/copy.ts +83 -64
  46. package/src/utils/decodeEventLog.ts +28 -6
@@ -1,102 +1,152 @@
1
- import { type Hex, hexToBytes, hexToNumber, keccak256 } from "viem";
2
- import type { LogFilter, SyncBlock } from "@/internal/types.js";
3
- import {
4
- getFilterFromBlock,
5
- getFilterToBlock,
6
- isAddressFactory,
7
- } from "@/runtime/filter.js";
1
+ import { type Hex, keccak256 } from "viem";
2
+ import type { Factory, LogFilter } from "@/internal/types.js";
3
+ import { isAddressFactory } from "@/runtime/filter.js";
8
4
 
9
5
  export const zeroLogsBloom =
10
6
  "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
11
7
 
12
8
  const BLOOM_SIZE_BYTES = 256;
13
9
 
14
- export const isInBloom = (_bloom: Hex, input: Hex): boolean => {
15
- const bloom = hexToBytes(_bloom);
16
- const hash = hexToBytes(keccak256(input));
17
-
18
- for (const i of [0, 2, 4]) {
19
- const bit = (hash[i + 1]! + (hash[i]! << 8)) & 0x7ff;
20
- if (
21
- (bloom[BLOOM_SIZE_BYTES - 1 - Math.floor(bit / 8)]! &
22
- (1 << (bit % 8))) ===
23
- 0
24
- )
25
- return false;
10
+ export type BloomBits = {
11
+ byte0: number;
12
+ mask0: number;
13
+ byte1: number;
14
+ mask1: number;
15
+ byte2: number;
16
+ mask2: number;
17
+ };
18
+
19
+ /**
20
+ * A `LogFilter` with each address and topic converted to `BloomBits`.
21
+ *
22
+ * `undefined` matches every bloom. An array matches if any item is in the bloom.
23
+ */
24
+ export type LogFilterBloom = {
25
+ address: BloomBits[] | undefined;
26
+ topics: (BloomBits[] | undefined)[];
27
+ };
28
+
29
+ export const getBloomBits = (input: Hex): BloomBits => {
30
+ const hash = keccak256(input, "bytes");
31
+ const bytes = [0, 0, 0];
32
+ const masks = [0, 0, 0];
33
+
34
+ for (let i = 0; i < 3; i++) {
35
+ const bit = (hash[i * 2 + 1]! + (hash[i * 2]! << 8)) & 0x7ff;
36
+ bytes[i] = BLOOM_SIZE_BYTES - 1 - Math.floor(bit / 8);
37
+ masks[i] = 1 << (bit % 8);
26
38
  }
27
39
 
28
- return true;
40
+ return {
41
+ byte0: bytes[0]!,
42
+ mask0: masks[0]!,
43
+ byte1: bytes[1]!,
44
+ mask1: masks[1]!,
45
+ byte2: bytes[2]!,
46
+ mask2: masks[2]!,
47
+ };
48
+ };
49
+
50
+ export const isBloomBitsInBloom = (
51
+ bloom: Uint8Array,
52
+ bits: BloomBits,
53
+ ): boolean =>
54
+ (bloom[bits.byte0]! & bits.mask0) !== 0 &&
55
+ (bloom[bits.byte1]! & bits.mask1) !== 0 &&
56
+ (bloom[bits.byte2]! & bits.mask2) !== 0;
57
+
58
+ export const isInBloom = (bloom: Uint8Array, input: Hex): boolean =>
59
+ isBloomBitsInBloom(bloom, getBloomBits(input));
60
+
61
+ const isAnyInBloom = (
62
+ bloom: Uint8Array,
63
+ bits: BloomBits[] | undefined,
64
+ ): boolean =>
65
+ bits === undefined || bits.some((b) => isBloomBitsInBloom(bloom, b));
66
+
67
+ const toBloomBits = (
68
+ input: Hex | Hex[] | null | undefined,
69
+ ): BloomBits[] | undefined => {
70
+ if (input === null || input === undefined) return undefined;
71
+ if (Array.isArray(input)) return input.map(getBloomBits);
72
+ return [getBloomBits(input)];
73
+ };
74
+
75
+ /**
76
+ * Convert `filter` to a `LogFilterBloom`. Call once for each filter,
77
+ * so that the inputs are not hashed again for each block.
78
+ */
79
+ export const getLogFilterBloom = (filter: LogFilter): LogFilterBloom => {
80
+ let address: BloomBits[] | undefined;
81
+
82
+ if (isAddressFactory(filter.address)) {
83
+ address = undefined;
84
+ } else if (Array.isArray(filter.address) && filter.address.length === 0) {
85
+ address = undefined;
86
+ } else {
87
+ address = toBloomBits(filter.address);
88
+ }
89
+
90
+ return {
91
+ address,
92
+ topics: [filter.topic0, filter.topic1, filter.topic2, filter.topic3].map(
93
+ toBloomBits,
94
+ ),
95
+ };
29
96
  };
30
97
 
31
98
  /**
32
99
  * Return true if `filter` is in `bloom`.
33
100
  *
34
- * A filter with an address of type `LogFactory` is matched
35
- * if the address filter is matched (new child contract) or the log
36
- * filter is matched (log on child contract).
101
+ * A filter with an address of type `LogFactory` matches all addresses.
102
+ * Use `isFactoryInBloom` to check for new child contracts.
37
103
  *
38
104
  * Note: False positives are possible.
39
105
  */
40
106
  export function isFilterInBloom({
41
- block,
107
+ bloom,
42
108
  filter,
43
109
  }: {
44
- block: Pick<SyncBlock, "number" | "logsBloom">;
45
- filter: LogFilter;
110
+ bloom: Uint8Array;
111
+ filter: LogFilterBloom;
46
112
  }): boolean {
47
- // Return `false` for out of range blocks
48
- if (
49
- hexToNumber(block.number) < getFilterFromBlock(filter) ||
50
- hexToNumber(block.number) > getFilterToBlock(filter)
51
- ) {
52
- return false;
53
- }
113
+ return (
114
+ isAnyInBloom(bloom, filter.address) &&
115
+ filter.topics.every((topic) => isAnyInBloom(bloom, topic))
116
+ );
117
+ }
54
118
 
55
- const isTopicsInBloom = [
56
- filter.topic0,
57
- filter.topic1,
58
- filter.topic2,
59
- filter.topic3,
60
- ].every((topic) => {
61
- if (topic === null || topic === undefined) {
62
- return true;
63
- } else if (Array.isArray(topic)) {
64
- return topic.some((t) => isInBloom(block.logsBloom, t));
65
- } else {
66
- return isInBloom(block.logsBloom, topic);
67
- }
68
- });
69
-
70
- let isAddressInBloom: boolean;
71
-
72
- if (filter.address === undefined) isAddressInBloom = true;
73
- else if (isAddressFactory(filter.address)) {
74
- // Return true if the `Factory` is matched.
75
- if (
76
- (filter.address.address === undefined ||
77
- (Array.isArray(filter.address.address)
78
- ? filter.address.address.some((address) =>
79
- isInBloom(block.logsBloom, address),
80
- )
81
- : isInBloom(block.logsBloom, filter.address.address))) &&
82
- isInBloom(block.logsBloom, filter.address.eventSelector)
83
- ) {
84
- return true;
85
- }
86
-
87
- isAddressInBloom = true;
88
- } else if (Array.isArray(filter.address)) {
89
- if (filter.address.length === 0) {
90
- isAddressInBloom = true;
91
- } else {
92
- isAddressInBloom = filter.address.some((address) =>
93
- isInBloom(block.logsBloom, address),
94
- );
95
- }
96
- } else {
97
- // single address case
98
- isAddressInBloom = isInBloom(block.logsBloom, filter.address);
99
- }
119
+ /**
120
+ * A `Factory` with its address and event selector converted to `BloomBits`.
121
+ */
122
+ export type FactoryBloom = {
123
+ address: BloomBits[] | undefined;
124
+ eventSelector: BloomBits;
125
+ };
100
126
 
101
- return isAddressInBloom && isTopicsInBloom;
127
+ /**
128
+ * Convert `factory` to a `FactoryBloom`. Call once for each factory,
129
+ * so that the inputs are not hashed again for each block.
130
+ */
131
+ export const getFactoryBloom = (factory: Factory): FactoryBloom => ({
132
+ address: toBloomBits(factory.address),
133
+ eventSelector: getBloomBits(factory.eventSelector),
134
+ });
135
+
136
+ /**
137
+ * Return true if a log that matches `factory` can be in `bloom`.
138
+ *
139
+ * Note: False positives are possible.
140
+ */
141
+ export function isFactoryInBloom({
142
+ bloom,
143
+ factory,
144
+ }: {
145
+ bloom: Uint8Array;
146
+ factory: FactoryBloom;
147
+ }): boolean {
148
+ return (
149
+ isAnyInBloom(bloom, factory.address) &&
150
+ isBloomBitsInBloom(bloom, factory.eventSelector)
151
+ );
102
152
  }
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  type Address,
3
3
  type Hash,
4
+ type Hex,
5
+ hexToBytes,
4
6
  hexToNumber,
5
7
  numberToHex,
6
8
  zeroHash,
@@ -45,11 +47,13 @@ import {
45
47
  isAddressFactory,
46
48
  isAddressMatched,
47
49
  isBlockFilterMatched,
50
+ isBlockInFilter,
48
51
  isLogFactoryMatched,
49
52
  isLogFilterMatched,
50
53
  isTraceFilterMatched,
51
54
  isTransactionFilterMatched,
52
55
  isTransferFilterMatched,
56
+ mergeLogFiltersToBlockRequest,
53
57
  } from "@/runtime/filter.js";
54
58
  import {
55
59
  getFactoryFragmentId,
@@ -60,7 +64,14 @@ import { isAsyncExecutionChain } from "@/utils/finality.js";
60
64
  import { createLock } from "@/utils/mutex.js";
61
65
  import { range } from "@/utils/range.js";
62
66
  import { startClock } from "@/utils/timer.js";
63
- import { isFilterInBloom, isInBloom, zeroLogsBloom } from "./bloom.js";
67
+ import {
68
+ getFactoryBloom,
69
+ getLogFilterBloom,
70
+ isFactoryInBloom,
71
+ isFilterInBloom,
72
+ isInBloom,
73
+ zeroLogsBloom,
74
+ } from "./bloom.js";
64
75
 
65
76
  export type RealtimeSync = {
66
77
  /**
@@ -176,6 +187,9 @@ export const createRealtimeSync = (
176
187
  }
177
188
  }
178
189
 
190
+ const logFilterBlooms = logFilters.map(getLogFilterBloom);
191
+ const factoryBlooms = factories.map(getFactoryBloom);
192
+
179
193
  const getLatestUnfinalizedBlock = () => {
180
194
  if (unfinalizedBlocks.length === 0) {
181
195
  return finalizedBlock;
@@ -211,33 +225,45 @@ export const createRealtimeSync = (
211
225
  // Logs
212
226
  ////////
213
227
 
214
- // "eth_getLogs" calls can be skipped if no filters match `newHeadBlock.logsBloom`.
215
228
  // Async-execution chains cannot reliably use blooms to filter or validate logs.
229
+ // A zero `logsBloom` is treated as missing.
216
230
  const isAsyncExecution = isAsyncExecutionChain(args.chain.id);
217
- const shouldRequestLogs = isAsyncExecution
218
- ? logFilters.length > 0
219
- : maybeBlockHeader.logsBloom === zeroLogsBloom ||
220
- logFilters.some((filter) =>
221
- isFilterInBloom({ block: maybeBlockHeader, filter }),
222
- );
231
+ const blockNumber = hexToNumber(maybeBlockHeader.number);
232
+ const bloom = hexToBytes(maybeBlockHeader.logsBloom);
233
+
234
+ const logsRequestFilter = mergeLogFiltersToBlockRequest({
235
+ logFilters: logFilters.filter(
236
+ (logFilter, i) =>
237
+ isBlockInFilter(logFilter, blockNumber) &&
238
+ (isAsyncExecution ||
239
+ maybeBlockHeader.logsBloom === zeroLogsBloom ||
240
+ isFilterInBloom({ bloom, filter: logFilterBlooms[i]! })),
241
+ ),
242
+ factories: factories.filter(
243
+ (factory, i) =>
244
+ isBlockInFilter(factory, blockNumber) &&
245
+ (isAsyncExecution ||
246
+ maybeBlockHeader.logsBloom === zeroLogsBloom ||
247
+ isFactoryInBloom({ bloom, factory: factoryBlooms[i]! })),
248
+ ),
249
+ });
250
+ const shouldRequestLogs = logsRequestFilter !== undefined;
223
251
 
224
252
  let logs: SyncLog[] = [];
225
253
  if (shouldRequestLogs) {
254
+ const logsRequestParams = {
255
+ blockHash: maybeBlockHeader.hash,
256
+ address: logsRequestFilter.address,
257
+ topics: logsRequestFilter.topics,
258
+ };
259
+
226
260
  if (block === undefined) {
227
261
  [block, logs] = await Promise.all([
228
262
  eth_getBlockByHash(args.rpc, [maybeBlockHeader.hash, true], context),
229
- eth_getLogs(
230
- args.rpc,
231
- [{ blockHash: maybeBlockHeader.hash }],
232
- context,
233
- ),
263
+ eth_getLogs(args.rpc, [logsRequestParams], context),
234
264
  ]);
235
265
  } else {
236
- logs = await eth_getLogs(
237
- args.rpc,
238
- [{ blockHash: block.hash }],
239
- context,
240
- );
266
+ logs = await eth_getLogs(args.rpc, [logsRequestParams], context);
241
267
  }
242
268
 
243
269
  validateLogsAndBlock(
@@ -245,7 +271,7 @@ export const createRealtimeSync = (
245
271
  block,
246
272
  {
247
273
  method: "eth_getLogs",
248
- params: [{ blockHash: block.hash }],
274
+ params: [logsRequestParams],
249
275
  },
250
276
  ethGetBlockMethod === "eth_getBlockByNumber"
251
277
  ? {
@@ -256,60 +282,8 @@ export const createRealtimeSync = (
256
282
  method: "eth_getBlockByHash",
257
283
  params: [block.hash, true],
258
284
  },
259
- isAsyncExecutionChain(args.chain.id),
260
285
  );
261
286
 
262
- if (isAsyncExecutionChain(args.chain.id) === false) {
263
- // Note: Exact `logsBloom` validations were considered too strict to add to `validateLogsAndBlock`.
264
- let isInvalidLogsBloom = false;
265
- for (const log of logs) {
266
- if (isInBloom(block.logsBloom, log.address) === false) {
267
- isInvalidLogsBloom = true;
268
- }
269
-
270
- if (
271
- log.topics[0] &&
272
- isInBloom(block.logsBloom, log.topics[0]) === false
273
- ) {
274
- isInvalidLogsBloom = true;
275
- }
276
-
277
- if (
278
- log.topics[1] &&
279
- isInBloom(block.logsBloom, log.topics[1]) === false
280
- ) {
281
- isInvalidLogsBloom = true;
282
- }
283
-
284
- if (
285
- log.topics[2] &&
286
- isInBloom(block.logsBloom, log.topics[2]) === false
287
- ) {
288
- isInvalidLogsBloom = true;
289
- }
290
-
291
- if (
292
- log.topics[3] &&
293
- isInBloom(block.logsBloom, log.topics[3]) === false
294
- ) {
295
- isInvalidLogsBloom = true;
296
- }
297
-
298
- if (isInvalidLogsBloom) {
299
- args.common.logger.warn({
300
- msg: "Detected inconsistent RPC responses. Log not found in block.logsBloom.",
301
- action: "fetch_block_data",
302
- chain: args.chain.name,
303
- chain_id: args.chain.id,
304
- number: hexToNumber(block.number),
305
- hash: block.hash,
306
- logIndex: hexToNumber(log.logIndex),
307
- });
308
- break;
309
- }
310
- }
311
- }
312
-
313
287
  for (const log of logs) {
314
288
  if (log.transactionHash === zeroHash) {
315
289
  args.common.logger.warn({
@@ -325,7 +299,10 @@ export const createRealtimeSync = (
325
299
  }
326
300
  }
327
301
 
328
- if (shouldRequestLogs === false && logFilters.length > 0) {
302
+ if (
303
+ shouldRequestLogs === false &&
304
+ (logFilters.length > 0 || factories.length > 0)
305
+ ) {
329
306
  args.common.logger.trace({
330
307
  msg: "Skipped eth_getLogs request due to bloom filter result",
331
308
  action: "fetch_block_data",
@@ -387,6 +364,7 @@ export const createRealtimeSync = (
387
364
 
388
365
  // Record `blockChildAddresses` that contain factory child addresses
389
366
  const blockChildAddresses: BlockWithEventData["childAddresses"] = new Map();
367
+ const factoryLogs = new Set<SyncLog>();
390
368
 
391
369
  const childAddressDecodeFailureIds = new Set<string>();
392
370
  let childAddressDecodeFailureCount = 0;
@@ -395,6 +373,7 @@ export const createRealtimeSync = (
395
373
  for (const factory of factories) {
396
374
  for (const log of logs) {
397
375
  if (isLogFactoryMatched({ factory, log })) {
376
+ factoryLogs.add(log);
398
377
  let address: Address;
399
378
  try {
400
379
  address = getChildAddress({ log, factory });
@@ -462,6 +441,69 @@ export const createRealtimeSync = (
462
441
  return isMatched;
463
442
  });
464
443
 
444
+ if (isAsyncExecution === false && block !== undefined) {
445
+ // Note: Exact `logsBloom` validations were considered too strict to add to `validateLogsAndBlock`.
446
+ const logsBloom = hexToBytes(block.logsBloom);
447
+ const checkedInputs = new Set<Hex>();
448
+ let isInvalidLogsBloom = false;
449
+ for (const log of [...factoryLogs, ...logs]) {
450
+ if (
451
+ checkedInputs.has(log.address) === false &&
452
+ isInBloom(logsBloom, log.address) === false
453
+ ) {
454
+ isInvalidLogsBloom = true;
455
+ }
456
+
457
+ if (
458
+ log.topics[0] &&
459
+ checkedInputs.has(log.topics[0]) === false &&
460
+ isInBloom(logsBloom, log.topics[0]) === false
461
+ ) {
462
+ isInvalidLogsBloom = true;
463
+ }
464
+
465
+ if (
466
+ log.topics[1] &&
467
+ checkedInputs.has(log.topics[1]) === false &&
468
+ isInBloom(logsBloom, log.topics[1]) === false
469
+ ) {
470
+ isInvalidLogsBloom = true;
471
+ }
472
+
473
+ if (
474
+ log.topics[2] &&
475
+ checkedInputs.has(log.topics[2]) === false &&
476
+ isInBloom(logsBloom, log.topics[2]) === false
477
+ ) {
478
+ isInvalidLogsBloom = true;
479
+ }
480
+
481
+ if (
482
+ log.topics[3] &&
483
+ checkedInputs.has(log.topics[3]) === false &&
484
+ isInBloom(logsBloom, log.topics[3]) === false
485
+ ) {
486
+ isInvalidLogsBloom = true;
487
+ }
488
+
489
+ if (isInvalidLogsBloom) {
490
+ args.common.logger.warn({
491
+ msg: "Detected inconsistent RPC responses. Log not found in block.logsBloom.",
492
+ action: "fetch_block_data",
493
+ chain: args.chain.name,
494
+ chain_id: args.chain.id,
495
+ number: hexToNumber(block.number),
496
+ hash: block.hash,
497
+ logIndex: hexToNumber(log.logIndex),
498
+ });
499
+ break;
500
+ }
501
+
502
+ checkedInputs.add(log.address);
503
+ for (const topic of log.topics) checkedInputs.add(topic);
504
+ }
505
+ }
506
+
465
507
  // Initial weak trace filtering before full filtering with factory addresses in handleBlock
466
508
  traces = traces.filter((trace) => {
467
509
  let isMatched = false;
package/src/utils/copy.ts CHANGED
@@ -5,75 +5,94 @@ import util from "node:util";
5
5
  */
6
6
  export const COPY_ON_WRITE = Symbol.for("ponder:copyOnWrite");
7
7
 
8
- const copyOnWriteCopies = new WeakMap<object, object>();
8
+ /**
9
+ * Target of a copy-on-write proxy.
10
+ *
11
+ * Note: The handler is shared by all proxies, so the state of each proxy
12
+ * is stored in its target.
13
+ */
14
+ class CopyOnWriteTarget {
15
+ obj: object;
16
+ copiedObject: object | undefined;
17
+
18
+ constructor(obj: object) {
19
+ this.obj = obj;
20
+ this.copiedObject = undefined;
21
+ }
9
22
 
10
- function inspectCopyOnWrite(this: object) {
11
- return copyOnWriteCopies.get(this) ?? (this as any)[COPY_ON_WRITE] ?? this;
23
+ // Note: `this` is the proxy when called by `util.inspect`.
24
+ [util.inspect.custom]() {
25
+ // @ts-expect-error
26
+ return this[COPY_ON_WRITE] ?? this.copiedObject ?? this.obj;
27
+ }
12
28
  }
13
29
 
30
+ const getCopiedObject = (target: CopyOnWriteTarget) => {
31
+ if (target.copiedObject === undefined) {
32
+ target.copiedObject = structuredClone(target.obj);
33
+ }
34
+ return target.copiedObject;
35
+ };
36
+
37
+ const copyOnWriteHandler: ProxyHandler<CopyOnWriteTarget> = {
38
+ get(target, prop, receiver) {
39
+ if (prop === COPY_ON_WRITE) {
40
+ return target.copiedObject ?? target.obj;
41
+ }
42
+ let result = Reflect.get(target.copiedObject ?? target.obj, prop, receiver);
43
+
44
+ if (
45
+ typeof result === "object" &&
46
+ result !== null &&
47
+ target.copiedObject === undefined
48
+ ) {
49
+ result = Reflect.get(getCopiedObject(target), prop, receiver);
50
+ }
51
+
52
+ return result;
53
+ },
54
+ set(target, prop, newValue, receiver) {
55
+ return Reflect.set(getCopiedObject(target), prop, newValue, receiver);
56
+ },
57
+ deleteProperty(target, prop) {
58
+ return Reflect.deleteProperty(getCopiedObject(target), prop);
59
+ },
60
+ defineProperty(target, prop, descriptor) {
61
+ return Reflect.defineProperty(getCopiedObject(target), prop, descriptor);
62
+ },
63
+ ownKeys(target) {
64
+ return Reflect.ownKeys(target.copiedObject ?? target.obj);
65
+ },
66
+ has(target, prop) {
67
+ return Reflect.has(target.copiedObject ?? target.obj, prop);
68
+ },
69
+ getOwnPropertyDescriptor(target, prop) {
70
+ return Reflect.getOwnPropertyDescriptor(
71
+ target.copiedObject ?? target.obj,
72
+ prop,
73
+ );
74
+ },
75
+ getPrototypeOf(target) {
76
+ return Reflect.getPrototypeOf(target.copiedObject ?? target.obj);
77
+ },
78
+ };
79
+
14
80
  /**
15
- * Create a copy-on-write proxy for an object.
81
+ * Create a copy-on-write proxy for a plain object.
82
+ *
83
+ * @dev Arrays and other exotic objects are not supported. The proxy target
84
+ * is a `CopyOnWriteTarget`, not `obj`, so the proxy invariants break for
85
+ * non-configurable properties such as `Array.length`:
86
+ * `Object.keys()` and spread throw a `TypeError`, and `Array.isArray()`
87
+ * returns `false`.
16
88
  */
17
- export const copyOnWrite = <T extends object>(obj: T): T => {
18
- let copiedObject: T | undefined;
19
-
20
- Object.defineProperty(obj, util.inspect.custom, {
21
- value: inspectCopyOnWrite,
22
- configurable: true,
23
- });
24
-
25
- const proxy = new Proxy<T>(obj, {
26
- get(target, prop, receiver) {
27
- if (prop === COPY_ON_WRITE) {
28
- return copiedObject ?? target;
29
- }
30
- let result = Reflect.get(copiedObject ?? target, prop, receiver);
31
-
32
- if (
33
- typeof result === "object" &&
34
- result !== null &&
35
- copiedObject === undefined
36
- ) {
37
- copiedObject = structuredClone(target);
38
- copyOnWriteCopies.set(proxy, copiedObject);
39
- result = Reflect.get(copiedObject, prop, receiver);
40
- }
41
-
42
- return result;
43
- },
44
- set(target, prop, newValue, receiver) {
45
- if (copiedObject === undefined) {
46
- copiedObject = structuredClone(target);
47
- copyOnWriteCopies.set(proxy, copiedObject);
48
- }
49
- return Reflect.set(copiedObject, prop, newValue, receiver);
50
- },
51
- deleteProperty(target, prop) {
52
- if (copiedObject === undefined) {
53
- copiedObject = structuredClone(target);
54
- copyOnWriteCopies.set(proxy, copiedObject);
55
- }
56
- return Reflect.deleteProperty(copiedObject, prop);
57
- },
58
- defineProperty(target, prop, descriptor) {
59
- if (copiedObject === undefined) {
60
- copiedObject = structuredClone(target);
61
- copyOnWriteCopies.set(proxy, copiedObject);
62
- }
63
- return Reflect.defineProperty(copiedObject, prop, descriptor);
64
- },
65
- ownKeys(target) {
66
- return Reflect.ownKeys(copiedObject ?? target);
67
- },
68
- has(target, prop) {
69
- return Reflect.has(copiedObject ?? target, prop);
70
- },
71
- getOwnPropertyDescriptor(target, prop) {
72
- return Reflect.getOwnPropertyDescriptor(copiedObject ?? target, prop);
73
- },
74
- });
75
-
76
- return proxy;
89
+ export const copyOnWrite = <T extends { [key: string]: unknown }>(
90
+ obj: T,
91
+ ): T => {
92
+ return new Proxy(
93
+ new CopyOnWriteTarget(obj),
94
+ copyOnWriteHandler,
95
+ ) as unknown as T;
77
96
  };
78
97
 
79
98
  /**