@ultimat3/entity 29.3.2 → 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 +7 -0
- package/package.json +5 -5
- package/src/describe.ts +2 -0
- package/src/entity.ts +5 -22
- package/src/index-identity.ts +46 -0
- package/src/index-init.ts +12 -0
- package/src/registry.ts +5 -0
- package/src/types.ts +2 -0
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
|
+
"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.
|
|
37
|
-
"@ultimat3/db": "29.
|
|
38
|
-
"@ultimat3/schema": "29.
|
|
39
|
-
"@ultimat3/time": "29.
|
|
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
|
-
|
|
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