@pvidaal07/heydb 0.0.0-stage → 2.0.0-beta.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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/extensions/commands.ts +732 -0
- package/extensions/context.ts +37 -0
- package/extensions/deps.ts +32 -0
- package/extensions/heydb.ts +20 -0
- package/extensions/secret-input.ts +120 -0
- package/extensions/tools.ts +402 -0
- package/lib/about.ts +7 -0
- package/lib/config/operations.ts +75 -0
- package/lib/config/store.ts +73 -0
- package/lib/config/types.ts +37 -0
- package/lib/config/validate.ts +136 -0
- package/lib/engines/index.ts +66 -0
- package/lib/engines/mysql/catalog.ts +371 -0
- package/lib/engines/mysql/introspect.ts +186 -0
- package/lib/engines/postgres/catalog.ts +390 -0
- package/lib/engines/postgres/introspect.ts +240 -0
- package/lib/knowledge/operations.ts +223 -0
- package/lib/knowledge/query.ts +129 -0
- package/lib/knowledge/types.ts +64 -0
- package/lib/knowledge/validate.ts +151 -0
- package/lib/model/diff.ts +218 -0
- package/lib/model/hash.ts +88 -0
- package/lib/model/names.ts +141 -0
- package/lib/model/order.ts +47 -0
- package/lib/model/references.ts +251 -0
- package/lib/model/snapshot.ts +128 -0
- package/lib/model/stats.ts +97 -0
- package/lib/model/validate.ts +240 -0
- package/lib/render/markdown.ts +508 -0
- package/lib/search.ts +530 -0
- package/lib/secrets.ts +149 -0
- package/lib/store/envfile.ts +124 -0
- package/lib/store/files.ts +57 -0
- package/lib/store/guards.ts +68 -0
- package/lib/store/knowledge.ts +139 -0
- package/lib/store/project.ts +28 -0
- package/lib/store/snapshots.ts +142 -0
- package/lib/usecases/author.ts +51 -0
- package/lib/usecases/code-scan.ts +435 -0
- package/lib/usecases/init.ts +111 -0
- package/lib/usecases/knowledge.ts +214 -0
- package/lib/usecases/project.ts +377 -0
- package/lib/usecases/resolve.ts +186 -0
- package/lib/usecases/sharing.ts +83 -0
- package/lib/usecases/status.ts +37 -0
- package/lib/usecases/sync.ts +33 -0
- package/package.json +69 -5
- package/skills/heydb/SKILL.md +67 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { formatName } from "./names.ts";
|
|
2
|
+
import { sortSnapshot } from "./order.ts";
|
|
3
|
+
import type { Column, ForeignKey, Index, Routine, Schema, Snapshot, Table, Trigger } from "./snapshot.ts";
|
|
4
|
+
|
|
5
|
+
// The schema hash says WHETHER the structure changed; the diff says WHAT
|
|
6
|
+
// changed. Both must agree: the diff compares exactly the fields hash.ts
|
|
7
|
+
// copies into its canonical form, and nothing else (never capture time,
|
|
8
|
+
// engine version, row estimates or warnings). If hash.ts gains a field, this
|
|
9
|
+
// file must compare it too; a test checks that both always agree.
|
|
10
|
+
//
|
|
11
|
+
// Objects are matched by name. A renamed column therefore shows up as one
|
|
12
|
+
// removed and one added column: the catalog cannot tell a rename from a drop
|
|
13
|
+
// plus a create, and a wrong guess would be worse than no guess.
|
|
14
|
+
|
|
15
|
+
export type ChangeKind = "added" | "removed" | "modified";
|
|
16
|
+
|
|
17
|
+
export type ChangeObject =
|
|
18
|
+
| "database"
|
|
19
|
+
| "schema"
|
|
20
|
+
| "table"
|
|
21
|
+
| "view"
|
|
22
|
+
| "column"
|
|
23
|
+
| "index"
|
|
24
|
+
| "foreignKey"
|
|
25
|
+
| "trigger"
|
|
26
|
+
| "routine";
|
|
27
|
+
|
|
28
|
+
export interface Change {
|
|
29
|
+
readonly kind: ChangeKind;
|
|
30
|
+
readonly object: ChangeObject;
|
|
31
|
+
/**
|
|
32
|
+
* Canonical name of the object. Indexes, foreign keys and triggers belong to a
|
|
33
|
+
* table, so they are named like a column of it: "shop.shop.orders.idx_customer".
|
|
34
|
+
*/
|
|
35
|
+
readonly target: string;
|
|
36
|
+
/** For modified objects, one line per changed field, e.g. "type: int → bigint". */
|
|
37
|
+
readonly details: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function diffSnapshots(before: Snapshot, after: Snapshot): Change[] {
|
|
41
|
+
const changes: Change[] = [];
|
|
42
|
+
const add = (kind: ChangeKind, object: ChangeObject, target: string, details: readonly string[] = []) =>
|
|
43
|
+
changes.push({ kind, object, target, details });
|
|
44
|
+
|
|
45
|
+
// sortSnapshot puts both sides in canonical form first (columns by position,
|
|
46
|
+
// trigger events sorted), so lists compare exactly as the hash sees them.
|
|
47
|
+
const a = sortSnapshot(before);
|
|
48
|
+
const b = sortSnapshot(after);
|
|
49
|
+
|
|
50
|
+
matchByKey(a.databases, b.databases, (database) => database.name, {
|
|
51
|
+
added: (database) => add("added", "database", databaseName(database.name)),
|
|
52
|
+
removed: (database) => add("removed", "database", databaseName(database.name)),
|
|
53
|
+
both: (old, current) =>
|
|
54
|
+
matchByKey(old.schemas, current.schemas, (schema) => schema.name, {
|
|
55
|
+
added: (schema) => add("added", "schema", formatName({ database: current.name, schema: schema.name })),
|
|
56
|
+
removed: (schema) => add("removed", "schema", formatName({ database: current.name, schema: schema.name })),
|
|
57
|
+
both: (oldSchema, schema) => diffSchema(current.name, oldSchema, schema, add),
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return changes.sort(compareChanges);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type AddChange = (kind: ChangeKind, object: ChangeObject, target: string, details?: readonly string[]) => void;
|
|
65
|
+
|
|
66
|
+
function diffSchema(database: string, before: Schema, after: Schema, add: AddChange): void {
|
|
67
|
+
const schema = after.name;
|
|
68
|
+
const tableName = (table: Table) => formatName({ database, schema, table: table.name });
|
|
69
|
+
|
|
70
|
+
matchByKey(before.tables, after.tables, (table) => table.name, {
|
|
71
|
+
added: (table) => add("added", table.kind, tableName(table)),
|
|
72
|
+
removed: (table) => add("removed", table.kind, tableName(table)),
|
|
73
|
+
both: (old, current) => diffTable(tableName(current), { database, schema, table: current.name }, old, current, add),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// MySQL lets a procedure and a function share a name, so the kind is part of
|
|
77
|
+
// the key: a procedure replaced by a function is one removed and one added.
|
|
78
|
+
const routineName = (routine: Routine) => formatName({ database, schema, table: routine.name });
|
|
79
|
+
matchByKey(before.routines, after.routines, (routine) => `${routine.kind}\u0000${routine.name}`, {
|
|
80
|
+
added: (routine) => add("added", "routine", routineName(routine), [routine.kind]),
|
|
81
|
+
removed: (routine) => add("removed", "routine", routineName(routine), [routine.kind]),
|
|
82
|
+
both: () => {},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface TableRef {
|
|
87
|
+
readonly database: string;
|
|
88
|
+
readonly schema: string;
|
|
89
|
+
readonly table: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function diffTable(target: string, ref: TableRef, before: Table, after: Table, add: AddChange): void {
|
|
93
|
+
const details = present([
|
|
94
|
+
field("kind", before.kind, after.kind),
|
|
95
|
+
field("comment", quote(before.comment), quote(after.comment)),
|
|
96
|
+
field("storage engine", quote(before.storageEngine), quote(after.storageEngine)),
|
|
97
|
+
field("primary key", list(before.primaryKey), list(after.primaryKey)),
|
|
98
|
+
before.viewDefinition === after.viewDefinition ? null : "definition changed",
|
|
99
|
+
]);
|
|
100
|
+
if (details.length > 0) add("modified", after.kind, target, details);
|
|
101
|
+
|
|
102
|
+
const childName = (name: string) => formatName({ ...ref, column: name });
|
|
103
|
+
const diffChildren = <T extends { readonly name: string }>(
|
|
104
|
+
object: ChangeObject,
|
|
105
|
+
old: readonly T[],
|
|
106
|
+
current: readonly T[],
|
|
107
|
+
compare: (a: T, b: T) => string[],
|
|
108
|
+
) =>
|
|
109
|
+
matchByKey(old, current, (item) => item.name, {
|
|
110
|
+
added: (item) => add("added", object, childName(item.name)),
|
|
111
|
+
removed: (item) => add("removed", object, childName(item.name)),
|
|
112
|
+
both: (a, b) => {
|
|
113
|
+
const changed = compare(a, b);
|
|
114
|
+
if (changed.length > 0) add("modified", object, childName(b.name), changed);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
diffChildren("column", before.columns, after.columns, compareColumns);
|
|
119
|
+
diffChildren("index", before.indexes, after.indexes, compareIndexes);
|
|
120
|
+
diffChildren("foreignKey", before.foreignKeys, after.foreignKeys, compareForeignKeys);
|
|
121
|
+
diffChildren("trigger", before.triggers, after.triggers, compareTriggers);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function compareColumns(a: Column, b: Column): string[] {
|
|
125
|
+
return present([
|
|
126
|
+
field("position", String(a.position), String(b.position)),
|
|
127
|
+
field("type", a.type, b.type),
|
|
128
|
+
field("nullable", String(a.nullable), String(b.nullable)),
|
|
129
|
+
field("default", quote(a.default), quote(b.default)),
|
|
130
|
+
field("comment", quote(a.comment), quote(b.comment)),
|
|
131
|
+
field("auto increment", String(a.autoIncrement), String(b.autoIncrement)),
|
|
132
|
+
field("generated", generated(a.generated), generated(b.generated)),
|
|
133
|
+
field("collation", quote(a.collation), quote(b.collation)),
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function compareIndexes(a: Index, b: Index): string[] {
|
|
138
|
+
return present([
|
|
139
|
+
field("columns", list(a.columns), list(b.columns)),
|
|
140
|
+
field("unique", String(a.unique), String(b.unique)),
|
|
141
|
+
field("method", a.method, b.method),
|
|
142
|
+
]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function compareForeignKeys(a: ForeignKey, b: ForeignKey): string[] {
|
|
146
|
+
return present([
|
|
147
|
+
field("columns", list(a.columns), list(b.columns)),
|
|
148
|
+
field("references", references(a), references(b)),
|
|
149
|
+
field("onDelete", a.onDelete, b.onDelete),
|
|
150
|
+
field("onUpdate", a.onUpdate, b.onUpdate),
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function compareTriggers(a: Trigger, b: Trigger): string[] {
|
|
155
|
+
return present([field("timing", a.timing, b.timing), field("events", list(a.events), list(b.events))]);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Pairs up two lists by key and calls one handler per key: only in `before`
|
|
159
|
+
// (removed), only in `after` (added), or in both. The final sort fixes the order.
|
|
160
|
+
function matchByKey<T>(
|
|
161
|
+
before: readonly T[],
|
|
162
|
+
after: readonly T[],
|
|
163
|
+
key: (item: T) => string,
|
|
164
|
+
on: { added: (item: T) => void; removed: (item: T) => void; both: (before: T, after: T) => void },
|
|
165
|
+
): void {
|
|
166
|
+
const old = new Map(before.map((item) => [key(item), item]));
|
|
167
|
+
const current = new Map(after.map((item) => [key(item), item]));
|
|
168
|
+
for (const [name, item] of old) {
|
|
169
|
+
const match = current.get(name);
|
|
170
|
+
if (match === undefined) on.removed(item);
|
|
171
|
+
else on.both(item, match);
|
|
172
|
+
}
|
|
173
|
+
for (const [name, item] of current) {
|
|
174
|
+
if (!old.has(name)) on.added(item);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** "name: before → after", or null when both sides print the same. */
|
|
179
|
+
function field(name: string, before: string, after: string): string | null {
|
|
180
|
+
return before === after ? null : `${name}: ${before} → ${after}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function present(details: (string | null)[]): string[] {
|
|
184
|
+
return details.filter((detail) => detail !== null);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Free text is quoted SQL-style so null, "" and the text "NULL" never look alike.
|
|
188
|
+
function quote(value: string | null): string {
|
|
189
|
+
return value === null ? "null" : `'${value.replaceAll("'", "''")}'`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function list(items: readonly string[]): string {
|
|
193
|
+
return `(${items.join(", ")})`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function generated(value: Column["generated"]): string {
|
|
197
|
+
return value === null ? "null" : `${value.expression} ${value.stored ? "STORED" : "VIRTUAL"}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function references(foreignKey: ForeignKey): string {
|
|
201
|
+
const { database, schema, table, columns } = foreignKey.references;
|
|
202
|
+
return `${formatName({ database, schema, table })}${list(columns)}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// formatName needs at least a schema. Formatting with an empty schema (always
|
|
206
|
+
// written as "") and cutting it off reuses its quoting rule for a database alone.
|
|
207
|
+
function databaseName(name: string): string {
|
|
208
|
+
return formatName({ database: name, schema: "" }).slice(0, -'.""'.length);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function compareChanges(a: Change, b: Change): number {
|
|
212
|
+
return compareText(a.target, b.target) || compareText(a.object, b.object) || compareText(a.kind, b.kind);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Plain code-unit comparison, like byName: the order never depends on the locale.
|
|
216
|
+
function compareText(a: string, b: string): number {
|
|
217
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
218
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { sortSnapshot } from "./order.ts";
|
|
4
|
+
import type { Column, Database, ForeignKey, Index, Schema, Snapshot, Table } from "./snapshot.ts";
|
|
5
|
+
|
|
6
|
+
// The schema hash is a fingerprint of the STRUCTURE only. Two captures of an
|
|
7
|
+
// unchanged database must produce the same hash on any machine, so the
|
|
8
|
+
// canonical form:
|
|
9
|
+
//
|
|
10
|
+
// 1. Copies fields explicitly (a whitelist). Anything not listed here — capture
|
|
11
|
+
// time, engine version, row estimates, warnings — never affects the hash,
|
|
12
|
+
// even if a future engine adapter adds new fields.
|
|
13
|
+
// 2. Sorts first with sortSnapshot (order.ts), the same rules the snapshot
|
|
14
|
+
// files use: unordered collections by name, columns by position, and
|
|
15
|
+
// meaningful lists (key and index columns) untouched.
|
|
16
|
+
//
|
|
17
|
+
// Changing this canonical form changes every hash: treat it as a breaking change.
|
|
18
|
+
|
|
19
|
+
export function schemaHash(snapshot: Snapshot): string {
|
|
20
|
+
const canonical = sortSnapshot(snapshot).databases.map(canonicalDatabase);
|
|
21
|
+
const digest = createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
22
|
+
return `sha256:${digest}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function canonicalDatabase(database: Database) {
|
|
26
|
+
return { name: database.name, schemas: database.schemas.map(canonicalSchema) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function canonicalSchema(schema: Schema) {
|
|
30
|
+
return {
|
|
31
|
+
name: schema.name,
|
|
32
|
+
tables: schema.tables.map(canonicalTable),
|
|
33
|
+
routines: schema.routines.map((routine) => ({ name: routine.name, kind: routine.kind })),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function canonicalTable(table: Table) {
|
|
38
|
+
return {
|
|
39
|
+
name: table.name,
|
|
40
|
+
kind: table.kind,
|
|
41
|
+
comment: table.comment,
|
|
42
|
+
storageEngine: table.storageEngine,
|
|
43
|
+
columns: table.columns.map(canonicalColumn),
|
|
44
|
+
primaryKey: [...table.primaryKey],
|
|
45
|
+
indexes: table.indexes.map(canonicalIndex),
|
|
46
|
+
foreignKeys: table.foreignKeys.map(canonicalForeignKey),
|
|
47
|
+
triggers: table.triggers.map((trigger) => ({
|
|
48
|
+
name: trigger.name,
|
|
49
|
+
timing: trigger.timing,
|
|
50
|
+
events: [...trigger.events],
|
|
51
|
+
})),
|
|
52
|
+
viewDefinition: table.viewDefinition,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function canonicalColumn(column: Column) {
|
|
57
|
+
return {
|
|
58
|
+
name: column.name,
|
|
59
|
+
position: column.position,
|
|
60
|
+
type: column.type,
|
|
61
|
+
nullable: column.nullable,
|
|
62
|
+
default: column.default,
|
|
63
|
+
comment: column.comment,
|
|
64
|
+
autoIncrement: column.autoIncrement,
|
|
65
|
+
generated: column.generated && { expression: column.generated.expression, stored: column.generated.stored },
|
|
66
|
+
collation: column.collation,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function canonicalIndex(index: Index) {
|
|
71
|
+
return { name: index.name, columns: [...index.columns], unique: index.unique, method: index.method };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function canonicalForeignKey(foreignKey: ForeignKey) {
|
|
75
|
+
const { references } = foreignKey;
|
|
76
|
+
return {
|
|
77
|
+
name: foreignKey.name,
|
|
78
|
+
columns: [...foreignKey.columns],
|
|
79
|
+
references: {
|
|
80
|
+
database: references.database,
|
|
81
|
+
schema: references.schema,
|
|
82
|
+
table: references.table,
|
|
83
|
+
columns: [...references.columns],
|
|
84
|
+
},
|
|
85
|
+
onDelete: foreignKey.onDelete,
|
|
86
|
+
onUpdate: foreignKey.onUpdate,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Canonical names give every object one unambiguous address:
|
|
2
|
+
//
|
|
3
|
+
// database.schema.table.column
|
|
4
|
+
//
|
|
5
|
+
// Three levels before the column are needed because PostgreSQL has
|
|
6
|
+
// database → schema → table. In MySQL and MariaDB the schema equals the
|
|
7
|
+
// database, so "shop.shop.orders" is the canonical name of table "orders".
|
|
8
|
+
//
|
|
9
|
+
// Real names may contain dots ("v1.old"), so a segment that contains a dot or a
|
|
10
|
+
// double quote, or is empty, is wrapped in double quotes, with inner quotes
|
|
11
|
+
// doubled — the same rule SQL uses for quoted identifiers.
|
|
12
|
+
|
|
13
|
+
export interface ObjectRef {
|
|
14
|
+
readonly database: string;
|
|
15
|
+
readonly schema: string;
|
|
16
|
+
readonly table?: string;
|
|
17
|
+
readonly column?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const QUOTE = '"';
|
|
21
|
+
|
|
22
|
+
export function formatName(ref: ObjectRef): string {
|
|
23
|
+
if (ref.column !== undefined && ref.table === undefined) {
|
|
24
|
+
throw new Error(`column "${ref.column}" needs a table`);
|
|
25
|
+
}
|
|
26
|
+
const segments = [ref.database, ref.schema];
|
|
27
|
+
if (ref.table !== undefined) segments.push(ref.table);
|
|
28
|
+
if (ref.column !== undefined) segments.push(ref.column);
|
|
29
|
+
return segments.map(formatSegment).join(".");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Names a database on its own, with the quoting rule of every segment:
|
|
34
|
+
* shop stays shop, v1.old becomes "v1.old". The first segment of every
|
|
35
|
+
* canonical name inside that database is exactly this text.
|
|
36
|
+
*/
|
|
37
|
+
export function formatDatabaseName(database: string): string {
|
|
38
|
+
return formatSegment(database);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The name to show people and agents. In MySQL and MariaDB the schema repeats
|
|
43
|
+
* the database, and "shop.shop.orders.total" makes them read (and pay for) the
|
|
44
|
+
* same word twice, so it prints as "shop.orders.total", the way MySQL itself
|
|
45
|
+
* names it. When the schema differs (PostgreSQL) every level stays.
|
|
46
|
+
*
|
|
47
|
+
* Display only: snapshots, knowledge files and ids keep the canonical name.
|
|
48
|
+
* resolveTable and resolveColumn accept both, so whatever heydb prints can be
|
|
49
|
+
* typed back.
|
|
50
|
+
*/
|
|
51
|
+
export function displayName(ref: ObjectRef): string {
|
|
52
|
+
if (ref.schema !== ref.database) return formatName(ref);
|
|
53
|
+
if (ref.column !== undefined && ref.table === undefined) {
|
|
54
|
+
throw new Error(`column "${ref.column}" needs a table`);
|
|
55
|
+
}
|
|
56
|
+
const segments = [ref.database];
|
|
57
|
+
if (ref.table !== undefined) segments.push(ref.table);
|
|
58
|
+
if (ref.column !== undefined) segments.push(ref.column);
|
|
59
|
+
return segments.map(formatSegment).join(".");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** displayName of a canonical name such as "shop.shop.orders". */
|
|
63
|
+
export function displayNameOf(canonical: string): string {
|
|
64
|
+
return displayName(parseName(canonical));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatSegment(segment: string): string {
|
|
68
|
+
const needsQuotes = segment === "" || segment.includes(".") || segment.includes(QUOTE);
|
|
69
|
+
if (!needsQuotes) return segment;
|
|
70
|
+
return QUOTE + segment.replaceAll(QUOTE, QUOTE + QUOTE) + QUOTE;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseName(text: string): ObjectRef {
|
|
74
|
+
const segments = parseSegments(text);
|
|
75
|
+
if (segments.length < 2 || segments.length > 4) {
|
|
76
|
+
throw new Error(`invalid name "${text}": expected 2 to 4 segments, got ${segments.length}`);
|
|
77
|
+
}
|
|
78
|
+
const [database, schema, table, column] = segments as [string, string, string?, string?];
|
|
79
|
+
return {
|
|
80
|
+
database,
|
|
81
|
+
schema,
|
|
82
|
+
...(table !== undefined && { table }),
|
|
83
|
+
...(column !== undefined && { column }),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Splits a dotted name into its segments with the same quoting rules as
|
|
89
|
+
* parseName, but accepts any number of them. People and agents write partial
|
|
90
|
+
* names ("orders", "shop.orders"); this reads them without guessing what each
|
|
91
|
+
* segment is.
|
|
92
|
+
*
|
|
93
|
+
* Reads one segment at a time. A segment is either quoted ("...") or plain
|
|
94
|
+
* text up to the next dot. After each segment we must be at the end of the
|
|
95
|
+
* text or at a dot that starts the next segment.
|
|
96
|
+
*/
|
|
97
|
+
export function parseSegments(text: string): string[] {
|
|
98
|
+
const segments: string[] = [];
|
|
99
|
+
let index = 0;
|
|
100
|
+
|
|
101
|
+
while (true) {
|
|
102
|
+
if (text[index] === QUOTE) {
|
|
103
|
+
const quoted = readQuoted(text, index);
|
|
104
|
+
segments.push(quoted.value);
|
|
105
|
+
index = quoted.end;
|
|
106
|
+
} else {
|
|
107
|
+
const dot = text.indexOf(".", index);
|
|
108
|
+
const end = dot === -1 ? text.length : dot;
|
|
109
|
+
const plain = text.slice(index, end);
|
|
110
|
+
if (plain === "") throw new Error(`invalid name "${text}": empty segment must be written as ""`);
|
|
111
|
+
if (plain.includes(QUOTE)) throw new Error(`invalid name "${text}": quote inside unquoted segment "${plain}"`);
|
|
112
|
+
segments.push(plain);
|
|
113
|
+
index = end;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (index === text.length) return segments;
|
|
117
|
+
if (text[index] !== ".") {
|
|
118
|
+
throw new Error(`invalid name "${text}": unexpected "${text[index]}" after closing quote`);
|
|
119
|
+
}
|
|
120
|
+
index += 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Starts at an opening quote; returns the unquoted value and the index after the closing quote.
|
|
125
|
+
function readQuoted(text: string, start: number): { value: string; end: number } {
|
|
126
|
+
let value = "";
|
|
127
|
+
let index = start + 1;
|
|
128
|
+
while (index < text.length) {
|
|
129
|
+
if (text[index] === QUOTE) {
|
|
130
|
+
if (text[index + 1] === QUOTE) {
|
|
131
|
+
value += QUOTE;
|
|
132
|
+
index += 2;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
return { value, end: index + 1 };
|
|
136
|
+
}
|
|
137
|
+
value += text[index];
|
|
138
|
+
index += 1;
|
|
139
|
+
}
|
|
140
|
+
throw new Error(`invalid name "${text}": unterminated quote`);
|
|
141
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Snapshot, Table, Warning } from "./snapshot.ts";
|
|
2
|
+
|
|
3
|
+
// Ordering rules shared by everything that needs a stable snapshot (the hash,
|
|
4
|
+
// the files written to disk):
|
|
5
|
+
// - collections whose order carries no meaning are sorted by name (warnings by
|
|
6
|
+
// code, target and message);
|
|
7
|
+
// - columns are sorted by their position in the table;
|
|
8
|
+
// - lists whose order DOES carry meaning are kept as they are: primary key
|
|
9
|
+
// columns, index columns, foreign key columns.
|
|
10
|
+
|
|
11
|
+
// Returns a sorted copy; never mutates the input. Plain code-unit comparison
|
|
12
|
+
// (not localeCompare) so the result does not depend on the machine's locale.
|
|
13
|
+
export function byName<T extends { readonly name: string }>(items: readonly T[]): T[] {
|
|
14
|
+
return [...items].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A copy of the snapshot with every unordered collection in canonical order. */
|
|
18
|
+
export function sortSnapshot(snapshot: Snapshot): Snapshot {
|
|
19
|
+
return {
|
|
20
|
+
...snapshot,
|
|
21
|
+
databases: byName(snapshot.databases).map((database) => ({
|
|
22
|
+
...database,
|
|
23
|
+
schemas: byName(database.schemas).map((schema) => ({
|
|
24
|
+
...schema,
|
|
25
|
+
tables: byName(schema.tables).map(sortTable),
|
|
26
|
+
routines: byName(schema.routines),
|
|
27
|
+
})),
|
|
28
|
+
})),
|
|
29
|
+
warnings: [...snapshot.warnings].sort(compareWarnings),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function compareWarnings(a: Warning, b: Warning): number {
|
|
34
|
+
const keyA = `${a.code}\u0000${a.target ?? ""}\u0000${a.message}`;
|
|
35
|
+
const keyB = `${b.code}\u0000${b.target ?? ""}\u0000${b.message}`;
|
|
36
|
+
return keyA < keyB ? -1 : keyA > keyB ? 1 : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sortTable(table: Table): Table {
|
|
40
|
+
return {
|
|
41
|
+
...table,
|
|
42
|
+
columns: [...table.columns].sort((a, b) => a.position - b.position),
|
|
43
|
+
indexes: byName(table.indexes),
|
|
44
|
+
foreignKeys: byName(table.foreignKeys),
|
|
45
|
+
triggers: byName(table.triggers).map((trigger) => ({ ...trigger, events: [...trigger.events].sort() })),
|
|
46
|
+
};
|
|
47
|
+
}
|