@ultimat3/entity 29.3.1 → 29.4.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/README.md CHANGED
@@ -534,6 +534,13 @@ export const posts = entity('posts', {
534
534
  | Refused | a unique GIN and an ordered GIN, at `entity()` — Postgres has neither, and the refusal names the edit |
535
535
  | Naming | the method is part of what separates two indexes on the same columns, so a btree and a GIN on one column are two indexes with two names |
536
536
 
537
+ On a big, hot table, build an index with `concurrently: true` — `{ on: ['status'], concurrently:
538
+ true }`. `x db gen` emits `create index concurrently if not exists` in a migration of its own,
539
+ marked `-- transaction: false`, so writes keep flowing while it builds; an interrupted build is
540
+ rebuilt by the next `x db migrate`. It is how the index is built, not what it is: not part of the
541
+ name, not recorded, and toggling it generates nothing. Every index REMOVED from `indexes` is dropped
542
+ the same way, concurrently or not — `@ultimat3/db`'s README, "Indexes built concurrently".
543
+
537
544
  ## Full-text search
538
545
 
539
546
  `.searchable()` on a `text()` column puts it in the entity's **one** generated `tsvector`, with a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "29.3.1",
3
+ "version": "29.4.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,9 +33,9 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/core": "29.3.1",
37
- "@ultimat3/db": "29.3.1",
38
- "@ultimat3/schema": "29.3.1",
39
- "@ultimat3/time": "29.3.1"
36
+ "@ultimat3/core": "29.4.0",
37
+ "@ultimat3/db": "29.4.0",
38
+ "@ultimat3/schema": "29.4.0",
39
+ "@ultimat3/time": "29.4.0"
40
40
  }
41
41
  }
package/src/describe.ts CHANGED
@@ -231,6 +231,8 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
231
231
  // Spread, never `?? null`: absent is what `@ultimat3/db` reads as the btree it always was,
232
232
  // and a written-out `null` would be a field no existing snapshot carries.
233
233
  ...(index.using === undefined ? {} : { using: index.using }),
234
+ // `true` or absent, never `false`, for the same reason: `x verify` hashes this projection.
235
+ ...(index.concurrently === true ? { concurrently: true as const } : {}),
234
236
  })),
235
237
  tags: input.tags,
236
238
  cacheTag: input.cacheTag,
package/src/entity.ts CHANGED
@@ -13,6 +13,7 @@ import { describeEntity, describeReferences } from './describe';
13
13
  import { invariantViolated } from './errors';
14
14
  import type { InvariantColumns, Resolve } from './expr';
15
15
  import { invariantColumns } from './expr';
16
+ import { distinctIndexes } from './index-identity';
16
17
  import type { IndexInit } from './index-init';
17
18
  import { indexName } from './index-name';
18
19
  import type { Invariant, InvariantDef } from './invariants';
@@ -303,6 +304,8 @@ export const entity = <const C extends ColumnMap>(
303
304
  // Absent stays absent: `btree` written out would be a field every existing snapshot lacks,
304
305
  // and `indexMethodOf` reads the two the same way precisely so nothing regenerates.
305
306
  ...(index.using === undefined || index.using === 'btree' ? {} : { using: index.using }),
307
+ // How it is BUILT, so not part of the name: `true` or absent, like `appendOnly`.
308
+ ...(index.concurrently === true ? { concurrently: true as const } : {}),
306
309
  };
307
310
  }),
308
311
  // The one index nobody declared and every search needs. Through the SAME `IndexInit` path a
@@ -319,28 +322,8 @@ export const entity = <const C extends ColumnMap>(
319
322
  },
320
323
  ]),
321
324
  ];
322
- /**
323
- * A foreign key already indexes its column; naming it again in `indexes` is not two indexes.
324
- *
325
- * On the WHOLE definition and not on the name. With the discriminator above the two rules agree
326
- * exactly, so this is not a behaviour change on its own — it is which one FAILS LOUDLY if the
327
- * naming is ever weakened again. Matching on the name drops the second index in silence, which
328
- * is how two different partial indexes became one for three majors; matching on the definition
329
- * keeps both, and two `create index` statements sharing a name is `42P07` on the next migration.
330
- */
331
- const identity = (index: IndexDef): string =>
332
- [
333
- index.name,
334
- index.columns.join(','),
335
- index.unique,
336
- index.order ?? '',
337
- index.where ?? '',
338
- index.using ?? '',
339
- ].join('|');
340
- const indexes: readonly IndexDef[] = declared.filter(
341
- (index, position) =>
342
- declared.findIndex((other) => identity(other) === identity(index)) === position,
343
- );
325
+ // A foreign key's own index named again in `indexes` is one index, not two (`index-identity.ts`).
326
+ const indexes = distinctIndexes(declared);
344
327
 
345
328
  const sealed = sealedFields({ $name: name, $table: table, $columns: init.columns });
346
329
  assertSealedDeclaration({
@@ -0,0 +1,46 @@
1
+ // When two index declarations are ONE index. Split out of `entity.ts` at the 500-line ceiling: a
2
+ // foreign key already indexes its column, and naming it again in `indexes` is not two indexes.
3
+
4
+ import type { IndexDef } from './types';
5
+
6
+ /**
7
+ * WHAT the index is — never how it is built. `concurrently` is left out on purpose: it changes the
8
+ * migration that builds an index, not the index, so a foreign key's own index and an `indexes:`
9
+ * entry asking for the same one concurrently are still one index (`distinctIndexes` keeps the ask).
10
+ */
11
+ const identity = (index: IndexDef): string =>
12
+ [
13
+ index.name,
14
+ index.columns.join(','),
15
+ index.unique,
16
+ index.order ?? '',
17
+ index.where ?? '',
18
+ index.using ?? '',
19
+ ].join('|');
20
+
21
+ /**
22
+ * Every declared index once, in first-declared order.
23
+ *
24
+ * On the WHOLE definition and not on the name. With the name discriminator (`index-name.ts`) the
25
+ * two rules agree exactly, so this is not a behaviour change on its own — it is which one FAILS
26
+ * LOUDLY if the naming is ever weakened again. Matching on the name drops the second index in
27
+ * silence, which is how two different partial indexes became one for three majors; matching on the
28
+ * definition keeps both, and two `create index` statements sharing a name is `42P07` on the next
29
+ * migration.
30
+ *
31
+ * A duplicate that asks for `concurrently: true` hands that ask to the one kept: dropping it would
32
+ * silently build a hot table's index inside the migration's transaction after all.
33
+ */
34
+ export function distinctIndexes(declared: readonly IndexDef[]): readonly IndexDef[] {
35
+ return declared
36
+ .filter(
37
+ (index, position) =>
38
+ declared.findIndex((other) => identity(other) === identity(index)) === position,
39
+ )
40
+ .map((index) =>
41
+ index.concurrently !== true &&
42
+ declared.some((other) => other.concurrently === true && identity(other) === identity(index))
43
+ ? { ...index, concurrently: true as const }
44
+ : index,
45
+ );
46
+ }
package/src/index-init.ts CHANGED
@@ -26,4 +26,16 @@ export interface IndexInit<C extends ColumnMap> {
26
26
  * unique and cannot order its keys.
27
27
  */
28
28
  readonly using?: IndexMethod;
29
+ /**
30
+ * Build it with `create index concurrently`, in a migration step of its own that runs OUTSIDE a
31
+ * transaction — so a big, hot table keeps taking writes while the index builds. A plain `create
32
+ * index` holds SHARE on the table for the whole build: every insert, update and delete waits.
33
+ *
34
+ * How the index is BUILT, never what it is: not part of its name, not recorded in a snapshot,
35
+ * and switching it on for an existing index generates nothing. `x db gen` writes the step as
36
+ * `<id>_concurrently.sql` beside the migration (or as the migration, when it is the only change),
37
+ * marked `-- transaction: false`; an index on a table the same migration creates is still built
38
+ * inside that migration's transaction — the table is empty and nobody else can see it.
39
+ */
40
+ readonly concurrently?: boolean;
29
41
  }
package/src/registry.ts CHANGED
@@ -112,6 +112,11 @@ export interface IndexDescription {
112
112
  * predates the field and an index that declares nothing read the same, so nothing regenerates.
113
113
  */
114
114
  readonly using?: IndexMethod | undefined;
115
+ /**
116
+ * `true` when the index is built with `create index concurrently`, outside a transaction; absent
117
+ * otherwise, so an entity that never asked hashes exactly what it hashed before.
118
+ */
119
+ readonly concurrently?: true | undefined;
115
120
  }
116
121
 
117
122
  export interface EntityDescription {
package/src/types.ts CHANGED
@@ -451,4 +451,6 @@ export interface IndexDef {
451
451
  * one fact that this release exists to stop.
452
452
  */
453
453
  readonly using?: IndexMethod;
454
+ /** `indexes: [{ concurrently: true }]` — `true` or absent, never `false` (`IndexInit`). */
455
+ readonly concurrently?: true;
454
456
  }