@ultimat3/entity 1.2.0 → 2.0.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/query.ts CHANGED
@@ -1,12 +1,28 @@
1
1
  // The chainable read. Every chain terminates in a cursor page — `page()` returns rows plus the
2
- // cursor for the next one, and `all()`/`one()` are that page's rows. There is no `offset()` and
3
- // there will not be one: under concurrent writes an insert before the offset shifts every later
4
- // page, so a client silently skips and repeats rows.
2
+ // cursor for the next one, `all()`/`one()` are that page's rows, and `inBatches()` is that page
3
+ // repeated until the cursor runs out. There is no `offset()` and there will not be one: under
4
+ // concurrent writes an insert before the offset shifts every later page, so a client silently
5
+ // skips and repeats rows.
5
6
 
7
+ import { systemClock } from '@ultimat3/core';
8
+ import type { BatchIterator } from './batch';
9
+ import { assertBatchable, batchIterator } from './batch';
6
10
  import type { EntityCore } from './entity';
7
- import type { Page, Repo, RepoOptions } from './repo';
11
+ import { assertPageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
12
+ import type { RelatedTables } from './preload';
13
+ import { preloaded } from './preload';
14
+ import type { Relation } from './relations';
15
+ import { relationNamed } from './relations';
16
+ import type { Page, Repo, RepoOptions, UpsertArgs } from './repo';
8
17
  import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
9
- import type { ColumnMap, Insertable } from './types';
18
+ import type { ColumnMap, IdOf, Insertable } from './types';
19
+
20
+ /**
21
+ * What a preloaded relation adds to a row. `unknown` because the name is a string resolved at
22
+ * runtime against the relation map: the row on the other side is parsed by its own entity, never
23
+ * asserted into shape here.
24
+ */
25
+ export type Preloaded<Name extends string> = { readonly [K in Name]: unknown };
10
26
 
11
27
  export interface ReadBuilder<Row> {
12
28
  /** Equality on the columns given. `where({ orgId })` is what satisfies the tenancy guard. */
@@ -19,30 +35,117 @@ export interface ReadBuilder<Row> {
19
35
  select<K extends keyof Row & string>(
20
36
  fields: { readonly [P in K]: true },
21
37
  ): ReadBuilder<Pick<Row, K>>;
38
+ /**
39
+ * One relation, read for the whole page in one extra `where <key> in (…)` and attached to every
40
+ * row under its own name — the eager form of the batching a point lookup does for itself, and
41
+ * the line an N+1 warning names. The relation is the `references()` already declared, so there
42
+ * is nothing to declare here; a name no foreign key produces is `X_PRELOAD_UNKNOWN_RELATION`,
43
+ * listing the ones that exist.
44
+ *
45
+ * A `belongsTo` attaches the row or `null`, a `hasMany` an array — always present, so "no
46
+ * author" never reads like "nobody preloaded the author". Attached after the projection: a
47
+ * `select()` narrows the columns, never the relations.
48
+ */
49
+ preload<Name extends string>(relation: Name): ReadBuilder<Row & Preloaded<Name>>;
50
+ /**
51
+ * Every row the chain matches, `size` at a time and one statement per batch — the terminal a
52
+ * `for await` consumes instead of holding a whole table in memory. A batch is the page `page()`
53
+ * would have returned at that position, so filters, tenancy, soft delete, the projection and
54
+ * every `preload()` mean here what they mean there, and an empty batch is never yielded.
55
+ *
56
+ * Keyset, never OFFSET: each batch resumes from the cursor the previous one ended on, so a row
57
+ * written mid-iteration cannot make the loop skip or repeat one. `after(cursor)` is where it
58
+ * starts and `.cursor` is where it stopped — persist that and a job resumes the iteration.
59
+ *
60
+ * The loop closes it: `break`, `return` and a throw all stop the next statement, and
61
+ * `await using` does the same for a handle kept in a variable. A chain that cannot carry a
62
+ * cursor — a nullable sort column — and a chain that also called `limit()` are refused here,
63
+ * not one batch later.
64
+ */
65
+ inBatches(size: number): BatchIterator<Row>;
22
66
  /** The terminal: one bounded page and the cursor that continues it. */
23
67
  page(): Promise<Page<Row>>;
24
68
  all(): Promise<readonly Row[]>;
25
69
  one(): Promise<Row | null>;
26
70
  count(): Promise<number>;
71
+ /**
72
+ * The grouped count: one statement, one entry per distinct value of `column`, keyed by that
73
+ * value — the aggregate a `count()` per row is the N+1 of. `recount every post's likes` is one
74
+ * `likes.andWhere('postId', 'in', ids).countBy('postId')`, not one statement per post.
75
+ *
76
+ * Counts the whole predicate, exactly as `count()` does: the chain's filters, its tenancy and
77
+ * its soft-delete visibility, never its page. A value nothing matched is absent rather than `0`,
78
+ * which is what `group by` returns and what lets a caller tell "none" from "never asked".
79
+ * Entries come back biggest group first, ties by the value, `null` — the group every row without
80
+ * one shares — last.
81
+ *
82
+ * Refused rather than answered: a column whose values a map cannot be keyed by (a timestamp, a
83
+ * jsonb, money), and a chain matching more distinct values than one statement should answer with.
84
+ */
85
+ countBy<K extends keyof Row & string>(column: K): Promise<ReadonlyMap<Row[K], number>>;
27
86
  /** The plan this chain describes. Safe to log — `describePlan()` elides values. */
28
87
  plan(): QueryPlan;
29
88
  }
30
89
 
31
90
  export interface Table<Row, C extends ColumnMap = ColumnMap> extends ReadBuilder<Row> {
32
91
  insert(values: Insertable<C>, options?: RepoOptions): Promise<Row>;
33
- update(id: string, patch: Partial<Row>, options?: RepoOptions): Promise<Row>;
34
- delete(id: string, options?: RepoOptions): Promise<void>;
92
+ /**
93
+ * Many rows, one statement — the bulk write a per-row `insert` loop is the N+1 of, and the line
94
+ * an N+1 warning on a write loop names. Every row is parsed and asserted exactly as `insert`
95
+ * parses one, so declared defaults are filled here and not by the caller. Resolves with the rows
96
+ * as stored, in order; `insertAll([])` writes nothing. A collision is an error — `upsertAll` is
97
+ * the call that tolerates one.
98
+ */
99
+ insertAll(rows: readonly Insertable<C>[], options?: RepoOptions): Promise<readonly Row[]>;
100
+ /**
101
+ * `insertAll` that resolves a collision instead of failing on it: `on conflict (…) do update`,
102
+ * or `do nothing` with `onMatch: 'nothing'`. Resolves with the rows this call actually wrote, so
103
+ * a row left alone is absent from the result — which is how a caller counts what it inserted.
104
+ * The conflict target and the primary key are never overwritten: they are how the stored row was
105
+ * found and where it lives.
106
+ */
107
+ upsertAll(rows: readonly Insertable<C>[], args: UpsertArgs<Row>): Promise<readonly Row[]>;
108
+ /** `IdOf<Row>`: an entity that declared `uuid<PostId>()` is addressed by a `PostId` only. */
109
+ update(id: IdOf<Row>, patch: Partial<Row>, options?: RepoOptions): Promise<Row>;
110
+ delete(id: IdOf<Row>, options?: RepoOptions): Promise<void>;
111
+ /**
112
+ * Delete by equality filter; resolves with the number of rows removed. The only way to remove a
113
+ * row from an entity whose primary key is composite — `likes`, `blocks`, a join table — where
114
+ * one id cannot name it. `deleteWhere({})` is `X_WRITE_UNFILTERED`, never every row.
115
+ */
116
+ deleteWhere(filter: Partial<Row>, options?: RepoOptions): Promise<number>;
117
+ /**
118
+ * Update by equality filter; resolves with the number of rows written. The `update(id, patch)`
119
+ * a composite primary key cannot express — `participants.updateWhere({ conversationId, userId },
120
+ * { lastReadAt })` is the reference case. Empty filter: `X_WRITE_UNFILTERED`. Empty patch:
121
+ * `X_PATCH_EMPTY`. `onUpdateNow()` columns are stamped exactly as `update(id, patch)` stamps them.
122
+ */
123
+ updateWhere(filter: Partial<Row>, patch: Partial<Row>, options?: RepoOptions): Promise<number>;
35
124
  }
36
125
 
37
126
  interface State {
38
127
  readonly where: readonly Predicate[];
39
128
  readonly orderBy: readonly SortKey[];
40
- readonly limit: number;
129
+ /**
130
+ * `undefined` until `limit()` is called, and not the default spelled a second time: the driver
131
+ * already defaults an unnamed page to `DEFAULT_PAGE_SIZE`, and only "the caller named a page
132
+ * size" tells `inBatches()` it was handed one number for two jobs.
133
+ */
134
+ readonly limit: number | undefined;
41
135
  readonly cursor: string | null;
42
136
  readonly select: readonly string[] | undefined;
137
+ /** Resolved when `preload()` was called, so an unknown name fails at the chain and not a page later. */
138
+ readonly preload: readonly Relation[];
43
139
  }
44
140
 
45
- const EMPTY: State = { where: [], orderBy: [], limit: 50, cursor: null, select: undefined };
141
+ const EMPTY: State = {
142
+ where: [],
143
+ orderBy: [],
144
+ limit: undefined,
145
+ cursor: null,
146
+ select: undefined,
147
+ preload: [],
148
+ };
46
149
 
47
150
  const asRecord = (value: unknown): Readonly<Record<string, unknown>> =>
48
151
  typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
@@ -52,18 +155,39 @@ const builder = <Source, Row>(
52
155
  repo: Repo<Source>,
53
156
  state: State,
54
157
  pick: (row: Source) => Row,
158
+ related?: RelatedTables,
55
159
  ): ReadBuilder<Row> => {
56
160
  const next = (patch: Partial<State>): ReadBuilder<Row> =>
57
- builder(entity, repo, { ...state, ...patch }, pick);
161
+ builder(entity, repo, { ...state, ...patch }, pick, related);
162
+
163
+ /**
164
+ * A projection that drops a key the page is about to be preloaded on would send the statement
165
+ * looking for a column it did not select. The framework asks for what it needs; `pick` still
166
+ * hands the caller only the columns they named.
167
+ */
168
+ const selected: readonly string[] | undefined =
169
+ state.select === undefined
170
+ ? undefined
171
+ : [...new Set([...state.select, ...state.preload.map((relation) => relation.localKey)])];
58
172
 
59
173
  const args = () => ({
60
174
  where: state.where,
61
175
  orderBy: state.orderBy,
62
- limit: state.limit,
176
+ // Sent only when the caller named one: an unnamed page is the driver's default, and passing
177
+ // it from here would be the same number written in two files.
178
+ ...(state.limit === undefined ? {} : { limit: state.limit }),
63
179
  cursor: state.cursor,
64
- ...(state.select === undefined ? {} : { select: state.select }),
180
+ ...(selected === undefined ? {} : { select: selected }),
65
181
  });
66
182
 
183
+ /** The page the caller gets: projected, then every named relation attached to it by position. */
184
+ const attach = (rows: readonly Source[]): Promise<readonly Row[]> =>
185
+ preloaded(
186
+ { entity, related, relations: state.preload, where: state.where },
187
+ rows,
188
+ rows.map(pick),
189
+ );
190
+
67
191
  return {
68
192
  where: (filter) =>
69
193
  next({
@@ -80,52 +204,114 @@ const builder = <Source, Row>(
80
204
  orderBy: (column, direction = 'asc') =>
81
205
  next({ orderBy: [...state.orderBy, { column, direction }] }),
82
206
 
83
- limit: (rows) => next({ limit: rows }),
207
+ // Judged on the chain, like `inBatches(size)` and for the same reason: the number is the
208
+ // author's own text, and a page size that arrived as action input is exactly the one nobody
209
+ // sized. `planFor` applies the identical guard, so a caller reaching the repository directly
210
+ // cannot go round it.
211
+ limit: (rows) => {
212
+ assertPageSize(entity.$name, rows);
213
+ return next({ limit: rows });
214
+ },
84
215
 
85
216
  after: (cursor) => next({ cursor }),
86
217
 
87
218
  select<K extends keyof Row & string>(fields: { readonly [P in K]: true }) {
88
219
  // The predicate is what carries the literal key type through `Object.keys`.
89
220
  const keys = Object.keys(fields).filter((key): key is K => Object.hasOwn(fields, key));
90
- return builder<Source, Pick<Row, K>>(entity, repo, { ...state, select: keys }, (row) => {
91
- const source = pick(row);
92
- const picked = {} as Pick<Row, K>;
93
- for (const key of keys) picked[key] = source[key];
94
- return picked;
221
+ return builder<Source, Pick<Row, K>>(
222
+ entity,
223
+ repo,
224
+ { ...state, select: keys },
225
+ (row) => {
226
+ const source = pick(row);
227
+ const picked = {} as Pick<Row, K>;
228
+ for (const key of keys) picked[key] = source[key];
229
+ return picked;
230
+ },
231
+ related,
232
+ );
233
+ },
234
+
235
+ preload<Name extends string>(relation: Name) {
236
+ // Resolved here, so a name no foreign key produces fails on the chain rather than one page
237
+ // later — and naming one relation twice is one statement, not two identical ones.
238
+ const resolved = relationNamed(entity.$name, relation);
239
+ const already = state.preload.some((held) => held.name === resolved.name);
240
+ return builder<Source, Row & Preloaded<Name>>(
241
+ entity,
242
+ repo,
243
+ { ...state, preload: already ? state.preload : [...state.preload, resolved] },
244
+ // The relation is attached after the projection, so `pick` is unchanged and the row type
245
+ // is the only thing that grows — one cast, where a runtime name becomes a static one.
246
+ pick as (row: Source) => Row & Preloaded<Name>,
247
+ related,
248
+ );
249
+ },
250
+
251
+ inBatches(size) {
252
+ assertBatchable(entity, size, state);
253
+ return batchIterator<Row>({
254
+ from: state.cursor,
255
+ // The chain's own arguments with the batch as the page size and the iteration's position
256
+ // in place of the chain's: one statement per batch, and the same one `page()` sends.
257
+ page: async (cursor) => {
258
+ const result = await repo.findMany({ ...args(), limit: size, cursor });
259
+ return { rows: await attach(result.rows), nextCursor: result.nextCursor };
260
+ },
95
261
  });
96
262
  },
97
263
 
98
264
  page: async () => {
99
265
  const result = await repo.findMany(args());
100
- return { rows: result.rows.map(pick), nextCursor: result.nextCursor };
266
+ return { rows: await attach(result.rows), nextCursor: result.nextCursor };
101
267
  },
102
268
 
103
- all: async () => (await repo.findMany(args())).rows.map(pick),
269
+ all: async () => attach((await repo.findMany(args())).rows),
104
270
 
105
271
  one: async () => {
106
272
  const { rows } = await repo.findMany({ ...args(), limit: 1 });
107
273
  const row = rows[0];
108
- return row === undefined ? null : pick(row);
274
+ return row === undefined ? null : ((await attach([row]))[0] ?? null);
109
275
  },
110
276
 
111
277
  count: () => repo.count(args()),
112
278
 
279
+ async countBy<K extends keyof Row & string>(column: K) {
280
+ // The one cast on this terminal, and it is the same seam `select()` has: the driver contract
281
+ // is row-agnostic because a column name is a runtime string there, while the chain knows
282
+ // which property it just named and therefore what the map is keyed by.
283
+ return (await repo.countBy(column, args())) as ReadonlyMap<Row[K], number>;
284
+ },
285
+
113
286
  plan: (): QueryPlan => ({
114
287
  entity: entity.$name,
115
288
  where: state.where,
116
289
  orderBy: state.orderBy,
117
- limit: state.limit,
290
+ // The page that will actually run, so an unnamed one still reads as the bound it has.
291
+ limit: state.limit ?? DEFAULT_PAGE_SIZE,
118
292
  ...(state.cursor === null ? {} : { cursor: state.cursor }),
119
- ...(state.select === undefined ? {} : { select: state.select }),
293
+ // The projection actually sent, preload keys included: a plan that is safe to log is only
294
+ // useful if it is the plan that ran.
295
+ ...(selected === undefined ? {} : { select: selected }),
120
296
  }),
121
297
  };
122
298
  };
123
299
 
124
- /** Columns declared `onUpdateNow()` are written by the framework, never by the caller. */
125
- const touch = <Row>(entity: EntityCore<Row>, patch: Partial<Row>): Partial<Row> => {
300
+ /**
301
+ * Columns declared `onUpdateNow()` are written by the framework, never by the caller. One helper,
302
+ * so `update(id, patch)` and `updateWhere(filter, patch)` stamp the same columns at the same
303
+ * moment — a second copy is how one of them ends up with a stale `updatedAt`.
304
+ *
305
+ * A patch that names nothing is returned untouched. Stamping `updatedAt` onto "the caller named no
306
+ * columns" would turn that mistake into a real write on any entity that happens to declare the
307
+ * column, and `X_PATCH_EMPTY` downstream would never see it — so whether the refusal fires would
308
+ * depend on the schema rather than on the call.
309
+ */
310
+ const touch = <Row, Patch>(entity: EntityCore<Row>, patch: Patch): Patch => {
311
+ if (namedColumns(patch).length === 0) return patch;
126
312
  const stamped: Record<string, unknown> = {};
127
313
  for (const [property, column] of Object.entries(entity.$columns)) {
128
- if (column.$meta.onUpdate !== undefined) stamped[property] = new Date();
314
+ if (column.$meta.onUpdate !== undefined) stamped[property] = systemClock.now();
129
315
  }
130
316
  return Object.assign({}, patch, stamped);
131
317
  };
@@ -136,9 +322,27 @@ const touch = <Row>(entity: EntityCore<Row>, patch: Partial<Row>): Partial<Row>
136
322
  export const tableFor = <Row, C extends ColumnMap>(
137
323
  entity: EntityCore<Row, C>,
138
324
  repo: Repo<Row>,
325
+ /** How this table reaches another — `database()` passes it; a table built by hand has none. */
326
+ related?: RelatedTables,
139
327
  ): Table<Row, C> => ({
140
- ...builder<Row, Row>(entity, repo, EMPTY, (row) => row),
328
+ ...builder<Row, Row>(entity, repo, EMPTY, (row) => row, related),
141
329
  insert: async (values, options) => repo.insert(entity.$parse(values), options),
330
+ insertAll: async (rows, options) =>
331
+ repo.insertAll(
332
+ rows.map((row) => entity.$parse(row)),
333
+ options,
334
+ ),
335
+ // `touch` and not `$parse` alone: an upsert that lands on a stored row IS an update, so an
336
+ // `onUpdateNow()` column has to move exactly as `update(id, patch)` moves it — and stamping it
337
+ // in a second place is how one of the two ends up writing a stale `updatedAt`.
338
+ upsertAll: async (rows, args) =>
339
+ repo.upsertAll(
340
+ rows.map((row) => touch(entity, entity.$parse(row))),
341
+ args,
342
+ ),
142
343
  update: async (id, patch, options) => repo.update(id, touch(entity, patch), options),
143
344
  delete: async (id, options) => repo.delete(id, options),
345
+ deleteWhere: async (filter, options) => repo.deleteWhere(filter, options),
346
+ updateWhere: async (filter, patch, options) =>
347
+ repo.updateWhere(filter, touch(entity, patch), options),
144
348
  });
package/src/registry.ts CHANGED
@@ -18,6 +18,25 @@ export interface ColumnDescription {
18
18
  readonly references: string | null;
19
19
  }
20
20
 
21
+ /**
22
+ * One `references()`, resolved: both ends, both names. `ColumnDescription.references` renders
23
+ * this as `"<table>.<column>"` for the migration generator (tier 1, which cannot import this
24
+ * package) and the manifest — physical names, which is their whole vocabulary. A traversal needs
25
+ * the row *properties* too, so it reads the record; the string is written, never parsed back.
26
+ */
27
+ export interface ReferenceDescription {
28
+ /** Property key on the declaring row — what a JS caller reads and a preload collects. */
29
+ readonly property: string;
30
+ /** Physical column on the declaring table — what SQL names. */
31
+ readonly column: string;
32
+ /** The key accepts null, so a target that resolves to nothing is data, not a broken key. */
33
+ readonly nullable: boolean;
34
+ /** The entity referenced. Not necessarily registered — an entity may point outside a set. */
35
+ readonly targetEntity: string;
36
+ readonly targetProperty: string;
37
+ readonly targetColumn: string;
38
+ }
39
+
21
40
  export interface InvariantDescription {
22
41
  readonly name: string;
23
42
  readonly kind: InvariantKind;
@@ -27,13 +46,31 @@ export interface InvariantDescription {
27
46
  readonly where: string | null;
28
47
  }
29
48
 
49
+ /**
50
+ * An index as the migration generator has to emit it. The columns are carried, never recovered
51
+ * from `name`: `<table>_<a>_<b>_idx` is one string for two columns, and the convention that built
52
+ * it cannot be run backwards — `posts_org_id_created_at_idx` reads as the single column
53
+ * `"org_id_created_at"`, which is a `42703` at apply time. `where` and `order` are here for the
54
+ * same reason: a partial index emitted as a total one refuses rows the entity allows.
55
+ */
56
+ export interface IndexDescription {
57
+ readonly name: string;
58
+ /** Physical columns, in index order. Always at least one. */
59
+ readonly columns: readonly string[];
60
+ readonly unique: boolean;
61
+ /** Partial index predicate as SQL, `null` when the index covers every row. */
62
+ readonly where: string | null;
63
+ /** `null` is Postgres' own default (`asc`), never written out. */
64
+ readonly order: 'asc' | 'desc' | null;
65
+ }
66
+
30
67
  export interface EntityDescription {
31
68
  readonly name: string;
32
69
  readonly table: string;
33
70
  readonly primaryKey: readonly string[];
34
71
  readonly columns: readonly ColumnDescription[];
35
72
  readonly invariants: readonly InvariantDescription[];
36
- readonly indexes: readonly string[];
73
+ readonly indexes: readonly IndexDescription[];
37
74
  readonly tags: readonly string[];
38
75
  readonly cacheTag: string;
39
76
  readonly softDelete: boolean;
@@ -44,9 +81,16 @@ export interface RegistryEntry {
44
81
  readonly name: string;
45
82
  readonly tableName: string;
46
83
  describe(): EntityDescription;
84
+ /**
85
+ * The foreign keys this entity declares, resolved. This is how a relation reaches query time:
86
+ * a method and not a field because a `references()` thunk may point at an entity declared
87
+ * later in an import cycle, so resolving at registration would read a half-evaluated module.
88
+ */
89
+ references(): readonly ReferenceDescription[];
47
90
  }
48
91
 
49
92
  const entities = new Map<string, RegistryEntry>();
93
+ let generation = 0;
50
94
 
51
95
  export const registerEntity = <E extends RegistryEntry>(entry: E): E => {
52
96
  const existing = entities.get(entry.name);
@@ -54,6 +98,7 @@ export const registerEntity = <E extends RegistryEntry>(entry: E): E => {
54
98
  throw entityDuplicate(entry.name, existing.tableName);
55
99
  }
56
100
  entities.set(entry.name, entry);
101
+ generation += 1;
57
102
  return entry;
58
103
  };
59
104
 
@@ -61,13 +106,26 @@ export const getEntity = (name: string): RegistryEntry | undefined => entities.g
61
106
 
62
107
  export const entityNames = (): readonly string[] => [...entities.keys()].sort();
63
108
 
64
- /** Deterministic order: the manifest is a build artefact and must diff cleanly. */
65
- export const describeEntities = (): readonly EntityDescription[] =>
109
+ /**
110
+ * Bumped by every mutation. A projection of the WHOLE registry — the relation map — caches
111
+ * against it, so a module imported late registers one more entity and invalidates that cache
112
+ * instead of being missed by it.
113
+ */
114
+ export const registryGeneration = (): number => generation;
115
+
116
+ /** Deterministic order: every projection of the registry is a build input and must diff cleanly. */
117
+ export const registeredEntities = (): readonly RegistryEntry[] =>
66
118
  entityNames().map((name) => {
67
119
  const entry = entities.get(name);
68
120
  if (entry === undefined) throw entityDuplicate(name, 'unknown');
69
- return entry.describe();
121
+ return entry;
70
122
  });
71
123
 
124
+ export const describeEntities = (): readonly EntityDescription[] =>
125
+ registeredEntities().map((entry) => entry.describe());
126
+
72
127
  /** Test seam. Production code never unregisters an entity. */
73
- export const clearRegistry = (): void => entities.clear();
128
+ export const clearRegistry = (): void => {
129
+ entities.clear();
130
+ generation += 1;
131
+ };