@kequtech/tenon-orm 0.4.0 → 0.4.2
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/CHANGELOG.md +17 -2
- package/README.md +84 -51
- package/dist/cli.js +6 -22
- package/dist/database.js +5 -0
- package/dist/generate.d.ts +11 -0
- package/dist/generate.js +26 -1
- package/dist/generation/output.js +2 -4
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +3 -0
- package/docs/adoption-guide.md +88 -153
- package/docs/agent-guide.md +91 -172
- package/docs/connections.md +185 -0
- package/docs/human-guide.md +132 -176
- package/docs/migrations.md +133 -0
- package/docs/mutations.md +104 -67
- package/docs/query-checking.md +119 -120
- package/docs/query-inspection.md +78 -74
- package/docs/reference.md +222 -474
- package/docs/schema-types.md +263 -0
- package/docs/sql.md +212 -0
- package/package.json +1 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# SQL migrations
|
|
2
|
+
|
|
3
|
+
Tenon's optional runner applies numbered SQL files and records their checksums in the database. It
|
|
4
|
+
can be used independently of query execution and type generation. Keep an existing runner unless
|
|
5
|
+
you intend to [transfer migration ownership](adoption-guide.md#transfer-migration-ownership).
|
|
6
|
+
|
|
7
|
+
## Create and apply a change
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx tenon new add_user_display_name
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`new` works offline. It creates the next numeric pair in `migrations/`, starting at `0001` in an empty
|
|
14
|
+
directory. For a directory whose latest migration is `0001`:
|
|
15
|
+
|
|
16
|
+
```sql
|
|
17
|
+
-- migrations/0002_add_user_display_name.up.sql
|
|
18
|
+
ALTER TABLE public.users ADD COLUMN display_name text;
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```sql
|
|
22
|
+
-- migrations/0002_add_user_display_name.down.sql
|
|
23
|
+
ALTER TABLE public.users DROP COLUMN display_name;
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Set `DATABASE_URL` through your environment, then run:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npx tenon status
|
|
30
|
+
npx tenon migrate
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`status` prints JSON with `applied` and `pending` ID arrays. `migrate` applies every pending up file
|
|
34
|
+
in numeric order and reports completed IDs. No pending files means nothing changes. No command
|
|
35
|
+
loads `.env` automatically.
|
|
36
|
+
|
|
37
|
+
After migrating a development database, [generate types](schema-types.md#generate-and-check-a-file),
|
|
38
|
+
review the diff, and commit migrations with generated declarations. Production applies migrations
|
|
39
|
+
and deploys the already-built application. Use a dedicated migrated database for tests.
|
|
40
|
+
|
|
41
|
+
## File rules and history
|
|
42
|
+
|
|
43
|
+
Files use `<numeric-id>_<name>.up.sql` and optional matching `.down.sql`. Names contain letters,
|
|
44
|
+
digits, underscores, or hyphens. Each ID has one name and an up file. SQL files with invalid names,
|
|
45
|
+
empty contents, duplicate numeric IDs, or down files without an up file fail validation.
|
|
46
|
+
|
|
47
|
+
The runner records ID, name, checksum, and application time in `<namespace>.__tenon_migrations`.
|
|
48
|
+
Checksums cover both up SQL and the presence/content of down SQL. Never edit, rename, remove, or
|
|
49
|
+
add a down file to an already-applied migration. Add a new forward migration instead. History must
|
|
50
|
+
match the beginning of the ordered file list; inserting an older migration also causes a conflict.
|
|
51
|
+
|
|
52
|
+
Migrations may contain multiple statements. Tenon owns the transaction, so authored files must not
|
|
53
|
+
contain transaction-control statements. Use dollar-quoted bodies for functions and explicit `E'...'`
|
|
54
|
+
for backslash escapes. A scaffold's files are empty until you fill them; both are checked even if
|
|
55
|
+
only the up direction is about to run.
|
|
56
|
+
|
|
57
|
+
## Transaction and failure behavior
|
|
58
|
+
|
|
59
|
+
Each migration file and its history entry commit together. The whole run is not one transaction:
|
|
60
|
+
if file three fails, files one and two may already have committed. Correct the cause, inspect
|
|
61
|
+
`status`, and rerun. The failed file is rolled back and remains pending. Restore original applied
|
|
62
|
+
files if history validation reports a conflict.
|
|
63
|
+
|
|
64
|
+
SQL that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`, needs a separate,
|
|
65
|
+
application-owned procedure. Tenon has no nontransactional migration flag. It also does not infer
|
|
66
|
+
schema changes or generate migration SQL from TypeScript.
|
|
67
|
+
|
|
68
|
+
## Roll back
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
npx tenon rollback
|
|
72
|
+
npx tenon rollback --steps 2
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Rollback runs the most recent down files in reverse order and removes their history records. It
|
|
76
|
+
prechecks that every selected migration has down SQL before executing any. `steps` must be a
|
|
77
|
+
positive safe integer; requesting more steps than exist selects all applied migrations.
|
|
78
|
+
|
|
79
|
+
For an irreversible change, omit the down file before first application. The missing file prevents
|
|
80
|
+
rollback across that migration. A down migration is not a backup: dropped data and sequence changes
|
|
81
|
+
may not be recoverable. Use the application's backup and recovery procedure when needed.
|
|
82
|
+
|
|
83
|
+
## Directory, schema, and locking
|
|
84
|
+
|
|
85
|
+
Defaults are `--directory migrations` and `--namespace public`. For a custom schema, use the same
|
|
86
|
+
options consistently:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
npx tenon new add_setting --directory db/migrations
|
|
90
|
+
npx tenon migrate --directory db/migrations --namespace app
|
|
91
|
+
npx tenon status --directory db/migrations --namespace app
|
|
92
|
+
npx tenon rollback --directory db/migrations --namespace app --steps 1
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The namespace selects history location, migration search path, and lock scope. During a file,
|
|
96
|
+
`search_path` is `<namespace>, pg_catalog`; qualify cross-schema names explicitly. The runner creates
|
|
97
|
+
the namespace when applying pending migrations. It does not configure runtime application queries.
|
|
98
|
+
Pass the matching namespace to type generation when generating that schema.
|
|
99
|
+
|
|
100
|
+
Migration and adoption operations use an exclusive session advisory lock for this database and
|
|
101
|
+
namespace. Status and generation use a shared lock. Different namespaces have independent lock
|
|
102
|
+
scopes. These locks do not coordinate with a legacy runner or arbitrary DDL.
|
|
103
|
+
|
|
104
|
+
Use direct or session-pooled connections for these tools; transaction pooling does not preserve
|
|
105
|
+
their session locks. Configure server timeouts on the tooling connection to bound waits. Migration
|
|
106
|
+
commands do not accept Describe's timeout flags.
|
|
107
|
+
|
|
108
|
+
Offline file creation uses `.tenon-create.lock` in the directory. Remove a stale lock only after
|
|
109
|
+
confirming that the process which created it has stopped. Baseline capture uses this local lock
|
|
110
|
+
and `pg_dump`; it does not take the runner's database advisory lock.
|
|
111
|
+
|
|
112
|
+
## Existing databases
|
|
113
|
+
|
|
114
|
+
An existing schema needs no baseline merely to execute queries or generate types. A baseline is
|
|
115
|
+
needed when Tenon takes over migration history: capture one reviewed schema at the old runner's
|
|
116
|
+
final checkpoint, record it on existing databases without execution, and execute it on fresh ones.
|
|
117
|
+
Follow [the adoption workflow](adoption-guide.md#transfer-migration-ownership).
|
|
118
|
+
|
|
119
|
+
## Programmatic API
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { migrate, migrationStatus, rollback } from '@kequtech/tenon-orm';
|
|
123
|
+
|
|
124
|
+
const options = { connection, directory: 'migrations', namespace: 'public' };
|
|
125
|
+
const before = await migrationStatus(options); // { applied, pending }
|
|
126
|
+
const applied = await migrate(options); // readonly string[] of completed IDs
|
|
127
|
+
const reverted = await rollback({ ...options, steps: 1 });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`connection` accepts the same URI/options/pool forms as `createTenon`. A supplied pool retains
|
|
131
|
+
application ownership. `createMigration({ name, directory? })` returns `{ id, upPath, downPath }`;
|
|
132
|
+
`readMigrations(directory?)` returns validated `{ id, name, up, down?, checksum }` records.
|
|
133
|
+
See [the command reference](reference.md#command-reference) for all CLI forms.
|
package/docs/mutations.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Writing data
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Write `INSERT`, `UPDATE`, `DELETE`, and `ON CONFLICT` in SQL. Use `sql.insertValues` and
|
|
4
|
+
`sql.assignments` when you have an object of field values; they return fragments for the column/value
|
|
5
|
+
parts of your statement. The [API reference](reference.md#object-based-mutation-fragments) defines
|
|
6
|
+
edge cases and validation rules.
|
|
7
|
+
|
|
8
|
+
Examples use an existing `db`, import `sql` from Tenon, and assume this table:
|
|
6
9
|
|
|
7
10
|
```sql
|
|
8
|
-
CREATE TABLE profiles (
|
|
11
|
+
CREATE TABLE public.profiles (
|
|
9
12
|
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
10
13
|
name text NOT NULL UNIQUE,
|
|
11
14
|
bio text DEFAULT 'unwritten',
|
|
@@ -14,23 +17,41 @@ CREATE TABLE profiles (
|
|
|
14
17
|
);
|
|
15
18
|
```
|
|
16
19
|
|
|
17
|
-
##
|
|
20
|
+
## Insert and return a row
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { sql } from '@kequtech/tenon-orm';
|
|
24
|
+
|
|
25
|
+
const profile = await db.query(
|
|
26
|
+
sql<{ id: number; name: string }>`INSERT INTO public.profiles
|
|
27
|
+
${sql.insertValues({ name: 'Ada' })} RETURNING id, name`,
|
|
28
|
+
{ returns: 'one' },
|
|
29
|
+
);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The helper emits `("name") VALUES ($1)` and binds `'Ada'`. PostgreSQL fills defaults. You choose
|
|
33
|
+
`RETURNING` and the result mode explicitly.
|
|
34
|
+
|
|
35
|
+
## Defaults, undefined, and null
|
|
18
36
|
|
|
19
37
|
```ts
|
|
20
38
|
await db.query(
|
|
21
|
-
sql`INSERT INTO profiles ${sql.insertValues([
|
|
22
|
-
{ name: '
|
|
23
|
-
{ name: '
|
|
24
|
-
])}
|
|
25
|
-
{ returns: 'many' },
|
|
39
|
+
sql`INSERT INTO public.profiles ${sql.insertValues([
|
|
40
|
+
{ name: 'Lin', bio: null },
|
|
41
|
+
{ name: 'Jo', score: 4, bio: undefined },
|
|
42
|
+
])}`,
|
|
26
43
|
);
|
|
27
44
|
```
|
|
28
45
|
|
|
29
|
-
|
|
30
|
-
aligned
|
|
31
|
-
|
|
46
|
+
Lin gets score 0 and SQL null for bio. Jo gets score 4 and the bio default. Heterogeneous rows stay
|
|
47
|
+
aligned. Missing/undefined insert cells use `DEFAULT`; explicit null remains SQL NULL.
|
|
48
|
+
|
|
49
|
+
A single default-only object emits `DEFAULT VALUES`, which still fails if the table has an unmet
|
|
50
|
+
required column. Empty batches fail. Multiple default-only rows need an explicit column list.
|
|
51
|
+
|
|
52
|
+
## Type and select accepted fields
|
|
32
53
|
|
|
33
|
-
|
|
54
|
+
Generate `Database` from the same schema, then narrow the fields the application accepts:
|
|
34
55
|
|
|
35
56
|
```ts
|
|
36
57
|
import { sql, type QueryClient } from '@kequtech/tenon-orm';
|
|
@@ -40,108 +61,124 @@ type ProfileInput = Pick<Database['profiles']['insert'], 'name' | 'score'>;
|
|
|
40
61
|
|
|
41
62
|
function createProfile(client: QueryClient, input: ProfileInput) {
|
|
42
63
|
return client.query(
|
|
43
|
-
sql<{ id: number }>`INSERT INTO profiles
|
|
44
|
-
|
|
64
|
+
sql<{ id: number }>`INSERT INTO public.profiles
|
|
65
|
+
${sql.insertValues(input, ['name', 'score'])} RETURNING id`,
|
|
45
66
|
{ returns: 'one' },
|
|
46
67
|
);
|
|
47
68
|
}
|
|
48
69
|
```
|
|
49
70
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
71
|
+
The explicit column list selects and orders fields and ignores unlisted keys. Keep required insert
|
|
72
|
+
fields in that list: a well-typed object can still produce an invalid insert if selection discards
|
|
73
|
+
its required `name`. Helpers have no schema metadata; PostgreSQL enforces constraints.
|
|
53
74
|
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
Types apply through annotations, `satisfies`, or helper generic arguments. They do not validate
|
|
76
|
+
external data, authorization, JSON contents, or CHECK constraints. See
|
|
77
|
+
[generated inputs](schema-types.md#insert-and-update-inputs).
|
|
56
78
|
|
|
57
|
-
|
|
58
|
-
const input: Database['profiles']['insert'] = { name: 'Ada' };
|
|
59
|
-
const query = sql`INSERT INTO profiles ${sql.insertValues(input, ['bio', 'score'])}`;
|
|
60
|
-
// Executing this fails: name was discarded and has no default.
|
|
61
|
-
```
|
|
79
|
+
## Patch fields and use expressions
|
|
62
80
|
|
|
63
|
-
|
|
81
|
+
For a validated `id` and patch:
|
|
64
82
|
|
|
65
83
|
```ts
|
|
66
84
|
const patch: Pick<Database['profiles']['update'], 'name' | 'bio'> = { bio: null };
|
|
67
|
-
await db.query(
|
|
68
|
-
|
|
85
|
+
await db.query(
|
|
86
|
+
sql`UPDATE public.profiles SET ${sql.assignments(patch)}, revision = revision + 1
|
|
87
|
+
WHERE id = ${id}`,
|
|
88
|
+
{ affectedRows: { exact: 1 } },
|
|
89
|
+
);
|
|
69
90
|
```
|
|
70
91
|
|
|
71
|
-
|
|
72
|
-
`column = DEFAULT
|
|
92
|
+
Assignments omit missing/undefined fields. Null clears a nullable field. Put arithmetic,
|
|
93
|
+
`column = DEFAULT`, and other SQL expressions in the surrounding statement; helper cells accept
|
|
94
|
+
bound values, not SQL fragments or expression objects.
|
|
73
95
|
|
|
74
|
-
|
|
75
|
-
expression-only update is intended:
|
|
96
|
+
`sql.assignments` throws if no defined fields remain, even if the surrounding query has another
|
|
97
|
+
assignment. Skip an empty patch, or branch when an expression-only update is intended:
|
|
76
98
|
|
|
77
99
|
```ts
|
|
78
100
|
if (patch.name !== undefined || patch.bio !== undefined) {
|
|
79
|
-
await db.query(
|
|
80
|
-
|
|
101
|
+
await db.query(
|
|
102
|
+
sql`UPDATE public.profiles SET ${sql.assignments(patch)}, revision = revision + 1
|
|
103
|
+
WHERE id = ${id}`,
|
|
104
|
+
{ affectedRows: { exact: 1 } },
|
|
105
|
+
);
|
|
81
106
|
} else {
|
|
82
|
-
await db.query(sql`UPDATE profiles SET revision = revision + 1 WHERE id = ${id}
|
|
107
|
+
await db.query(sql`UPDATE public.profiles SET revision = revision + 1 WHERE id = ${id}`, {
|
|
108
|
+
affectedRows: { exact: 1 },
|
|
109
|
+
});
|
|
83
110
|
}
|
|
84
111
|
```
|
|
85
112
|
|
|
86
|
-
##
|
|
113
|
+
## Upsert without overwriting an omitted field
|
|
87
114
|
|
|
88
|
-
|
|
115
|
+
For validated `name`, `score`, and optional `bio`, choose conflict behavior explicitly:
|
|
89
116
|
|
|
90
117
|
```ts
|
|
91
118
|
if (bio !== undefined) {
|
|
92
|
-
await db.query(sql`INSERT INTO profiles ${sql.insertValues({ name, score, bio })}
|
|
119
|
+
await db.query(sql`INSERT INTO public.profiles ${sql.insertValues({ name, score, bio })}
|
|
93
120
|
ON CONFLICT (name) DO UPDATE SET score = excluded.score, bio = excluded.bio`);
|
|
94
121
|
} else {
|
|
95
|
-
await db.query(sql`INSERT INTO profiles ${sql.insertValues({ name, score })}
|
|
122
|
+
await db.query(sql`INSERT INTO public.profiles ${sql.insertValues({ name, score })}
|
|
96
123
|
ON CONFLICT (name) DO UPDATE SET score = excluded.score`);
|
|
97
124
|
}
|
|
98
125
|
```
|
|
99
126
|
|
|
100
|
-
The second
|
|
101
|
-
|
|
127
|
+
The second branch preserves an existing bio. Omitting an insert field uses its database default;
|
|
128
|
+
it does not decide whether an upsert overwrites that field.
|
|
129
|
+
|
|
130
|
+
## Delete with a bound
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
await db.query(sql`DELETE FROM public.profiles WHERE id = ${id}`, {
|
|
134
|
+
affectedRows: { max: 1 },
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Zero or one deletion is allowed; more causes rollback. Use `exact: 1` when absence is also an error.
|
|
139
|
+
Guards work without `RETURNING`. See [transactions and guards](reference.md#transactions-guarded-writes-and-errors).
|
|
102
140
|
|
|
103
141
|
## JSON and PostgreSQL arrays
|
|
104
142
|
|
|
105
|
-
For
|
|
106
|
-
array:
|
|
143
|
+
For an additional table with `payload jsonb` and `tags text[]`:
|
|
107
144
|
|
|
108
145
|
```ts
|
|
109
146
|
import { builtin, json, param } from '@kequtech/tenon-orm';
|
|
110
147
|
|
|
111
148
|
const tags = ['ada', 'lin'];
|
|
112
|
-
await db.query(
|
|
113
|
-
|
|
114
|
-
);
|
|
149
|
+
await db.query(sql`INSERT INTO public.deliveries
|
|
150
|
+
${sql.insertValues({ payload: json(tags), tags: param(tags, builtin.textArray) })}`);
|
|
115
151
|
```
|
|
116
152
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
For bigint/bigserial fields, generated writes accept exact strings, bigint, or explicit parameters;
|
|
124
|
-
reads remain strings by default. Use `param('9007199254740993', builtin.int8)` for an explicit int8
|
|
125
|
-
parameter and `param(['9007199254740993', null], builtin.int8Array)` for an int8 array. The bigint
|
|
126
|
-
output codec and matching array encoder are separate opt-in choices.
|
|
153
|
+
`json(value)` encodes a JSON document; `param(value, codec)` chooses a PostgreSQL type and encoder.
|
|
154
|
+
A JSON array and a PostgreSQL array are separate formats. Generated writes accept encoded strings
|
|
155
|
+
or explicit parameters; plain objects/arrays reject. `json(null)` means JSON null, while plain
|
|
156
|
+
`null` means SQL NULL. `json('text')` encodes a JSON string, not an already-encoded document.
|
|
157
|
+
JSON encoding rejects bigint, including nested bigint values.
|
|
127
158
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
159
|
+
For int8, use exact strings, bigint, or `param('9007199254740993', builtin.int8)`. For an int8 array,
|
|
160
|
+
use `param(['9007199254740993', null], builtin.int8Array)`. Reads remain strings by default. Plain
|
|
161
|
+
Date and supported Temporal values work as scalar inputs; Date encodes in UTC. Neither input form
|
|
162
|
+
changes the output decoder. See [value mappings](schema-types.md#value-mappings).
|
|
131
163
|
|
|
132
|
-
##
|
|
164
|
+
## Batch within one transaction
|
|
133
165
|
|
|
134
|
-
For
|
|
166
|
+
For `rows` containing generated profile insert inputs:
|
|
135
167
|
|
|
136
168
|
```ts
|
|
137
169
|
await db.transaction(async (tx) => {
|
|
138
170
|
for (let offset = 0; offset < rows.length; offset += 300) {
|
|
139
171
|
const batch = rows.slice(offset, offset + 300);
|
|
140
|
-
await tx.query(sql`INSERT INTO profiles
|
|
172
|
+
await tx.query(sql`INSERT INTO public.profiles
|
|
173
|
+
${sql.insertValues(batch, ['name', 'bio', 'score'])}`);
|
|
141
174
|
}
|
|
142
175
|
});
|
|
143
176
|
```
|
|
144
177
|
|
|
145
|
-
A failure rolls back
|
|
146
|
-
query-wide 65,535-parameter limit,
|
|
147
|
-
Tenon does not split batches or choose transaction boundaries
|
|
178
|
+
A failure rolls back every batch; empty input makes no calls. Choose a batch size below the
|
|
179
|
+
query-wide 65,535-parameter limit, counting surrounding interpolations too. Default cells consume
|
|
180
|
+
no parameters. Tenon does not split batches or choose transaction boundaries.
|
|
181
|
+
|
|
182
|
+
These helpers support optional fields at runtime. The optional source checker needs explicit shape
|
|
183
|
+
types with required selected fields and fixed batch lengths; ordinary generated patch types can
|
|
184
|
+
remain unchecked. See [object expansion checking](query-checking.md#object-expansion).
|