utilium 3.6.1 → 3.7.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/dist/array.d.ts CHANGED
@@ -117,4 +117,5 @@ export type FromKeyed<A extends any[], KeyName extends (A extends (infer E)[] ?
117
117
  [_ in KeyName & PropertyKey]: K;
118
118
  }>;
119
119
  } : never;
120
+ export declare function chunkArray<const T>(array: readonly T[], chunkSize: number): T[][];
120
121
  export {};
package/dist/array.js CHANGED
@@ -1 +1,8 @@
1
- export {};
1
+ export function chunkArray(array, chunkSize) {
2
+ if (!Number.isSafeInteger(chunkSize) || chunkSize <= 0)
3
+ throw new Error('chunkArray: chunk size must be a positive integer');
4
+ const chunks = [];
5
+ for (let i = 0; i < array.length; i += chunkSize)
6
+ chunks.push(array.slice(i, i + chunkSize));
7
+ return chunks;
8
+ }
package/dist/cache.d.ts CHANGED
@@ -1,3 +1,7 @@
1
+ /**
2
+ * A ranged cache
3
+ * Copyright (c) 2025 James Prevett
4
+ */
1
5
  export interface Options {
2
6
  /**
3
7
  * If true, use multiple buffers to cache a file.
@@ -17,11 +21,20 @@ export interface Options {
17
21
  * @default false
18
22
  */
19
23
  cacheOnly?: boolean;
24
+ /**
25
+ * Multiplier applied to a region's capacity when it has to grow.
26
+ * Values outside `[1, 100]` fall back to the default.
27
+ * Higher trades memory for fewer reallocations, while `1` allocates exactly what is needed and makes repeated appends quadratic.
28
+ * @default 2
29
+ */
30
+ growFactor?: number;
20
31
  }
21
32
  export type Range = {
22
33
  start: number;
23
34
  end: number;
24
35
  };
36
+ /** Insert `[start, end)` into a sorted, non-overlapping list of ranges, merging it with any ranges it touches. */
37
+ export declare function addRange(ranges: Range[], start: number, end: number): void;
25
38
  export interface Region {
26
39
  /** The region's offset from the start of the resource */
27
40
  offset: number;
@@ -47,8 +60,20 @@ export declare class Resource<ID> {
47
60
  constructor(
48
61
  /** The resource ID */
49
62
  id: ID, _size: number, options: Options, resources?: Map<ID, Resource<ID> | undefined>);
63
+ /**
64
+ * Resize `region.data` to `length` bytes.
65
+ * Capacity can be over-allocated geometrically so appending to a region repeatedly stays amortized O(1).
66
+ */
67
+ protected resizeRegion(region: Region, length: number): void;
68
+ /** Merge the region after `index` into the one at `index`, if the gap between them is small enough */
69
+ protected mergeAt(index: number): boolean;
50
70
  /** Combines adjacent regions and combines adjacent ranges within a region */
51
71
  collect(): void;
72
+ /**
73
+ * Combine the region at `index` with the neighbors it now reaches.
74
+ * Only that region can have changed, so this does not re-walk the whole list the way `collect` does.
75
+ */
76
+ protected collectAt(index: number): void;
52
77
  /** Takes an initial range and finds the sub-ranges that are not in the cache */
53
78
  missing(start: number, end: number): Range[];
54
79
  /**
@@ -56,8 +81,12 @@ export declare class Resource<ID> {
56
81
  * This is conceptually the inverse of `missing`.
57
82
  */
58
83
  cached(start: number, end: number): Range[];
84
+ /** Index of the first region positioned after `offset`. Regions are kept sorted, so this is a binary search. */
85
+ protected indexAfter(offset: number): number;
59
86
  /** Get the region who's ranges include an offset */
60
87
  regionAt(offset: number): Region | undefined;
88
+ /** The regions overlapping `[start, end)`, in order. Seeks to the first one rather than scanning from the start. */
89
+ regionsIn(start: number, end: number): Generator<Region>;
61
90
  /** Add new data to the cache at given specified offset */
62
91
  add(data: Uint8Array, offset: number): this;
63
92
  }
package/dist/cache.js CHANGED
@@ -1,9 +1,27 @@
1
- // SPDX-License-Identifier: LGPL-3.0-or-later
2
- /**
3
- * A ranged cache
4
- * Copyright (c) 2025 James Prevett
5
- */
6
- import { extendBuffer } from './buffer.js';
1
+ /** Insert `[start, end)` into a sorted, non-overlapping list of ranges, merging it with any ranges it touches. */
2
+ export function addRange(ranges, start, end) {
3
+ let i = ranges.length;
4
+ while (i > 0 && ranges[i - 1].start > start)
5
+ i--;
6
+ const prev = ranges[i - 1];
7
+ if (prev && prev.end >= start) {
8
+ if (prev.end >= end)
9
+ return;
10
+ prev.end = end;
11
+ }
12
+ else {
13
+ ranges.splice(i, 0, { start, end });
14
+ i++;
15
+ }
16
+ const merged = ranges[i - 1];
17
+ let j = i;
18
+ while (j < ranges.length && ranges[j].start <= merged.end) {
19
+ merged.end = Math.max(merged.end, ranges[j].end);
20
+ j++;
21
+ }
22
+ if (j > i)
23
+ ranges.splice(i, j - i);
24
+ }
7
25
  /**
8
26
  * The cache for a specific resource
9
27
  * @internal
@@ -51,50 +69,71 @@ export class Resource {
51
69
  this._size = _size;
52
70
  this.options = options;
53
71
  options.sparse ??= true;
72
+ if (!options.growFactor
73
+ || !Number.isFinite(options.growFactor)
74
+ || options.growFactor < 1
75
+ || options.growFactor > 100)
76
+ options.growFactor = 2;
54
77
  if (!options.sparse)
55
78
  this.regions.push({ offset: 0, data: new Uint8Array(_size), ranges: [] });
56
79
  resources?.set(id, this);
57
80
  }
81
+ /**
82
+ * Resize `region.data` to `length` bytes.
83
+ * Capacity can be over-allocated geometrically so appending to a region repeatedly stays amortized O(1).
84
+ */
85
+ resizeRegion(region, length) {
86
+ const { buffer, byteOffset } = region.data;
87
+ if (buffer.byteLength - byteOffset >= length) {
88
+ region.data = new Uint8Array(buffer, byteOffset, length);
89
+ return;
90
+ }
91
+ const grown = new Uint8Array(Math.max(length, (buffer.byteLength - byteOffset) * this.options.growFactor));
92
+ grown.set(region.data);
93
+ region.data = grown.subarray(0, length);
94
+ }
95
+ /** Merge the region after `index` into the one at `index`, if the gap between them is small enough */
96
+ mergeAt(index) {
97
+ const current = this.regions[index];
98
+ const next = this.regions[index + 1];
99
+ if (!current || !next)
100
+ return false;
101
+ const { regionGapThreshold = 0xfff } = this.options;
102
+ if (next.offset - (current.offset + current.data.byteLength) > regionGapThreshold)
103
+ return false;
104
+ for (const range of next.ranges)
105
+ addRange(current.ranges, range.start, range.end);
106
+ const length = next.offset + next.data.byteLength - current.offset;
107
+ if (length > current.data.byteLength)
108
+ this.resizeRegion(current, length);
109
+ current.data.set(next.data, next.offset - current.offset);
110
+ this.regions.splice(index + 1, 1);
111
+ return true;
112
+ }
58
113
  /** Combines adjacent regions and combines adjacent ranges within a region */
59
114
  collect() {
60
115
  if (!this.options.sparse)
61
116
  return;
62
- const { regionGapThreshold = 0xfff } = this.options;
63
- for (let i = 0; i < this.regions.length - 1;) {
64
- const current = this.regions[i];
65
- const next = this.regions[i + 1];
66
- if (next.offset - (current.offset + current.data.byteLength) > regionGapThreshold) {
117
+ for (let i = 0; i < this.regions.length - 1;)
118
+ if (!this.mergeAt(i))
67
119
  i++;
68
- continue;
69
- }
70
- // Combine ranges
71
- current.ranges.push(...next.ranges);
72
- current.ranges.sort((a, b) => a.start - b.start);
73
- // Combine overlapping/adjacent ranges
74
- current.ranges = current.ranges.reduce((acc, range) => {
75
- if (!acc.length || acc.at(-1).end < range.start) {
76
- acc.push(range);
77
- }
78
- else {
79
- acc.at(-1).end = Math.max(acc.at(-1).end, range.end);
80
- }
81
- return acc;
82
- }, []);
83
- // Extend buffer to include the new region. `current.data` starts at
84
- // `current.offset`, so its length is measured from there (the `.set()`
85
- // destination is already relative to `current.offset`).
86
- current.data = extendBuffer(current.data, next.offset + next.data.byteLength - current.offset);
87
- current.data.set(next.data, next.offset - current.offset);
88
- // Remove the next region after merging
89
- this.regions.splice(i + 1, 1);
90
- }
120
+ }
121
+ /**
122
+ * Combine the region at `index` with the neighbors it now reaches.
123
+ * Only that region can have changed, so this does not re-walk the whole list the way `collect` does.
124
+ */
125
+ collectAt(index) {
126
+ if (!this.options.sparse)
127
+ return;
128
+ if (index > 0 && this.mergeAt(index - 1))
129
+ index--;
130
+ while (this.mergeAt(index))
131
+ ;
91
132
  }
92
133
  /** Takes an initial range and finds the sub-ranges that are not in the cache */
93
134
  missing(start, end) {
94
135
  const missingRanges = [];
95
- for (const region of this.regions) {
96
- if (region.offset >= end)
97
- break;
136
+ for (const region of this.regionsIn(start, end)) {
98
137
  for (const range of region.ranges) {
99
138
  if (range.end <= start)
100
139
  continue;
@@ -123,9 +162,7 @@ export class Resource {
123
162
  */
124
163
  cached(start, end) {
125
164
  const cachedRanges = [];
126
- for (const region of this.regions) {
127
- if (region.offset >= end)
128
- break;
165
+ for (const region of this.regionsIn(start, end)) {
129
166
  for (const range of region.ranges) {
130
167
  if (range.end <= start)
131
168
  continue;
@@ -150,43 +187,52 @@ export class Resource {
150
187
  }
151
188
  return merged;
152
189
  }
190
+ /** Index of the first region positioned after `offset`. Regions are kept sorted, so this is a binary search. */
191
+ indexAfter(offset) {
192
+ let low = 0, high = this.regions.length;
193
+ while (low < high) {
194
+ const mid = (low + high) >> 1;
195
+ if (this.regions[mid].offset > offset)
196
+ high = mid;
197
+ else
198
+ low = mid + 1;
199
+ }
200
+ return low;
201
+ }
153
202
  /** Get the region who's ranges include an offset */
154
203
  regionAt(offset) {
155
- if (!this.regions.length)
156
- return;
157
- for (const region of this.regions) {
158
- if (region.offset > offset)
159
- break;
160
- // Check if the offset is within this region
161
- if (offset >= region.offset && offset < region.offset + region.data.byteLength)
162
- return region;
204
+ const region = this.regions[this.indexAfter(offset) - 1];
205
+ if (region && offset < region.offset + region.data.byteLength)
206
+ return region;
207
+ }
208
+ /** The regions overlapping `[start, end)`, in order. Seeks to the first one rather than scanning from the start. */
209
+ *regionsIn(start, end) {
210
+ for (let i = Math.max(0, this.indexAfter(start) - 1); i < this.regions.length; i++) {
211
+ const region = this.regions[i];
212
+ if (region.offset >= end)
213
+ return;
214
+ if (region.offset + region.data.byteLength > start)
215
+ yield region;
163
216
  }
164
217
  }
165
218
  /** Add new data to the cache at given specified offset */
166
219
  add(data, offset) {
167
220
  const end = offset + data.byteLength;
168
- const region = this.regionAt(offset);
169
- if (region) {
221
+ const index = this.indexAfter(offset) - 1;
222
+ const region = this.regions[index];
223
+ if (region && offset < region.offset + region.data.byteLength) {
170
224
  // `region.data` holds bytes starting at `region.offset`, so positions
171
225
  // within it are relative to `region.offset` (not absolute resource offsets).
172
- region.data = extendBuffer(region.data, end - region.offset);
226
+ if (end - region.offset > region.data.byteLength)
227
+ this.resizeRegion(region, end - region.offset);
173
228
  region.data.set(data, offset - region.offset);
174
- region.ranges.push({ start: offset, end });
175
- region.ranges.sort((a, b) => a.start - b.start);
176
- this.collect();
229
+ addRange(region.ranges, offset, end);
230
+ this.collectAt(index);
177
231
  return this;
178
232
  }
179
- // Find the correct index to insert the new region
180
- const newRegion = { data, offset: offset, ranges: [{ start: offset, end }] };
181
- const insertIndex = this.regions.findIndex(region => region.offset > offset);
182
233
  // Insert at the right index to keep regions sorted
183
- if (insertIndex == -1) {
184
- this.regions.push(newRegion); // Append if no later region exists
185
- }
186
- else {
187
- this.regions.splice(insertIndex, 0, newRegion); // Insert before the first region with a greater offset
188
- }
189
- this.collect();
234
+ this.regions.splice(index + 1, 0, { data, offset, ranges: [{ start: offset, end }] });
235
+ this.collectAt(index + 1);
190
236
  return this;
191
237
  }
192
238
  }
package/dist/objects.d.ts CHANGED
@@ -22,7 +22,18 @@ export declare function isObject(value: unknown): value is object;
22
22
  export type DeepAssign<To extends object, From extends object> = {
23
23
  [K in keyof To | keyof From]: K extends keyof To ? K extends keyof From ? To[K] extends object ? From[K] extends object ? Expand<DeepAssign<To[K], From[K]>> : never : From[K] extends object ? never : From[K] : To[K] : From[K & keyof From];
24
24
  };
25
- export declare function deepAssign<To extends object, From extends object>(to: To, from: From, treatArraysAsPrimitives?: boolean): DeepAssign<To, From>;
25
+ export interface DeepAssignOptions {
26
+ /** Whether to replace arrays instead of merging them */
27
+ replaceArrays?: boolean;
28
+ /** Whether to allow unsafe override of the `constructor` property */
29
+ unsafeOverrideConstructor?: boolean;
30
+ }
31
+ /**
32
+ * @deprecated
33
+ * Use an options object instead, see {@link DeepAssignOptions}
34
+ */
35
+ export declare function deepAssign<To extends object, From extends object>(to: To, from: From, treatArraysAsPrimitives: boolean): DeepAssign<To, From>;
36
+ export declare function deepAssign<To extends object, From extends object>(to: To, from: From, options?: DeepAssignOptions): DeepAssign<To, From>;
26
37
  export type StructurallyEqual<To, From> = diff.DeepRT<To, From>['result'] extends 'equal' ? true : false;
27
38
  export declare function structurallyEqual<To, From>(to: To, from: From): StructurallyEqual<To, From>;
28
39
  /**
package/dist/objects.js CHANGED
@@ -37,26 +37,22 @@ export function assignWithDefaults(to, from, defaults = to) {
37
37
  export function isObject(value) {
38
38
  return Object(value) === value;
39
39
  }
40
- export function deepAssign(to, from, treatArraysAsPrimitives = false) {
41
- const keys = new Set([
42
- ...Object.keys(to),
43
- ...Object.keys(from),
44
- ]);
45
- for (const key of keys) {
46
- if (!(key in from))
40
+ export function deepAssign(to, from, options = {}) {
41
+ options = typeof options == 'boolean' ? { replaceArrays: options } : options;
42
+ for (const [key, value] of Object.entries(from)) {
43
+ if (key === '__proto__' || (!options.unsafeOverrideConstructor && key === 'constructor'))
47
44
  continue;
48
- const value = from[key];
49
45
  if (!(key in to)) {
50
46
  to[key] = value;
51
47
  continue;
52
48
  }
53
49
  if ((!isObject(to[key]) && Object(value) !== value)
54
- || (treatArraysAsPrimitives && (Array.isArray(value) || Array.isArray(to[key])))) {
50
+ || (options.replaceArrays && (Array.isArray(value) || Array.isArray(to[key])))) {
55
51
  to[key] = value;
56
52
  continue;
57
53
  }
58
54
  if (isObject(to[key]) && Object(value) === value) {
59
- deepAssign(to[key], value, treatArraysAsPrimitives);
55
+ deepAssign(to[key], value, options);
60
56
  continue;
61
57
  }
62
58
  throw new TypeError(!isObject(to[key])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "utilium",
3
- "version": "3.6.1",
3
+ "version": "3.7.0",
4
4
  "description": "Typescript utilities",
5
5
  "funding": {
6
6
  "type": "individual",