@driftengine/entities 3.61.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/store.ts ADDED
@@ -0,0 +1,556 @@
1
+ /**
2
+ * A component type, and the sparse set that holds one.
3
+ *
4
+ * **Sparse sets rather than archetypes, and the reason is churn.** An archetype table iterates a
5
+ * multi-component query faster because the components are already adjacent — but adding or removing
6
+ * one component moves the entity between tables, which copies every component it has. A game that
7
+ * adds and removes a marker component per frame pays that copy per frame, and a marker is exactly
8
+ * the pattern queries are built for. Here, add and remove are constant time and move nothing else.
9
+ *
10
+ * The cost is that a multi-component query walks one dense array and tests membership in the others,
11
+ * so the data is not adjacent. **That is to be measured rather than assumed**, and the measurement
12
+ * is a deliverable of this track — in a language where every object field is already an indirection
13
+ * the gap is not the one a systems language would see.
14
+ *
15
+ * ---
16
+ *
17
+ * ## The schema is DriftScript's, and there is no second description
18
+ *
19
+ * A component's fields, their types and their **stable ids** are a `Schema` — the same structure
20
+ * Track N's Phase 5 built for state migration. Storage layout reads it, serialization reads its
21
+ * ids, loading a scene across a field change goes through `migrate` unchanged, and an inspector
22
+ * will read it when Track K exists. Four readers, one description, nothing to keep in sync.
23
+ *
24
+ * A field id is `(declaring module, declaring record, field name)` and never a position, so a field
25
+ * inserted in the middle moves nothing and a scene saved before it loads afterwards.
26
+ *
27
+ * ## Numeric fields get typed arrays; everything else gets a plain one
28
+ *
29
+ * `AGENTS.md`'s bulk-data rule where it belongs. **What it gives up** is that a component is not a
30
+ * value a caller can hold — reading one means reading its fields — which is deliberate, because a
31
+ * component object handed out is an object allocated per read, sixty times a second.
32
+ */
33
+ import type { Schema } from 'driftscript';
34
+ import { type Entity, entityIndex } from './entity.ts';
35
+ import { type ComponentView, type ViewColumn, refreshView } from './view.ts';
36
+
37
+ /**
38
+ * The presence column's name for an optional field.
39
+ *
40
+ * A suffix rather than a parallel map, because `grow`, `remove` and the view all already walk
41
+ * `this.columns` — and a second map would be one more thing each of them has to remember. What it
42
+ * gives up is a field literally named `of$present`, which the language refuses at declaration and
43
+ * which `$` makes unwritable in a `.drs` file anyway. **What would make it wrong** is a component
44
+ * that is mostly optional, where a bitset over all of its optional fields beats a byte each — a
45
+ * change to how presence is stored, not to what an option is, so it moves nothing a save holds.
46
+ */
47
+ export const presenceColumn = (field: string): string => `${field}$present`;
48
+
49
+ /**
50
+ * `option:f64` → `f64`, and `undefined` for a type that is not an option.
51
+ *
52
+ * A prefix rather than a structured type because a schema's field type is a *key* — one string
53
+ * compared for equality by a migration — and parsing it here is cheaper than carrying a second
54
+ * representation that could disagree with it.
55
+ */
56
+ export function optionInner(type: string): string | undefined {
57
+ return type.startsWith('option:') ? type.slice('option:'.length) : undefined;
58
+ }
59
+
60
+ /**
61
+ * Which typed array carries which field type.
62
+ *
63
+ * `i64` and `u64` are `Float64Array` rather than `BigInt64Array` because the language they come
64
+ * from represents them as JavaScript numbers — exact to 2^53 — and a `BigInt` column would be a
65
+ * different representation on this side of a boundary that is supposed to be one.
66
+ *
67
+ * **That was inferred here before DriftScript said it, and now it is the language's own rule.**
68
+ * 1.10.0 defines the domain of `i64` and `u64` as `±(2^53 - 1)` and `0 … 2^53 - 1`, refuses a
69
+ * literal outside it, and refuses wrapping arithmetic on either width because the true result is
70
+ * rounded before anything can reduce it. So this column is not a compromise the engine chose; it is
71
+ * the width the language guarantees, and the two agree by contract rather than by coincidence.
72
+ *
73
+ * `Entity` is here too, and `Float64Array` is what makes a handle survive: a handle spends the
74
+ * whole 53-bit budget, so anything narrower silently truncates the generation, which is the failure
75
+ * the handle's width exists to prevent.
76
+ */
77
+ const COLUMNS: Readonly<Record<string, new (length: number) => ArrayLike<number>>> = {
78
+ f32: Float32Array,
79
+ f64: Float64Array,
80
+ i8: Int8Array,
81
+ i16: Int16Array,
82
+ i32: Int32Array,
83
+ i64: Float64Array,
84
+ u8: Uint8Array,
85
+ u16: Uint16Array,
86
+ u32: Uint32Array,
87
+ u64: Float64Array,
88
+ bool: Uint8Array,
89
+ Entity: Float64Array,
90
+ };
91
+
92
+ /** Field types held in a plain array because they are not numbers. */
93
+ const BOXED: ReadonlySet<string> = new Set(['String']);
94
+
95
+ /**
96
+ * Whether a field type is a fieldless enum, which the language writes as `enum:<Name>`.
97
+ *
98
+ * **A discriminant is an integer and always was**, so this is a column rather than a boxed value —
99
+ * which is the whole of why it can be added without deciding anything about layout.
100
+ *
101
+ * **The gap it closes is between a language feature and its usable shape.** DriftScript has `enum`,
102
+ * and `match` over one refuses to compile until every variant is handled; that exhaustiveness is
103
+ * the language's stated advantage over a Lua version which would silently do nothing for a state
104
+ * nobody wrote a branch for. But a state machine's state has to survive between ticks, which means
105
+ * a *component* field, and there was no column for one — so the language's own corpus keeps its
106
+ * `Alertness` in a `data` record, which is not what a system iterates, and a consumer shipped
107
+ * `mood: i32` with the three values written out in a comment. The feature and the place it is
108
+ * needed could not meet.
109
+ */
110
+ function enumName(type: string): string | undefined {
111
+ return type.startsWith('enum:') ? type.slice('enum:'.length) : undefined;
112
+ }
113
+
114
+ /**
115
+ * The column a discriminant goes in: `Int32Array`, and the width is the decision.
116
+ *
117
+ * **A byte would hold every enum anybody is likely to write and is not what this uses**, because
118
+ * the failure mode of guessing too small is silent. `defineComponent` is handed `enum:Mood` and a
119
+ * name is all it gets — the variant list belongs to the module that declared it, which this package
120
+ * does not read — so it cannot know whether 256 is enough. Four bytes a field costs three more than
121
+ * a byte for a small enum and cannot truncate a large one.
122
+ *
123
+ * **What would make it wrong** is a consumer measuring enum columns as a real share of their
124
+ * component memory, at which point the width belongs to whoever declares the enum and can count its
125
+ * variants, and this stops choosing.
126
+ */
127
+ const ENUM_COLUMN = Int32Array;
128
+
129
+ export interface ComponentType {
130
+ readonly name: string;
131
+ readonly schema: Schema;
132
+ /**
133
+ * A small dense integer, for indexing by component type in a query or a schedule.
134
+ *
135
+ * Allocated in declaration order at module load, so it is the same on every run of the same
136
+ * code — and it is deliberately **not** what a scene is written with. A scene uses field ids,
137
+ * which survive a reorder; this survives nothing and is never stored.
138
+ */
139
+ readonly id: number;
140
+ /** Fields typed `Entity`, which a scene load has to rewrite through its id map. */
141
+ readonly entityFields: readonly string[];
142
+ }
143
+
144
+ let nextComponentId = 0;
145
+
146
+ export function defineComponent(schema: Schema): ComponentType;
147
+ export function defineComponent(
148
+ name: string,
149
+ fields: Readonly<Record<string, string>>,
150
+ ): ComponentType;
151
+ /**
152
+ * Declare a component type, from a schema or from a name and a field table.
153
+ *
154
+ * **The schema overload is what a language-declared component uses**, and it exists because a field
155
+ * id is `(declaring module, declaring record, field name)`. A `.drs` file that declares `Health` is
156
+ * the declaring module; building the id here would file every language-declared component under
157
+ * `entities` and lose the half that makes a save survive a rename. The record overload goes on
158
+ * building `entities::<Name>::<field>`, because for a component declared in TypeScript this package
159
+ * genuinely *is* where the shape is declared.
160
+ *
161
+ * The ids are still never hand-written in the record overload, for the reason that has always been
162
+ * here: a hand-written id is a chance to write two the same, which `driftscript` refuses at
163
+ * `DS0284` for a record and which nothing would catch on this side.
164
+ *
165
+ * **What this costs** is two entry points to one concept. **What would make it wrong** is a third
166
+ * caller wanting a different id scheme, at which point the id belongs entirely to whoever declares
167
+ * the field and this function stops building one at all.
168
+ */
169
+ export function defineComponent(
170
+ first: Schema | string,
171
+ fields?: Readonly<Record<string, string>>,
172
+ ): ComponentType {
173
+ const schema: Schema =
174
+ typeof first === 'string'
175
+ ? {
176
+ name: first,
177
+ fields: Object.entries(fields ?? {}).map(([field, type]) => ({
178
+ id: `entities::${first}::${field}`,
179
+ name: field,
180
+ type,
181
+ })),
182
+ }
183
+ : first;
184
+
185
+ for (const field of schema.fields) {
186
+ const inner = optionInner(field.type) ?? field.type;
187
+ if (COLUMNS[inner] === undefined && !BOXED.has(inner) && enumName(inner) === undefined) {
188
+ throw new Error(
189
+ `\`${schema.name}.${field.name}\` is declared \`${field.type}\`, which has no column. The ` +
190
+ `types with one are ${[...Object.keys(COLUMNS), ...BOXED].join(', ')}, a fieldless ` +
191
+ '`enum:<Name>`, and an option of any of them. A type that has to be boxed is a decision ' +
192
+ 'about layout, so it is added here rather than assumed.',
193
+ );
194
+ }
195
+ }
196
+
197
+ return {
198
+ name: schema.name,
199
+ id: nextComponentId++,
200
+ schema,
201
+ /* An optional handle is still a handle, so a scene load has to rewrite it through the id map
202
+ exactly as it rewrites a required one. Leaving it out would serialise a handle into a world
203
+ whose indices mean something else. */
204
+ entityFields: schema.fields
205
+ .filter((f) => f.type === 'Entity' || f.type === 'option:Entity')
206
+ .map((f) => f.name),
207
+ };
208
+ }
209
+
210
+ /**
211
+ * One store's contents, for a rewind. Reused between saves; see `AllocatorSnapshot`.
212
+ *
213
+ * **`sparse` is absent and that is a decision.** It is derivable from `dense` — position *i* holds
214
+ * the entity whose sparse entry is *i* — and its length grows with the highest entity index the
215
+ * world has ever reached rather than with the live set. A world of forty entities that once held
216
+ * forty thousand would copy 160 KB per store per tick to save an array it can rebuild in forty
217
+ * writes. `loadFrom` rebuilds it.
218
+ */
219
+ export interface StoreSnapshot {
220
+ count: number;
221
+ dense: Float64Array;
222
+ /** Field name to its column's contents, mirroring `columns`. Grown to match, never shrunk. */
223
+ columns: Map<string, NumericColumn | unknown[]>;
224
+ }
225
+
226
+ export function createStoreSnapshot(): StoreSnapshot {
227
+ return { count: 0, dense: new Float64Array(0), columns: new Map() };
228
+ }
229
+
230
+ export interface ComponentStoreOptions {
231
+ /** Initial column length. Present for the growth test; a consumer has no reason to set it. */
232
+ readonly capacity?: number;
233
+ }
234
+
235
+ /** A mutable numeric column, which the readonly `ArrayLike` in `COLUMNS` does not describe. */
236
+ type NumericColumn =
237
+ | Float32Array
238
+ | Float64Array
239
+ | Int8Array
240
+ | Int16Array
241
+ | Int32Array
242
+ | Uint8Array
243
+ | Uint16Array
244
+ | Uint32Array;
245
+
246
+ export class ComponentStore {
247
+ readonly type: ComponentType;
248
+
249
+ /**
250
+ * The entities that have this component, in insertion order modified by swap-removal.
251
+ *
252
+ * **Deterministic given the same history, and not a stable property of the set** — two worlds
253
+ * holding the same entities in a different removal order iterate differently. That is what replay
254
+ * needs and is not what a consumer wanting a stable order gets; they sort by entity, and the fact
255
+ * that it is a choice is why this says so.
256
+ */
257
+ private denseArray: Float64Array;
258
+ /** Entity index → position in `dense`, or -1. */
259
+ private sparse: Int32Array;
260
+ private readonly columns = new Map<string, NumericColumn | unknown[]>();
261
+ private count = 0;
262
+ /**
263
+ * The live view of these columns, made on first ask.
264
+ *
265
+ * Lazy rather than built in the constructor, because a store that nobody indexes directly should
266
+ * not carry an object per field name it will never hand out.
267
+ */
268
+ private viewObject: ComponentView | null = null;
269
+
270
+ constructor(type: ComponentType, options: ComponentStoreOptions = {}) {
271
+ this.type = type;
272
+ const capacity = Math.max(1, options.capacity ?? 16);
273
+ this.denseArray = new Float64Array(capacity);
274
+ this.sparse = new Int32Array(64).fill(-1);
275
+ for (const field of type.schema.fields) {
276
+ const inner = optionInner(field.type);
277
+ const declared = inner ?? field.type;
278
+ /* A discriminant is an integer, so it takes an integer column rather than being boxed. */
279
+ const Column = enumName(declared) === undefined ? COLUMNS[declared] : ENUM_COLUMN;
280
+ this.columns.set(
281
+ field.name,
282
+ Column === undefined
283
+ ? new Array<unknown>(capacity)
284
+ : (new Column(capacity) as NumericColumn),
285
+ );
286
+ /* An option keeps its inner type's column and gains a byte per entity saying whether the
287
+ value in it means anything. Without the second column, an optional `Entity` would have to
288
+ live in a boxed array so `null` could stand for absence — which is the whole bulk-data
289
+ rule given up for one bit. */
290
+ if (inner !== undefined)
291
+ this.columns.set(presenceColumn(field.name), new Uint8Array(capacity));
292
+ }
293
+ }
294
+
295
+ get size(): number {
296
+ return this.count;
297
+ }
298
+
299
+ /** The entities this store holds, oldest first. Valid up to `size`; the rest is capacity. */
300
+ get dense(): Float64Array {
301
+ return this.denseArray;
302
+ }
303
+
304
+ /**
305
+ * The live columns of this store, addressable by field name.
306
+ *
307
+ * The same object every call, refreshed after every reallocation — see `view.ts` for why that is
308
+ * the contract rather than a convenience.
309
+ */
310
+ view(): ComponentView {
311
+ if (this.viewObject === null) this.viewObject = { sparse: this.sparse } as ComponentView;
312
+ refreshView(this.viewObject, this.sparse, this.columns as ReadonlyMap<string, ViewColumn>);
313
+ return this.viewObject;
314
+ }
315
+
316
+ /** Point the view at the arrays that exist now. Called after either kind of growth. */
317
+ private refresh(): void {
318
+ if (this.viewObject === null) return;
319
+ refreshView(this.viewObject, this.sparse, this.columns as ReadonlyMap<string, ViewColumn>);
320
+ }
321
+
322
+ /** One field's storage, for a caller iterating it directly rather than through `read`. */
323
+ column(field: string): NumericColumn | unknown[] {
324
+ const found = this.columns.get(field);
325
+ if (found === undefined) throw new Error(this.noField(field));
326
+ return found;
327
+ }
328
+
329
+ has(entity: Entity): boolean {
330
+ const at = this.positionOf(entity);
331
+ return at >= 0;
332
+ }
333
+
334
+ add(entity: Entity, values: Readonly<Record<string, unknown>> = {}): void {
335
+ if (this.has(entity)) {
336
+ for (const [field, value] of Object.entries(values)) this.write(entity, field, value);
337
+ return;
338
+ }
339
+
340
+ const at = this.count;
341
+ this.grow(at + 1);
342
+ this.growSparse(entityIndex(entity) + 1);
343
+ this.denseArray[at] = entity;
344
+ this.sparse[entityIndex(entity)] = at;
345
+ this.count += 1;
346
+
347
+ /*
348
+ * Every field written, including the ones the caller omitted.
349
+ *
350
+ * A column is reused storage — the slot may hold whatever the entity that was there last left
351
+ * — so an omitted field would silently inherit a stranger's value. Zeroing is what makes
352
+ * `add(e, { x: 5 })` mean what it reads like.
353
+ */
354
+ for (const field of this.type.schema.fields) {
355
+ const column = this.columns.get(field.name);
356
+ if (column === undefined) continue;
357
+ const given = values[field.name];
358
+ if (Array.isArray(column)) column[at] = given ?? null;
359
+ else (column as NumericColumn)[at] = typeof given === 'number' ? given : Number(given ?? 0);
360
+ /*
361
+ * Presence is written here rather than left at whatever the slot held.
362
+ *
363
+ * This loop walks the *schema's* fields and the presence columns are not among them, so a
364
+ * reused slot would otherwise inherit the presence of whichever entity was there last —
365
+ * an optional field that reads as set, holding a number the caller never wrote.
366
+ */
367
+ const present = this.columns.get(presenceColumn(field.name));
368
+ if (present !== undefined) (present as NumericColumn)[at] = given === undefined ? 0 : 1;
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Drop an entity's component. `false` when it never had one, which is not an error.
374
+ *
375
+ * The last entry is swapped into the hole and **its** sparse entry is fixed — the fix-up being
376
+ * the whole of what makes a constant-time removal correct.
377
+ *
378
+ * **The order of the two sparse writes is load-bearing**, and it is the only thing that is. When
379
+ * the entry being removed *is* the last, the swap is with itself and `moved` is `entity`: writing
380
+ * the position first and then the -1 leaves it absent, which is right; the other way round leaves
381
+ * it pointing into the space past `size`.
382
+ *
383
+ * There was a `position < size` guard in `positionOf` as well, which made the order not matter —
384
+ * and made a perturbation of the order pass every test. Two defences for one case is one defence
385
+ * and one comment that cannot be checked, so the guard is gone and the order is what the test
386
+ * for removing the last entry actually holds.
387
+ */
388
+ remove(entity: Entity): boolean {
389
+ const at = this.positionOf(entity);
390
+ if (at < 0) return false;
391
+
392
+ const last = this.count - 1;
393
+ const moved = this.denseArray[last] as number;
394
+ this.denseArray[at] = moved;
395
+ for (const column of this.columns.values()) {
396
+ if (Array.isArray(column)) column[at] = column[last];
397
+ else (column as NumericColumn)[at] = (column as NumericColumn)[last] as number;
398
+ }
399
+ this.sparse[entityIndex(moved)] = at;
400
+ this.sparse[entityIndex(entity)] = -1;
401
+ this.count -= 1;
402
+ return true;
403
+ }
404
+
405
+ read(entity: Entity, field: string): unknown {
406
+ const column = this.columns.get(field);
407
+ if (column === undefined) throw new Error(this.noField(field));
408
+ const at = this.positionOf(entity);
409
+ if (at < 0) return undefined;
410
+ const present = this.columns.get(presenceColumn(field));
411
+ if (present !== undefined && (present as NumericColumn)[at] === 0) return undefined;
412
+ return column[at];
413
+ }
414
+
415
+ write(entity: Entity, field: string, value: unknown): void {
416
+ const column = this.columns.get(field);
417
+ if (column === undefined) throw new Error(this.noField(field));
418
+ const at = this.positionOf(entity);
419
+ if (at < 0) return;
420
+ /* `undefined` is how an optional field is cleared. On a non-optional field there is no presence
421
+ column and the value goes through the same coercion it always did. */
422
+ const present = this.columns.get(presenceColumn(field));
423
+ if (present !== undefined) (present as NumericColumn)[at] = value === undefined ? 0 : 1;
424
+ if (Array.isArray(column)) column[at] = value;
425
+ else (column as NumericColumn)[at] = typeof value === 'number' ? value : Number(value ?? 0);
426
+ }
427
+
428
+ /**
429
+ * Where an entity sits, or -1.
430
+ *
431
+ * **The dense array holds the whole handle**, so the generation is compared for free: a stale
432
+ * handle whose slot is live finds a different number and is refused. A sparse array alone could
433
+ * only compare indices, which is the silent-reuse failure one level down.
434
+ */
435
+ private positionOf(entity: Entity): number {
436
+ const index = entityIndex(entity);
437
+ if (index >= this.sparse.length) return -1;
438
+ const at = this.sparse[index] as number;
439
+ if (at < 0) return -1;
440
+ return this.denseArray[at] === entity ? at : -1;
441
+ }
442
+
443
+ /**
444
+ * Copy this store's contents into a slot, growing the slot to match if it is short.
445
+ *
446
+ * **`count` bounds every copy, not capacity.** A store that once held ten thousand entities and
447
+ * now holds four has ten thousand slots of stale numbers behind the live four, and copying them
448
+ * would make a snapshot's cost depend on the world's history instead of its size.
449
+ *
450
+ * A boxed column copies references. That is correct rather than a shortcut: the one boxed field
451
+ * type is `String`, and a string cannot be mutated behind the snapshot's back.
452
+ */
453
+ saveInto(slot: StoreSnapshot): void {
454
+ const count = this.count;
455
+ if (slot.dense.length < count) slot.dense = new Float64Array(this.denseArray.length);
456
+ for (let i = 0; i < count; i++) slot.dense[i] = this.denseArray[i] as number;
457
+
458
+ for (const [field, column] of this.columns) {
459
+ let into = slot.columns.get(field);
460
+ if (into === undefined || into.length < count) {
461
+ into = Array.isArray(column)
462
+ ? new Array<unknown>(column.length)
463
+ : new (column.constructor as new (n: number) => NumericColumn)(column.length);
464
+ slot.columns.set(field, into);
465
+ }
466
+ if (Array.isArray(column)) {
467
+ const target = into as unknown[];
468
+ for (let i = 0; i < count; i++) target[i] = column[i];
469
+ } else {
470
+ const source = column as NumericColumn;
471
+ const target = into as NumericColumn;
472
+ for (let i = 0; i < count; i++) target[i] = source[i] as number;
473
+ }
474
+ }
475
+
476
+ slot.count = count;
477
+ }
478
+
479
+ /**
480
+ * Put this store back to a saved slot, rebuilding `sparse` from the restored `dense`.
481
+ *
482
+ * **The order of the two sparse passes is load-bearing.** The entries this store's *current* live
483
+ * set occupies are cleared first, while `dense` still says which they are; overwriting `dense`
484
+ * first loses that record and leaves stale positions behind, and a stale position is worse than a
485
+ * missing one because `positionOf` would find a live slot holding a different handle.
486
+ */
487
+ loadFrom(slot: StoreSnapshot): void {
488
+ for (let at = 0; at < this.count; at++) {
489
+ this.sparse[entityIndex(this.denseArray[at] as number)] = -1;
490
+ }
491
+
492
+ /* Before the columns are read out of the map: `grow` replaces every array in it. */
493
+ this.grow(Math.max(1, slot.count));
494
+
495
+ for (let i = 0; i < slot.count; i++) this.denseArray[i] = slot.dense[i] as number;
496
+
497
+ for (const [field, column] of this.columns) {
498
+ const from = slot.columns.get(field);
499
+ if (from === undefined) continue;
500
+ if (Array.isArray(column)) {
501
+ const source = from as unknown[];
502
+ for (let i = 0; i < slot.count; i++) column[i] = source[i];
503
+ } else {
504
+ const source = from as NumericColumn;
505
+ const target = column as NumericColumn;
506
+ for (let i = 0; i < slot.count; i++) target[i] = source[i] as number;
507
+ }
508
+ }
509
+
510
+ this.count = slot.count;
511
+
512
+ for (let at = 0; at < slot.count; at++) {
513
+ const index = entityIndex(this.denseArray[at] as number);
514
+ this.growSparse(index + 1);
515
+ this.sparse[index] = at;
516
+ }
517
+ }
518
+
519
+ private noField(field: string): string {
520
+ return (
521
+ `\`${this.type.name}\` has no field \`${field}\`. It has ` +
522
+ `${this.type.schema.fields.map((f) => `\`${f.name}\``).join(', ')}.`
523
+ );
524
+ }
525
+
526
+ private grow(needed: number): void {
527
+ if (needed <= this.denseArray.length) return;
528
+ let size = this.denseArray.length;
529
+ while (size < needed) size *= 2;
530
+
531
+ const dense = new Float64Array(size);
532
+ dense.set(this.denseArray);
533
+ this.denseArray = dense;
534
+
535
+ for (const [field, column] of this.columns) {
536
+ if (Array.isArray(column)) {
537
+ column.length = size;
538
+ continue;
539
+ }
540
+ const grown = new (column.constructor as new (n: number) => NumericColumn)(size);
541
+ grown.set(column as never);
542
+ this.columns.set(field, grown);
543
+ }
544
+ this.refresh();
545
+ }
546
+
547
+ private growSparse(needed: number): void {
548
+ if (needed <= this.sparse.length) return;
549
+ let size = this.sparse.length;
550
+ while (size < needed) size *= 2;
551
+ const sparse = new Int32Array(size).fill(-1);
552
+ sparse.set(this.sparse);
553
+ this.sparse = sparse;
554
+ this.refresh();
555
+ }
556
+ }