@everystack/cli 0.3.8 → 0.3.10
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/package.json +6 -2
- package/src/cli/authz-compile.ts +2 -1
- package/src/cli/commands/db-generate.ts +15 -4
- package/src/cli/commands/db-pull.ts +65 -16
- package/src/cli/db-source.ts +77 -0
- package/src/cli/index.ts +10 -2
- package/src/cli/model-render.ts +108 -16
- package/src/cli/output.ts +3 -1
- package/src/cli/schema-compile.ts +42 -10
- package/src/cli/schema-diff.ts +71 -34
- package/src/cli/schema-source.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"structured-headers": "1.0.1",
|
|
75
75
|
"tsx": "4.21.0",
|
|
76
76
|
"typescript": "5.9.3",
|
|
77
|
-
"@everystack/model": "0.3.
|
|
77
|
+
"@everystack/model": "0.3.4"
|
|
78
78
|
},
|
|
79
79
|
"peerDependencies": {
|
|
80
80
|
"@everystack/server": ">=0.1.0",
|
|
@@ -87,10 +87,14 @@
|
|
|
87
87
|
"@aws-sdk/s3-request-presigner": "3.1053.0",
|
|
88
88
|
"drizzle-orm": "0.41.0",
|
|
89
89
|
"expo-updates": "55.0.21",
|
|
90
|
+
"postgres": "3.4.9",
|
|
90
91
|
"react": "19.2.0",
|
|
91
92
|
"react-native": "0.83.6"
|
|
92
93
|
},
|
|
93
94
|
"peerDependenciesMeta": {
|
|
95
|
+
"postgres": {
|
|
96
|
+
"optional": true
|
|
97
|
+
},
|
|
94
98
|
"@everystack/server": {
|
|
95
99
|
"optional": true
|
|
96
100
|
},
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -48,7 +48,8 @@ function castForType(type: string): string {
|
|
|
48
48
|
case 'uuid': return 'uuid';
|
|
49
49
|
case 'bigint':
|
|
50
50
|
case 'bigserial': return 'bigint';
|
|
51
|
-
case 'integer':
|
|
51
|
+
case 'integer':
|
|
52
|
+
case 'serial': return 'integer';
|
|
52
53
|
default: return type;
|
|
53
54
|
}
|
|
54
55
|
}
|
|
@@ -27,6 +27,7 @@ import { compileModuleMigration } from '../migration-compile.js';
|
|
|
27
27
|
import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
|
|
28
28
|
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
29
29
|
import { FUNCTIONS_SQL, catalogFunctionToDescriptor } from '../security-catalog.js';
|
|
30
|
+
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
30
31
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
31
32
|
import { invokeAction } from '../aws.js';
|
|
32
33
|
import { step, success, fail, info, warn } from '../output.js';
|
|
@@ -152,17 +153,27 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
152
153
|
// updates it. The drift-guard test asserts this file matches the models.
|
|
153
154
|
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
154
155
|
await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
const dbSource: DbSource = resolveDbSource(flags);
|
|
157
|
+
let runner: QueryRunner;
|
|
158
|
+
let end: (() => Promise<void>) | undefined;
|
|
159
|
+
if (dbSource.kind === 'url') {
|
|
160
|
+
step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
|
|
161
|
+
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
162
|
+
} else {
|
|
163
|
+
step('Resolving deployed config...');
|
|
164
|
+
const config = await resolveConfig(flags.stage);
|
|
165
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
166
|
+
runner = lambdaRunner(config.region, opsFunction(config));
|
|
167
|
+
}
|
|
159
168
|
step('Introspecting live database (columns + constraints)...');
|
|
160
169
|
current = await introspectSchema(runner);
|
|
161
170
|
step('Introspecting authorization (rls + grants + policies)...');
|
|
162
171
|
liveAuthz = await introspectContract(runner, mapFunctionRow, FUNCTIONS_SQL);
|
|
172
|
+
await end?.();
|
|
163
173
|
} catch (err: any) {
|
|
164
174
|
fail(err.message);
|
|
165
175
|
info('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
|
|
176
|
+
info('Diffing against a local database instead? Pass --database-url or set DATABASE_URL.');
|
|
166
177
|
process.exit(1);
|
|
167
178
|
}
|
|
168
179
|
|
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
|
|
3
3
|
*
|
|
4
|
-
* db:pull --stage <name> [--schema public] [--out
|
|
4
|
+
* db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>]
|
|
5
5
|
*
|
|
6
6
|
* The reverse of db:generate: that writes migrations FROM Models; this writes Models FROM the
|
|
7
7
|
* database. It introspects the deployed schema through the read-only ops Lambda `db:query`
|
|
8
|
-
* action
|
|
9
|
-
*
|
|
8
|
+
* action — or, for a database no Lambda can reach (the brownfield case where the target
|
|
9
|
+
* schema exists only locally), directly via `--database-url` / an inherited `DATABASE_URL`
|
|
10
|
+
* (see db-source.ts for precedence) — and renders the TypeScript a Model is written in — a
|
|
11
|
+
* starting point you review and refine (it flags, as inline comments, anything the field
|
|
12
|
+
* builder can't yet express).
|
|
10
13
|
*
|
|
11
|
-
* The rendered source is the artifact
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
+
* The rendered source is the artifact. An `--out` that is a directory (anything not ending
|
|
15
|
+
* in `.ts` — `--out models` is the canonical form) is the DEFAULT shape: one file per model
|
|
16
|
+
* plus an `index.ts` assembling the `models` array, because a real schema pulled as a single
|
|
17
|
+
* module is unreviewable. An `--out` ending in `.ts` writes the single-module form; with no
|
|
18
|
+
* `--out` the single module goes to stdout (so `db:pull > models.ts` works). Every
|
|
19
|
+
* progress/diagnostic line goes to stderr, so the stdout pipe stays pure source. The
|
|
20
|
+
* renderers (model-render.ts) are pure; this shell is the IO.
|
|
14
21
|
*/
|
|
15
22
|
|
|
16
23
|
import fs from 'node:fs/promises';
|
|
17
24
|
import path from 'node:path';
|
|
18
25
|
import { introspectSchema } from '../schema-introspect.js';
|
|
19
|
-
import { renderModelSource } from '../model-render.js';
|
|
26
|
+
import { renderModelSource, renderModelFiles, pullableTables } from '../model-render.js';
|
|
20
27
|
import type { QueryRunner } from '../authz-contract.js';
|
|
28
|
+
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
21
29
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
22
30
|
import { invokeAction } from '../aws.js';
|
|
23
31
|
import { fail } from '../output.js';
|
|
@@ -41,29 +49,69 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
|
41
49
|
export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
|
|
42
50
|
const schema = flags.schema || 'public';
|
|
43
51
|
|
|
52
|
+
let dbSource: DbSource;
|
|
53
|
+
try {
|
|
54
|
+
dbSource = resolveDbSource(flags);
|
|
55
|
+
} catch (err: any) {
|
|
56
|
+
fail(err.message);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
let current;
|
|
45
61
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
let runner: QueryRunner;
|
|
63
|
+
let end: (() => Promise<void>) | undefined;
|
|
64
|
+
if (dbSource.kind === 'url') {
|
|
65
|
+
note(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
|
|
66
|
+
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
67
|
+
} else {
|
|
68
|
+
note('Resolving deployed config...');
|
|
69
|
+
const config = await resolveConfig(flags.stage);
|
|
70
|
+
detail(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
71
|
+
runner = lambdaRunner(config.region, opsFunction(config));
|
|
72
|
+
}
|
|
49
73
|
note(`Introspecting live database (schema: ${schema})...`);
|
|
50
|
-
current = await introspectSchema(
|
|
74
|
+
current = await introspectSchema(runner);
|
|
75
|
+
await end?.();
|
|
51
76
|
} catch (err: any) {
|
|
52
77
|
fail(err.message);
|
|
53
|
-
|
|
78
|
+
if (dbSource.kind === 'url') {
|
|
79
|
+
detail('Check the connection string and that the database is accepting connections.');
|
|
80
|
+
} else {
|
|
81
|
+
detail('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
|
|
82
|
+
detail('Introspecting a local database instead? Pass --database-url or set DATABASE_URL.');
|
|
83
|
+
}
|
|
54
84
|
process.exit(1);
|
|
55
85
|
}
|
|
56
86
|
|
|
57
|
-
const pulled = current
|
|
87
|
+
const pulled = pullableTables(current, schema);
|
|
58
88
|
if (pulled.length === 0) {
|
|
59
89
|
fail(`No tables found in schema "${schema}".`);
|
|
60
90
|
process.exit(1);
|
|
61
91
|
}
|
|
62
92
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
93
|
+
let source: string;
|
|
94
|
+
if (flags.out && !flags.out.endsWith('.ts')) {
|
|
95
|
+
// A directory: one file per model + index.ts — the default shape for a real app.
|
|
96
|
+
const dir = path.resolve(flags.out);
|
|
97
|
+
const files = renderModelFiles(current, { schema });
|
|
98
|
+
try {
|
|
99
|
+
await fs.mkdir(dir, { recursive: true });
|
|
100
|
+
const written = new Set(files.map((f) => f.file));
|
|
101
|
+
const existing = (await fs.readdir(dir)).filter((f) => f.endsWith('.ts') && !written.has(f));
|
|
102
|
+
for (const f of files) await fs.writeFile(path.join(dir, f.file), f.source, 'utf8');
|
|
103
|
+
if (existing.length) {
|
|
104
|
+
caution(`${existing.length} existing .ts file(s) in ${path.relative(process.cwd(), dir)} were not part of this pull and were left untouched: ${existing.join(', ')}`);
|
|
105
|
+
}
|
|
106
|
+
} catch (err: any) {
|
|
107
|
+
fail(`Could not write ${flags.out}: ${err.message}`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
ok(`Wrote ${files.length - 1} model file(s) + index.ts to ${path.relative(process.cwd(), dir)}.`);
|
|
111
|
+
source = files.map((f) => f.source).join('\n');
|
|
112
|
+
} else if (flags.out) {
|
|
66
113
|
const outPath = path.resolve(flags.out);
|
|
114
|
+
source = renderModelSource(current, { schema });
|
|
67
115
|
try {
|
|
68
116
|
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
69
117
|
await fs.writeFile(outPath, source, 'utf8');
|
|
@@ -73,6 +121,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
73
121
|
}
|
|
74
122
|
ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
|
|
75
123
|
} else {
|
|
124
|
+
source = renderModelSource(current, { schema });
|
|
76
125
|
process.stdout.write(source);
|
|
77
126
|
ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
|
|
78
127
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db-source — where db:pull / db:generate find the database.
|
|
3
|
+
*
|
|
4
|
+
* Two paths to the same QueryRunner contract:
|
|
5
|
+
*
|
|
6
|
+
* - **Deployed** (the default): introspection SQL runs through the read-only ops Lambda
|
|
7
|
+
* `db:query` action. Credentials never leave AWS; works for any deployed stage.
|
|
8
|
+
* - **Direct** (`--database-url` or an inherited `DATABASE_URL`): a postgres.js
|
|
9
|
+
* connection from this process. This is the brownfield on-ramp's missing half — when
|
|
10
|
+
* the target schema exists only on a local Postgres (migrations applied locally, no
|
|
11
|
+
* interim deploy), there is no Lambda to ask.
|
|
12
|
+
*
|
|
13
|
+
* Precedence is explicit-over-ambient: `--database-url` beats an explicit `--stage`,
|
|
14
|
+
* which beats an inherited `DATABASE_URL`, which beats the default stage. An explicit
|
|
15
|
+
* `--stage` deliberately wins over the env var so a shell with DATABASE_URL exported
|
|
16
|
+
* still introspects the stage you named.
|
|
17
|
+
*
|
|
18
|
+
* The driver is loaded lazily: `postgres` ships with @everystack/server (every deployed
|
|
19
|
+
* consumer has it hoisted), so the deployed path never pays the import and a missing
|
|
20
|
+
* driver only surfaces — with instructions — when the direct path is actually used.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { QueryRunner } from './authz-contract.js';
|
|
24
|
+
|
|
25
|
+
export type DbSource =
|
|
26
|
+
| { kind: 'url'; url: string; from: 'flag' | 'env' }
|
|
27
|
+
| { kind: 'stage' };
|
|
28
|
+
|
|
29
|
+
/** Decide which path serves this invocation. Pure; env injectable for tests. */
|
|
30
|
+
export function resolveDbSource(
|
|
31
|
+
flags: Record<string, string>,
|
|
32
|
+
env: Record<string, string | undefined> = process.env,
|
|
33
|
+
): DbSource {
|
|
34
|
+
const flagUrl = flags['database-url'];
|
|
35
|
+
if (flagUrl) {
|
|
36
|
+
// parseFlags encodes a value-less flag as 'true'
|
|
37
|
+
if (flagUrl === 'true') {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'--database-url needs a connection string (or omit the flag and set DATABASE_URL in the environment).',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return { kind: 'url', url: flagUrl, from: 'flag' };
|
|
43
|
+
}
|
|
44
|
+
if (flags.stage) return { kind: 'stage' };
|
|
45
|
+
if (env.DATABASE_URL) return { kind: 'url', url: env.DATABASE_URL, from: 'env' };
|
|
46
|
+
return { kind: 'stage' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface UrlRunner {
|
|
50
|
+
runner: QueryRunner;
|
|
51
|
+
/** Close the client so the process can exit cleanly. */
|
|
52
|
+
end: () => Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A QueryRunner over a direct postgres.js connection. One connection is enough —
|
|
57
|
+
* introspection is a handful of sequential catalog queries.
|
|
58
|
+
*/
|
|
59
|
+
export async function createUrlRunner(
|
|
60
|
+
url: string,
|
|
61
|
+
load: () => Promise<any> = () => import('postgres'),
|
|
62
|
+
): Promise<UrlRunner> {
|
|
63
|
+
let mod: any;
|
|
64
|
+
try {
|
|
65
|
+
mod = await load();
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'The direct-connection path needs the "postgres" driver. It ships with @everystack/server; in a project without it: pnpm add -D postgres',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const postgres = mod.default ?? mod;
|
|
72
|
+
const sql = postgres(url, { max: 1, onnotice: () => {} });
|
|
73
|
+
return {
|
|
74
|
+
runner: async (query: string) => Array.from(await sql.unsafe(query)),
|
|
75
|
+
end: () => sql.end({ timeout: 5 }),
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -95,6 +95,13 @@ async function main() {
|
|
|
95
95
|
|
|
96
96
|
const flags = parseFlags(args.slice(1));
|
|
97
97
|
|
|
98
|
+
// `everystack <command> --help` shows help instead of running the command
|
|
99
|
+
// (a db command falling through to the default stage is a footgun).
|
|
100
|
+
if ('help' in flags || 'h' in flags) {
|
|
101
|
+
printHelp();
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
98
105
|
// Auto-detect deployed URL from SST outputs
|
|
99
106
|
await loadSstOutputs(flags.stage);
|
|
100
107
|
|
|
@@ -280,8 +287,9 @@ Usage:
|
|
|
280
287
|
everystack db:backups [--stage <name>] List logical backups (id, size, created)
|
|
281
288
|
everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
|
|
282
289
|
everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
|
|
283
|
-
everystack db:generate [--stage <name>] [--name <label>] [--models models/index.ts] [--allow-drops] Diff models vs the live DB → write the next migration (never touches the DB; DROPs held back unless --allow-drops)
|
|
284
|
-
everystack db:pull [--stage <name>] [--schema public] [--out <file>] Introspect the live DB → render field() Models (the brownfield on-ramp
|
|
290
|
+
everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models models/index.ts] [--allow-drops] Diff models vs the live DB → write the next migration (never touches the DB; DROPs held back unless --allow-drops)
|
|
291
|
+
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise
|
|
292
|
+
Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
|
|
285
293
|
everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
|
|
286
294
|
everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
|
|
287
295
|
everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
|
package/src/cli/model-render.ts
CHANGED
|
@@ -67,6 +67,8 @@ const FIELD_FACTORY: Record<string, string> = {
|
|
|
67
67
|
text: 'text',
|
|
68
68
|
integer: 'integer',
|
|
69
69
|
bigint: 'bigint',
|
|
70
|
+
real: 'real',
|
|
71
|
+
'double precision': 'doublePrecision',
|
|
70
72
|
boolean: 'boolean',
|
|
71
73
|
jsonb: 'jsonb',
|
|
72
74
|
date: 'date',
|
|
@@ -102,6 +104,19 @@ function bareName(table: string): string {
|
|
|
102
104
|
return table.replace(/^[^.]+\./, '');
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
/** Migration infrastructure a pull must never render as a model, whatever schema it landed in. */
|
|
108
|
+
const INFRASTRUCTURE_TABLES = new Set(['__drizzle_migrations']);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The tables a pull renders: the requested schema, minus migration infrastructure.
|
|
112
|
+
* drizzle-kit keeps its journal in the `drizzle` schema, but a restored or legacy
|
|
113
|
+
* database can carry `public.__drizzle_migrations` — the journal is tooling state,
|
|
114
|
+
* never an app model. Exported so the command shell counts the same set it writes.
|
|
115
|
+
*/
|
|
116
|
+
export function pullableTables(snapshot: SchemaSnapshot, schema: string): TableSchema[] {
|
|
117
|
+
return snapshot.tables.filter((t) => t.table.startsWith(`${schema}.`) && !INFRASTRUCTURE_TABLES.has(bareName(t.table)));
|
|
118
|
+
}
|
|
119
|
+
|
|
105
120
|
/** A table name → its model variable: PascalCase, naively singularized. `image_variants` → `ImageVariant`. */
|
|
106
121
|
export function modelVarName(table: string): string {
|
|
107
122
|
const bare = bareName(table);
|
|
@@ -132,20 +147,37 @@ function tsLiteral(value: string): string {
|
|
|
132
147
|
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
133
148
|
}
|
|
134
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The `field.serial()`/`field.bigserial()` factory for an integer/bigint column whose
|
|
152
|
+
* default is its own conventional sequence (`<table>_<column>_seq`, optionally
|
|
153
|
+
* schema-qualified — the deparser qualifies it when the schema is off the search_path).
|
|
154
|
+
* A nextval on any OTHER sequence is not a serial declaration and stays surfaced.
|
|
155
|
+
*/
|
|
156
|
+
function serialFactoryCall(table: TableSchema, col: ColumnSchema): string | null {
|
|
157
|
+
const factory = col.type === 'integer' ? 'serial' : col.type === 'bigint' ? 'bigserial' : null;
|
|
158
|
+
if (!factory || col.default == null) return null;
|
|
159
|
+
const m = col.default.match(/^nextval\('(?:[^'.]+\.)?([^'.]+)'::regclass\)$/);
|
|
160
|
+
if (!m || m[1] !== `${bareName(table.table)}_${col.name}_seq`) return null;
|
|
161
|
+
return `field.${factory}()`;
|
|
162
|
+
}
|
|
163
|
+
|
|
135
164
|
/** One field line: ` authorId: field.uuid().notNull().references(() => Profile),` (+ any surfacing comment). */
|
|
136
165
|
function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>, enums: Map<string, string[]>, validates: Map<string, string>): string {
|
|
137
166
|
// An enum column's type is the enum's name; render the explicit-name field.enum with its
|
|
138
167
|
// values (the always-explicit form round-trips the name and the value order).
|
|
139
168
|
const enumValues = enums.get(col.type);
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
169
|
+
const serial = serialFactoryCall(table, col);
|
|
170
|
+
const call = serial
|
|
171
|
+
?? (enumValues
|
|
172
|
+
? `field.enum(${tsLiteral(col.type)}, [${enumValues.map(tsLiteral).join(', ')}])`
|
|
173
|
+
: fieldFactoryCall(col.type));
|
|
143
174
|
let expr = call ?? 'field.text()';
|
|
144
175
|
let comment = call ? '' : ` // FIXME: column type '${col.type}' has no field mapping`;
|
|
145
176
|
|
|
146
177
|
const isPk = table.primaryKey.includes(col.name);
|
|
147
178
|
if (isPk) expr += '.primaryKey()';
|
|
148
|
-
|
|
179
|
+
// A serial field is NOT NULL by construction, so the modifier would be redundant.
|
|
180
|
+
else if (col.notNull && !serial) expr += '.notNull()';
|
|
149
181
|
|
|
150
182
|
if (table.uniques.some((u) => u.columns.length === 1 && u.columns[0] === col.name)) expr += '.unique()';
|
|
151
183
|
|
|
@@ -161,7 +193,7 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
161
193
|
} else comment += ` // FK → ${fk.refTable} (not in the pulled set)`;
|
|
162
194
|
}
|
|
163
195
|
|
|
164
|
-
if (col.default != null) {
|
|
196
|
+
if (col.default != null && !serial) {
|
|
165
197
|
const d = renderDefault(col.default);
|
|
166
198
|
if (d) expr += d;
|
|
167
199
|
else comment += ` // TODO: unmapped default ${col.default}`;
|
|
@@ -232,6 +264,19 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
232
264
|
return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n fields: {\n${fields}\n },${constraints}\n});`;
|
|
233
265
|
}
|
|
234
266
|
|
|
267
|
+
/** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
|
|
268
|
+
function importHeader(body: string): string {
|
|
269
|
+
const symbols = ['defineModel', 'field'];
|
|
270
|
+
if (/\bunique\(/.test(body)) symbols.push('unique');
|
|
271
|
+
if (/\bcheck\(/.test(body)) symbols.push('check');
|
|
272
|
+
if (/\bindex\(/.test(body)) symbols.push('index');
|
|
273
|
+
if (/\bforeignKey\(/.test(body)) symbols.push('foreignKey');
|
|
274
|
+
if (/\bsql`/.test(body)) symbols.push('sql');
|
|
275
|
+
const imports = [`import { ${symbols.join(', ')} } from '@everystack/model';`];
|
|
276
|
+
if (/\.validate\(/.test(body)) imports.push(`import { z } from 'zod';`);
|
|
277
|
+
return imports.join('\n');
|
|
278
|
+
}
|
|
279
|
+
|
|
235
280
|
/**
|
|
236
281
|
* Render a whole snapshot as a single Models module: the import, one block per table (scoped
|
|
237
282
|
* to `opts.schema`), and the `models` array the rest of the framework consumes. The tables
|
|
@@ -239,24 +284,71 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
239
284
|
*/
|
|
240
285
|
export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions = {}): string {
|
|
241
286
|
const schema = opts.schema ?? 'public';
|
|
242
|
-
const tables = snapshot
|
|
287
|
+
const tables = pullableTables(snapshot, schema);
|
|
243
288
|
const known = new Set(tables.map((t) => bareName(t.table)));
|
|
244
289
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
245
290
|
|
|
246
291
|
const blocks = tables.map((t) => renderModelBlock(t, known, enums));
|
|
247
292
|
const body = blocks.join('\n\n');
|
|
248
293
|
|
|
249
|
-
|
|
250
|
-
const symbols = ['defineModel', 'field'];
|
|
251
|
-
if (/\bunique\(/.test(body)) symbols.push('unique');
|
|
252
|
-
if (/\bcheck\(/.test(body)) symbols.push('check');
|
|
253
|
-
if (/\bindex\(/.test(body)) symbols.push('index');
|
|
254
|
-
if (/\bforeignKey\(/.test(body)) symbols.push('foreignKey');
|
|
255
|
-
if (/\bsql`/.test(body)) symbols.push('sql');
|
|
256
|
-
const imports = [`import { ${symbols.join(', ')} } from '@everystack/model';`];
|
|
257
|
-
if (/\.validate\(/.test(body)) imports.push(`import { z } from 'zod';`);
|
|
258
|
-
const header = imports.join('\n');
|
|
294
|
+
const header = importHeader(body);
|
|
259
295
|
const footer = `export const models = [${tables.map((t) => modelVarName(t.table)).join(', ')}];`;
|
|
260
296
|
|
|
261
297
|
return [header, ...blocks, footer].join('\n\n') + '\n';
|
|
262
298
|
}
|
|
299
|
+
|
|
300
|
+
/** `image_variants` → `image-variants.ts` — the repo's kebab-case file convention. */
|
|
301
|
+
function modelFileName(table: string): string {
|
|
302
|
+
return `${bareName(table).replace(/_/g, '-')}.ts`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The bare tables (other than itself) a table's rendered FKs reference within the pulled set. */
|
|
306
|
+
function referencedTables(table: TableSchema, known: Set<string>): string[] {
|
|
307
|
+
const self = bareName(table.table);
|
|
308
|
+
const refs = new Set<string>();
|
|
309
|
+
for (const fk of table.foreignKeys) {
|
|
310
|
+
const target = bareName(fk.refTable);
|
|
311
|
+
if (target !== self && known.has(target)) refs.add(target);
|
|
312
|
+
}
|
|
313
|
+
return [...refs].sort();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface RenderedModelFile {
|
|
317
|
+
/** File name relative to the output directory (`posts.ts`, …, `index.ts` last). */
|
|
318
|
+
file: string;
|
|
319
|
+
source: string;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Render a snapshot as one file per model plus an `index.ts` — the shape a real app keeps
|
|
324
|
+
* its models in (a 200-table pull as a single module is unreviewable). Each model file
|
|
325
|
+
* imports its own `@everystack/model` symbols and its FK targets from their sibling files
|
|
326
|
+
* (extensionless specifiers — Metro and TS bundler resolution both take them; the
|
|
327
|
+
* `() => Model` thunks make circular FK imports safe). The index re-exports every model
|
|
328
|
+
* and assembles the `models` array the rest of the framework consumes.
|
|
329
|
+
*/
|
|
330
|
+
export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions = {}): RenderedModelFile[] {
|
|
331
|
+
const schema = opts.schema ?? 'public';
|
|
332
|
+
const tables = pullableTables(snapshot, schema);
|
|
333
|
+
const known = new Set(tables.map((t) => bareName(t.table)));
|
|
334
|
+
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
335
|
+
|
|
336
|
+
const files: RenderedModelFile[] = tables.map((t) => {
|
|
337
|
+
const block = renderModelBlock(t, known, enums);
|
|
338
|
+
const crossImports = referencedTables(t, known).map(
|
|
339
|
+
(target) => `import { ${modelVarName(target)} } from './${bareName(target).replace(/_/g, '-')}';`,
|
|
340
|
+
);
|
|
341
|
+
const header = [importHeader(block), ...crossImports].join('\n');
|
|
342
|
+
return { file: modelFileName(t.table), source: `${header}\n\n${block}\n` };
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const names = tables.map((t) => modelVarName(t.table));
|
|
346
|
+
const index = [
|
|
347
|
+
names.map((n, i) => `import { ${n} } from './${bareName(tables[i].table).replace(/_/g, '-')}';`).join('\n'),
|
|
348
|
+
`export {\n${names.map((n) => ` ${n},`).join('\n')}\n};`,
|
|
349
|
+
`export const models = [\n${names.map((n) => ` ${n},`).join('\n')}\n];`,
|
|
350
|
+
].join('\n\n');
|
|
351
|
+
files.push({ file: 'index.ts', source: index + '\n' });
|
|
352
|
+
|
|
353
|
+
return files;
|
|
354
|
+
}
|
package/src/cli/output.ts
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* No spinner library — step markers work in CI/CD and terminals alike.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
// Progress is diagnostics, never data — stderr, so a codegen command (db:pull) keeps a
|
|
8
|
+
// pure stdout for redirects even when shared plumbing (resolveConfig discovery) reports.
|
|
7
9
|
export function step(msg: string): void {
|
|
8
|
-
console.
|
|
10
|
+
console.error(` > ${msg}`);
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export function success(msg: string): void {
|
|
@@ -68,7 +68,14 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
|
|
|
68
68
|
return { uniques, checks, indexes };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Postgres type for a field, with the precision drizzle-kit would emit — in
|
|
73
|
+
* `format_type`'s vocabulary, because this feeds the IR that diffs against
|
|
74
|
+
* introspection. A serial field's *column type* is integer/bigint (`serial` is
|
|
75
|
+
* DDL shorthand, not a type — `format_type` never reports it); the sequence
|
|
76
|
+
* default is carried separately (see compileTableSchema), and the DDL renderers
|
|
77
|
+
* spell the shorthand (see SERIAL_DDL).
|
|
78
|
+
*/
|
|
72
79
|
const PG_TYPE: Record<string, string> = {
|
|
73
80
|
text: 'text',
|
|
74
81
|
uuid: 'uuid',
|
|
@@ -76,7 +83,10 @@ const PG_TYPE: Record<string, string> = {
|
|
|
76
83
|
boolean: 'boolean',
|
|
77
84
|
jsonb: 'jsonb',
|
|
78
85
|
bigint: 'bigint',
|
|
79
|
-
|
|
86
|
+
serial: 'integer',
|
|
87
|
+
bigserial: 'bigint',
|
|
88
|
+
real: 'real',
|
|
89
|
+
doublePrecision: 'double precision',
|
|
80
90
|
timestamptz: 'timestamp with time zone',
|
|
81
91
|
// `format_type` canonicalizes a bare `timestamp` column to this spelling, and
|
|
82
92
|
// introspection compares against it — so the compiler must emit the same, or an
|
|
@@ -221,15 +231,26 @@ function literal(value: unknown): string {
|
|
|
221
231
|
return String(value);
|
|
222
232
|
}
|
|
223
233
|
|
|
234
|
+
/** The DDL shorthand for the sequence-backed types — CREATE/ADD COLUMN must use it (it creates the sequence). */
|
|
235
|
+
const SERIAL_DDL: Partial<Record<FieldSpec['type'], string>> = { serial: 'serial', bigserial: 'bigserial' };
|
|
236
|
+
|
|
237
|
+
/** The implicit sequence a serial column owns, as `pg_get_expr` deparses its default. */
|
|
238
|
+
function serialDefault(table: string, sqlName: string): string {
|
|
239
|
+
return `nextval('${table}_${sqlName}_seq'::regclass)`;
|
|
240
|
+
}
|
|
241
|
+
|
|
224
242
|
/**
|
|
225
243
|
* One column definition (no leading indent). Clause order matches drizzle's:
|
|
226
244
|
* type, PRIMARY KEY (single-column only), DEFAULT, NOT NULL. `.references()`,
|
|
227
245
|
* `.unique()`, and composite PKs are table-level, emitted by compileCreateTable.
|
|
246
|
+
* A serial field renders the `serial` shorthand and no DEFAULT — the shorthand
|
|
247
|
+
* creates the backing sequence a bare nextval default could not reference.
|
|
228
248
|
*/
|
|
229
249
|
export function fieldColumnSql(sqlName: string, spec: FieldSpec, inlinePrimaryKey: boolean): string {
|
|
230
|
-
|
|
250
|
+
const serial = SERIAL_DDL[spec.type];
|
|
251
|
+
let s = `"${sqlName}" ${serial ?? pgType(spec)}`;
|
|
231
252
|
if (inlinePrimaryKey) s += ' PRIMARY KEY';
|
|
232
|
-
const def = defaultExpr(spec);
|
|
253
|
+
const def = serial ? null : defaultExpr(spec);
|
|
233
254
|
if (def) s += ` DEFAULT ${def}`;
|
|
234
255
|
if (spec.isNotNull) s += ' NOT NULL';
|
|
235
256
|
return s;
|
|
@@ -394,12 +415,23 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
|
|
|
394
415
|
const schema = model.schema ?? opts.schema ?? 'public';
|
|
395
416
|
const entries = Object.entries(model.fields);
|
|
396
417
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
418
|
+
// A serial column's IR is the pg-precise truth introspection reports: integer/bigint
|
|
419
|
+
// (PG_TYPE), NOT NULL, and the implicit sequence's nextval default — so an unchanged
|
|
420
|
+
// serial column diffs clean. The `serial` shorthand exists only in the DDL renderers.
|
|
421
|
+
const columns = entries.map(([name, field]) => {
|
|
422
|
+
const sqlName = toSnakeCase(name);
|
|
423
|
+
const isSerial = field.spec.type in SERIAL_DDL;
|
|
424
|
+
return {
|
|
425
|
+
name: sqlName,
|
|
426
|
+
type: pgType(field.spec),
|
|
427
|
+
notNull: field.spec.isNotNull || isSerial,
|
|
428
|
+
// The deparser qualifies the sequence only when its schema is off the search_path,
|
|
429
|
+
// so a public table's sequence stays bare and a package-schema one is qualified.
|
|
430
|
+
default: isSerial
|
|
431
|
+
? serialDefault(schema === 'public' ? model.table : `${schema}.${model.table}`, sqlName)
|
|
432
|
+
: defaultExpr(field.spec),
|
|
433
|
+
};
|
|
434
|
+
});
|
|
403
435
|
|
|
404
436
|
const tableConstraints = modelConstraintSpecs(model);
|
|
405
437
|
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -111,6 +111,12 @@ export function normalizeDefault(expr: string | null): string | null {
|
|
|
111
111
|
} while (s !== prev);
|
|
112
112
|
|
|
113
113
|
if (/^current_timestamp$/i.test(s)) return 'now()';
|
|
114
|
+
|
|
115
|
+
// The deparser qualifies a sequence name only when its schema is off the search_path,
|
|
116
|
+
// so the same serial default reads `nextval('x_seq')` on one database and
|
|
117
|
+
// `nextval('public.x_seq')` on another. public is always on the search_path — strip it.
|
|
118
|
+
s = s.replace(/^nextval\('public\.([^']+)'/, "nextval('$1'");
|
|
119
|
+
|
|
114
120
|
return s;
|
|
115
121
|
}
|
|
116
122
|
|
|
@@ -124,6 +130,9 @@ export type TypeChangeRisk = 'safe' | 'lossy' | 'risky';
|
|
|
124
130
|
/** Integer/numeric widening rank — a value at a lower rank always fits a higher one. */
|
|
125
131
|
const NUMERIC_RANK: Record<string, number> = { smallint: 1, integer: 2, bigint: 3, numeric: 4 };
|
|
126
132
|
|
|
133
|
+
/** Float widening rank — float4 always fits float8. */
|
|
134
|
+
const FLOAT_RANK: Record<string, number> = { real: 1, 'double precision': 2 };
|
|
135
|
+
|
|
127
136
|
/** Split a `format_type` string into its base and any precision/length args: `numeric(12,2)` -> {base:'numeric', args:[12,2]}. */
|
|
128
137
|
function parseType(t: string): { base: string; args: number[] } {
|
|
129
138
|
const m = t.match(/^(.*?)\s*\(([^)]*)\)\s*$/);
|
|
@@ -157,6 +166,18 @@ export function classifyTypeChange(from: string, to: string): TypeChangeRisk {
|
|
|
157
166
|
return tr > fr ? 'safe' : 'lossy'; // widen up the integer/numeric ranks is safe; down can overflow
|
|
158
167
|
}
|
|
159
168
|
|
|
169
|
+
const ff = FLOAT_RANK[f.base];
|
|
170
|
+
const tf = FLOAT_RANK[t.base];
|
|
171
|
+
if (ff && tf) return tf >= ff ? 'safe' : 'lossy'; // float widening is exact; narrowing rounds
|
|
172
|
+
// A true integer type into a float is safe only when the mantissa provably holds every
|
|
173
|
+
// value (smallint fits both; integer fits float8's 53 bits but not float4's 24);
|
|
174
|
+
// otherwise it rounds — lossy, never failing. numeric is excluded (its range exceeds
|
|
175
|
+
// float8, so the cast can fail), and a float into anything narrower falls through to
|
|
176
|
+
// risky: an out-of-range cast (1e300::integer) fails per row.
|
|
177
|
+
if ((f.base === 'smallint' || f.base === 'integer' || f.base === 'bigint') && tf) {
|
|
178
|
+
return f.base === 'smallint' || (f.base === 'integer' && t.base === 'double precision') ? 'safe' : 'lossy';
|
|
179
|
+
}
|
|
180
|
+
|
|
160
181
|
if (f.base === t.base) return argsWiden(f.args, t.args) ? 'safe' : 'lossy'; // same base, only precision/length moved
|
|
161
182
|
|
|
162
183
|
const timestamps = new Set(['timestamp', 'timestamp with time zone', 'timestamp without time zone']);
|
|
@@ -408,16 +429,25 @@ function diffColumns(d: TableSchema, c: TableSchema, tableRenames: Record<string
|
|
|
408
429
|
return out;
|
|
409
430
|
}
|
|
410
431
|
|
|
432
|
+
/** A table's uniques and foreign keys as ConstraintSpecs (the shape addConstraintSql renders). */
|
|
433
|
+
function tableConstraintSpecs(t: TableSchema): ConstraintSpec[] {
|
|
434
|
+
return [
|
|
435
|
+
...t.uniques.map((u): ConstraintSpec => ({ type: 'unique', name: u.name, columns: u.columns })),
|
|
436
|
+
...t.foreignKeys.map((fk): ConstraintSpec => ({ type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate })),
|
|
437
|
+
];
|
|
438
|
+
}
|
|
439
|
+
|
|
411
440
|
/**
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
441
|
+
* The identity a unique/FK is matched on — its content, never its name. A pulled database
|
|
442
|
+
* carries Postgres default names (`posts_author_id_fkey`, `posts_slug_key`) while the
|
|
443
|
+
* compiler names drizzle-style; a same-content constraint under either name is the same
|
|
444
|
+
* constraint (the rule checks already follow). Matching by name would read every brownfield
|
|
445
|
+
* FK as drift and emit an ADD that duplicates a constraint the table already has.
|
|
415
446
|
*/
|
|
416
|
-
function
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
return m;
|
|
447
|
+
function constraintContentKey(c: ConstraintSpec): string {
|
|
448
|
+
if (c.type === 'unique') return `u|${c.columns.join(',')}`;
|
|
449
|
+
if (c.type === 'foreignKey') return `f|${c.columns.join(',')}|${c.refTable}|${c.refColumns.join(',')}|${c.onDelete ?? ''}|${c.onUpdate ?? ''}`;
|
|
450
|
+
return `c|${normalizeCheck(c.expr)}`; // checks take their own predicate-matched path (diffChecks)
|
|
421
451
|
}
|
|
422
452
|
|
|
423
453
|
/**
|
|
@@ -437,32 +467,25 @@ function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChang
|
|
|
437
467
|
}
|
|
438
468
|
}
|
|
439
469
|
|
|
440
|
-
function constraintsEqual(a: ConstraintSpec, b: ConstraintSpec): boolean {
|
|
441
|
-
if (a.type !== b.type) return false;
|
|
442
|
-
if (a.type === 'unique' && b.type === 'unique') return a.columns.join(',') === b.columns.join(',');
|
|
443
|
-
if (a.type === 'check' && b.type === 'check') return normalizeCheck(a.expr) === normalizeCheck(b.expr);
|
|
444
|
-
if (a.type === 'foreignKey' && b.type === 'foreignKey') {
|
|
445
|
-
return a.columns.join(',') === b.columns.join(',')
|
|
446
|
-
&& a.refTable === b.refTable
|
|
447
|
-
&& a.refColumns.join(',') === b.refColumns.join(',')
|
|
448
|
-
&& (a.onDelete ?? '') === (b.onDelete ?? '')
|
|
449
|
-
&& (a.onUpdate ?? '') === (b.onUpdate ?? '');
|
|
450
|
-
}
|
|
451
|
-
return false;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
470
|
function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: SchemaChange[], addConstraints: SchemaChange[], alters: SchemaChange[]): void {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
//
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
471
|
+
// Multiset-match uniques and FKs by content: a desired constraint that finds a
|
|
472
|
+
// same-content current one is unchanged (whatever either is named); an unmatched
|
|
473
|
+
// desired is an add; a leftover current is dropped by its real name. A *changed*
|
|
474
|
+
// constraint therefore falls out as drop + add, never a silent mutation.
|
|
475
|
+
const currentByKey = new Map<string, ConstraintSpec[]>();
|
|
476
|
+
for (const cur of tableConstraintSpecs(c)) {
|
|
477
|
+
const key = constraintContentKey(cur);
|
|
478
|
+
const bucket = currentByKey.get(key);
|
|
479
|
+
if (bucket) bucket.push(cur);
|
|
480
|
+
else currentByKey.set(key, [cur]);
|
|
481
|
+
}
|
|
482
|
+
for (const des of tableConstraintSpecs(d)) {
|
|
483
|
+
const bucket = currentByKey.get(constraintContentKey(des));
|
|
484
|
+
if (bucket?.length) bucket.pop();
|
|
485
|
+
else addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: des });
|
|
462
486
|
}
|
|
463
|
-
for (const
|
|
464
|
-
const cur
|
|
465
|
-
if (!cur || !constraintsEqual(des, cur)) addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: des });
|
|
487
|
+
for (const bucket of currentByKey.values()) {
|
|
488
|
+
for (const cur of bucket) dropConstraints.push({ kind: 'dropConstraint', table: d.table, name: cur.name });
|
|
466
489
|
}
|
|
467
490
|
|
|
468
491
|
diffChecks(d, c, dropConstraints, addConstraints);
|
|
@@ -486,10 +509,24 @@ const colList = (cols: string[]): string => cols.map(quote).join(', ');
|
|
|
486
509
|
/** A single-quoted enum value literal, doubling any embedded quote. */
|
|
487
510
|
const enumLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
|
|
488
511
|
|
|
512
|
+
/**
|
|
513
|
+
* The `serial`/`bigserial` DDL shorthand for a column whose IR is integer/bigint with a
|
|
514
|
+
* nextval default (how compileTableSchema and introspection both represent a serial
|
|
515
|
+
* column), or null. CREATE/ADD COLUMN must use the shorthand — it creates the backing
|
|
516
|
+
* sequence, which a bare `DEFAULT nextval(...)` would reference before it exists.
|
|
517
|
+
*/
|
|
518
|
+
function serialSpelling(col: ColumnSchema): string | null {
|
|
519
|
+
if (col.default == null || !/^nextval\('[^']+'::regclass\)$/.test(col.default)) return null;
|
|
520
|
+
if (col.type === 'integer') return 'serial';
|
|
521
|
+
if (col.type === 'bigint') return 'bigserial';
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
|
|
489
525
|
/** A column definition body (no leading indent): `"name" type [DEFAULT expr] [NOT NULL]`. */
|
|
490
526
|
function columnSql(col: ColumnSchema): string {
|
|
491
|
-
|
|
492
|
-
|
|
527
|
+
const serial = serialSpelling(col);
|
|
528
|
+
let s = `${quote(col.name)} ${serial ?? col.type}`;
|
|
529
|
+
if (!serial && col.default != null) s += ` DEFAULT ${col.default}`;
|
|
493
530
|
if (col.notNull) s += ' NOT NULL';
|
|
494
531
|
return s;
|
|
495
532
|
}
|
package/src/cli/schema-source.ts
CHANGED
|
@@ -69,8 +69,14 @@ function baseColumnSource(columnName: string, spec: FieldSpec): { call: string;
|
|
|
69
69
|
return { call: `integer(${n})`, builder: 'integer' };
|
|
70
70
|
case 'bigint':
|
|
71
71
|
return { call: `bigint(${n}, { mode: 'number' })`, builder: 'bigint' };
|
|
72
|
+
case 'serial':
|
|
73
|
+
return { call: `serial(${n})`, builder: 'serial' };
|
|
72
74
|
case 'bigserial':
|
|
73
75
|
return { call: `bigserial(${n}, { mode: 'number' })`, builder: 'bigserial' };
|
|
76
|
+
case 'real':
|
|
77
|
+
return { call: `real(${n})`, builder: 'real' };
|
|
78
|
+
case 'doublePrecision':
|
|
79
|
+
return { call: `doublePrecision(${n})`, builder: 'doublePrecision' };
|
|
74
80
|
case 'boolean':
|
|
75
81
|
return { call: `boolean(${n})`, builder: 'boolean' };
|
|
76
82
|
case 'timestamptz':
|