@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.
@@ -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
- # Mutation cookbook
1
+ # Writing data
2
2
 
3
- Use `sql.insertValues` and `sql.assignments` for object fields while keeping SQL structure visible.
4
- The [reference](reference.md#object-based-mutation-fragments) defines the complete contract.
5
- Examples assume an existing `db` and this table, with generated `Database` from the same schema:
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
- ## Defaults, undefined, and null in batches
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: 'Ada', bio: null },
23
- { name: 'Lin', score: 4, bio: undefined },
24
- ])} RETURNING id`,
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
- Ada gets the score default and SQL null for bio. Lin gets score 4 and the bio default. Columns stay
30
- aligned across heterogeneous rows. Empty batches fail; a single default-only object emits
31
- `DEFAULT VALUES` and still depends on the table's required-column rules.
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
- ## Input types and runtime validation
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
- ${sql.insertValues(input, ['name', 'score'])} RETURNING id`,
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
- Annotations, `satisfies`, or helper generic arguments apply generated input types; merely generating
51
- the file does not check writes. Use `Pick` to narrow accepted fields and retain runtime validation.
52
- Generated inputs describe defaults and driver representations, not authorization or constraints.
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
- Selected columns choose and order fields, ignoring unlisted keys. They can discard a required field
55
- even when the original object is valid:
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
- ```ts
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
- ## Patches mixed with expressions
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(sql`UPDATE profiles SET ${sql.assignments(patch)}, revision = revision + 1
68
- WHERE id = ${id}`);
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
- Missing/undefined fields are omitted; null clears a nullable field. Put arithmetic and
72
- `column = DEFAULT` in SQL. Helpers accept values, not SQL expression objects.
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
- An empty patch fails even if the surrounding SQL has other assignments. Branch when an
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(sql`UPDATE profiles SET ${sql.assignments(patch)}, revision = revision + 1
80
- WHERE id = ${id}`);
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
- ## Preserve an omitted field during an upsert
113
+ ## Upsert without overwriting an omitted field
87
114
 
88
- Given validated `name`, `score`, and optional `bio`, choose conflict behavior explicitly:
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 query preserves an existing bio. Omitting an insert value uses its database default; that
101
- alone does not decide which fields a conflict update overwrites.
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 a table with `payload jsonb` and `tags text[]`, encode JSON separately from the PostgreSQL
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
- sql`INSERT INTO deliveries ${sql.insertValues({ payload: json(tags), tags: param(tags, builtin.textArray) })}`,
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
- Generated JSON writes accept pgclear parameters or encoded strings. `json(value)` encodes objects,
118
- arrays, strings, and JSON null; plain `null` means SQL NULL. Arrays need `param(value, codec)` or
119
- encoded PostgreSQL text. Plain arrays, objects, and undefined reject as bound values. Tenon passes
120
- explicit parameters unchanged to pgclear. `json()` still rejects bigint values, including bigint
121
- nested inside objects; bigint support for ordinary SQL parameters does not change JSON encoding.
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
- Date and supported Temporal values can be ordinary scalar inputs, including mutation fields. Date
129
- encodes in UTC; Temporal calendar dates, wall times, and instants retain their distinct meanings.
130
- Choose the value appropriate to the PostgreSQL column and retain application validation.
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
- ## Batches in one transaction
164
+ ## Batch within one transaction
133
165
 
134
- For an array `rows` of generated profile inputs:
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 ${sql.insertValues(batch, ['name', 'bio', 'score'])}`);
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 all batches. Empty input makes no insert calls. Choose a batch size below the
146
- query-wide 65,535-parameter limit, including surrounding parameters; default cells consume none.
147
- Tenon does not split batches or choose transaction boundaries for you.
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).