@prabhask5/stellar-engine 1.2.0 → 1.2.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/README.md +454 -386
- package/dist/auth/resolveAuthState.js +3 -16
- package/dist/auth/resolveAuthState.js.map +1 -1
- package/dist/auth/singleUser.d.ts.map +1 -1
- package/dist/auth/singleUser.js +2 -3
- package/dist/auth/singleUser.js.map +1 -1
- package/dist/bin/commands.d.ts +14 -0
- package/dist/bin/commands.d.ts.map +1 -0
- package/dist/bin/commands.js +68 -0
- package/dist/bin/commands.js.map +1 -0
- package/dist/bin/install-pwa.d.ts +20 -6
- package/dist/bin/install-pwa.d.ts.map +1 -1
- package/dist/bin/install-pwa.js +111 -234
- package/dist/bin/install-pwa.js.map +1 -1
- package/dist/config.d.ts +63 -29
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +265 -37
- package/dist/config.js.map +1 -1
- package/dist/database.d.ts +64 -14
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +104 -16
- package/dist/database.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +8 -11
- package/dist/engine.js.map +1 -1
- package/dist/entries/types.d.ts +4 -3
- package/dist/entries/types.d.ts.map +1 -1
- package/dist/entries/utils.d.ts +1 -0
- package/dist/entries/utils.d.ts.map +1 -1
- package/dist/entries/utils.js +8 -0
- package/dist/entries/utils.js.map +1 -1
- package/dist/entries/vite.d.ts +1 -1
- package/dist/entries/vite.d.ts.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +150 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +891 -0
- package/dist/schema.js.map +1 -0
- package/dist/sw/build/vite-plugin.d.ts +93 -18
- package/dist/sw/build/vite-plugin.d.ts.map +1 -1
- package/dist/sw/build/vite-plugin.js +356 -22
- package/dist/sw/build/vite-plugin.js.map +1 -1
- package/dist/sw/sw.js +0 -5
- package/dist/types.d.ts +139 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
package/dist/schema.js
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Supabase SQL Generation from Schema Definitions
|
|
3
|
+
*
|
|
4
|
+
* Generates complete Supabase SQL (CREATE TABLE, RLS, triggers, indexes,
|
|
5
|
+
* realtime subscriptions) from a declarative {@link SchemaDefinition}.
|
|
6
|
+
* This eliminates the need for consumers to hand-write SQL — the schema
|
|
7
|
+
* in code becomes the single source of truth.
|
|
8
|
+
*
|
|
9
|
+
* The generation flow:
|
|
10
|
+
* 1. Schema string fields → Supabase SQL columns (with inferred types)
|
|
11
|
+
* 2. `sqlColumns` (object form) → Supabase SQL columns (explicit types)
|
|
12
|
+
* 3. System columns (auto) → Both Supabase + Dexie
|
|
13
|
+
*
|
|
14
|
+
* Column types are inferred from field naming conventions:
|
|
15
|
+
* - `*_id` → `uuid` (foreign key)
|
|
16
|
+
* - `*_at` → `timestamptz`
|
|
17
|
+
* - `order` → `double precision default 0`
|
|
18
|
+
* - `*_count`, `*_value`, etc. → `integer default 0`
|
|
19
|
+
* - Boolean patterns (`is_*`, `completed`, etc.) → `boolean default false`
|
|
20
|
+
* - Everything else → `text`
|
|
21
|
+
*
|
|
22
|
+
* @see {@link config.ts#initEngine} for schema-driven initialization
|
|
23
|
+
* @see {@link sw/build/vite-plugin.ts} for the Vite plugin that auto-applies generated SQL
|
|
24
|
+
*/
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Constants
|
|
27
|
+
// =============================================================================
|
|
28
|
+
/**
|
|
29
|
+
* SQL reserved words that must be double-quoted when used as column names.
|
|
30
|
+
*
|
|
31
|
+
* PostgreSQL treats these as keywords, so bare usage in DDL/DML will cause
|
|
32
|
+
* syntax errors. The generator wraps them in `"..."` automatically.
|
|
33
|
+
*/
|
|
34
|
+
const SQL_RESERVED_WORDS = new Set(['order', 'type', 'section', 'status', 'date', 'name', 'value']);
|
|
35
|
+
/**
|
|
36
|
+
* System columns automatically added to every sync-enabled table.
|
|
37
|
+
*
|
|
38
|
+
* These columns power the sync engine's core features:
|
|
39
|
+
* - `id` — Primary key (UUID)
|
|
40
|
+
* - `user_id` — Row ownership with cascading delete
|
|
41
|
+
* - `created_at` / `updated_at` — Timestamps for sync ordering
|
|
42
|
+
* - `deleted` — Soft-delete flag (tombstone)
|
|
43
|
+
* - `_version` — Optimistic concurrency control
|
|
44
|
+
* - `device_id` — Echo suppression for realtime updates
|
|
45
|
+
*/
|
|
46
|
+
const SYSTEM_COLUMNS = [
|
|
47
|
+
['id', 'uuid default gen_random_uuid() primary key'],
|
|
48
|
+
['user_id', 'uuid not null references auth.users(id) on delete cascade'],
|
|
49
|
+
['created_at', 'timestamptz not null default now()'],
|
|
50
|
+
['updated_at', 'timestamptz not null default now()'],
|
|
51
|
+
['deleted', 'boolean not null default false'],
|
|
52
|
+
['_version', 'integer not null default 1'],
|
|
53
|
+
['device_id', 'text']
|
|
54
|
+
];
|
|
55
|
+
// =============================================================================
|
|
56
|
+
// Column Type Inference
|
|
57
|
+
// =============================================================================
|
|
58
|
+
/**
|
|
59
|
+
* Infer a SQL column type from a field name using naming conventions.
|
|
60
|
+
*
|
|
61
|
+
* The engine uses consistent field naming patterns across all apps, so the
|
|
62
|
+
* column type can be reliably determined from the field suffix or exact name.
|
|
63
|
+
* Consumers can override any inference via `sqlColumns` in the schema config.
|
|
64
|
+
*
|
|
65
|
+
* @param fieldName - The snake_case field name (e.g., `'goal_list_id'`, `'order'`).
|
|
66
|
+
* @returns The SQL type with optional default (e.g., `'uuid'`, `'boolean default false'`).
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* inferColumnType('goal_list_id'); // → 'uuid'
|
|
70
|
+
* inferColumnType('completed_at'); // → 'timestamptz'
|
|
71
|
+
* inferColumnType('order'); // → 'double precision default 0'
|
|
72
|
+
* inferColumnType('is_active'); // → 'boolean default false'
|
|
73
|
+
* inferColumnType('title'); // → 'text'
|
|
74
|
+
*
|
|
75
|
+
* @see {@link SchemaTableConfig.sqlColumns} for explicit type overrides
|
|
76
|
+
*/
|
|
77
|
+
export function inferColumnType(fieldName) {
|
|
78
|
+
/* UUID foreign key references. */
|
|
79
|
+
if (fieldName.endsWith('_id'))
|
|
80
|
+
return 'uuid';
|
|
81
|
+
/* Timestamp fields. */
|
|
82
|
+
if (fieldName.endsWith('_at'))
|
|
83
|
+
return 'timestamptz';
|
|
84
|
+
/* Sort order — double precision for fractional ordering. */
|
|
85
|
+
if (fieldName === 'order')
|
|
86
|
+
return 'double precision default 0';
|
|
87
|
+
/* Boolean flags — common patterns across both apps. */
|
|
88
|
+
if (fieldName === 'completed' ||
|
|
89
|
+
fieldName === 'deleted' ||
|
|
90
|
+
fieldName === 'active' ||
|
|
91
|
+
fieldName.startsWith('is_')) {
|
|
92
|
+
return 'boolean default false';
|
|
93
|
+
}
|
|
94
|
+
/* Version counter — starts at 1 (first write is version 1). */
|
|
95
|
+
if (fieldName === '_version')
|
|
96
|
+
return 'integer default 1';
|
|
97
|
+
/* Date fields (without time component). */
|
|
98
|
+
if (fieldName === 'date')
|
|
99
|
+
return 'date';
|
|
100
|
+
/* Numeric counter/measurement fields. */
|
|
101
|
+
if (fieldName.endsWith('_count') ||
|
|
102
|
+
fieldName.endsWith('_value') ||
|
|
103
|
+
fieldName.endsWith('_size') ||
|
|
104
|
+
fieldName.endsWith('_ms') ||
|
|
105
|
+
fieldName.endsWith('_duration')) {
|
|
106
|
+
return 'integer default 0';
|
|
107
|
+
}
|
|
108
|
+
/* Enum-like text fields — no special type, but recognized for documentation. */
|
|
109
|
+
if (fieldName === 'status' || fieldName === 'type' || fieldName === 'section') {
|
|
110
|
+
return 'text';
|
|
111
|
+
}
|
|
112
|
+
/* Default: plain text. */
|
|
113
|
+
return 'text';
|
|
114
|
+
}
|
|
115
|
+
// =============================================================================
|
|
116
|
+
// Internal Helpers
|
|
117
|
+
// =============================================================================
|
|
118
|
+
/**
|
|
119
|
+
* Quote a column name if it is a SQL reserved word.
|
|
120
|
+
*
|
|
121
|
+
* @param name - The column name to potentially quote.
|
|
122
|
+
* @returns The column name, wrapped in double quotes if reserved.
|
|
123
|
+
*/
|
|
124
|
+
function quoteIfReserved(name) {
|
|
125
|
+
return SQL_RESERVED_WORDS.has(name) ? `"${name}"` : name;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Parse a Dexie-style index string into individual field names.
|
|
129
|
+
*
|
|
130
|
+
* Splits on commas, trims whitespace, and skips compound indexes in
|
|
131
|
+
* brackets (`[field1+field2]`) which are Dexie-specific and have no
|
|
132
|
+
* SQL equivalent.
|
|
133
|
+
*
|
|
134
|
+
* @param indexes - The raw index string (e.g., `'goal_list_id, order, [foo+bar]'`).
|
|
135
|
+
* @returns An array of individual field names.
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* parseIndexFields('goal_list_id, order');
|
|
139
|
+
* // → ['goal_list_id', 'order']
|
|
140
|
+
*
|
|
141
|
+
* parseIndexFields('daily_routine_goal_id, date, [daily_routine_goal_id+date]');
|
|
142
|
+
* // → ['daily_routine_goal_id', 'date']
|
|
143
|
+
*/
|
|
144
|
+
function parseIndexFields(indexes) {
|
|
145
|
+
if (!indexes.trim())
|
|
146
|
+
return [];
|
|
147
|
+
return indexes
|
|
148
|
+
.split(',')
|
|
149
|
+
.map((f) => f.trim())
|
|
150
|
+
.filter((f) => f.length > 0 && !f.startsWith('['));
|
|
151
|
+
}
|
|
152
|
+
// =============================================================================
|
|
153
|
+
// TypeScript Generation Helpers
|
|
154
|
+
// =============================================================================
|
|
155
|
+
/** Mass nouns that should not be singularized. */
|
|
156
|
+
const MASS_NOUNS = new Set([
|
|
157
|
+
'progress',
|
|
158
|
+
'status',
|
|
159
|
+
'settings',
|
|
160
|
+
'news',
|
|
161
|
+
'focus',
|
|
162
|
+
'agenda',
|
|
163
|
+
'data',
|
|
164
|
+
'media',
|
|
165
|
+
'metadata',
|
|
166
|
+
'analytics',
|
|
167
|
+
'feedback',
|
|
168
|
+
'info'
|
|
169
|
+
]);
|
|
170
|
+
/**
|
|
171
|
+
* Convert a snake_case string to PascalCase.
|
|
172
|
+
*
|
|
173
|
+
* @example snakeToPascal('goal_lists') → 'GoalLists'
|
|
174
|
+
*/
|
|
175
|
+
function snakeToPascal(s) {
|
|
176
|
+
return s
|
|
177
|
+
.split('_')
|
|
178
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
179
|
+
.join('');
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Convert a PascalCase field name to PascalCase (from snake_case).
|
|
183
|
+
*/
|
|
184
|
+
function fieldToPascal(s) {
|
|
185
|
+
return snakeToPascal(s);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Singularize a table name: PascalCase the snake_case name, then
|
|
189
|
+
* singularize the last word using basic English rules.
|
|
190
|
+
*
|
|
191
|
+
* @example singularize('goal_lists') → 'GoalList'
|
|
192
|
+
* @example singularize('task_categories') → 'TaskCategory'
|
|
193
|
+
* @example singularize('daily_goal_progress') → 'DailyGoalProgress'
|
|
194
|
+
*/
|
|
195
|
+
function singularize(tableName) {
|
|
196
|
+
const pascal = snakeToPascal(tableName);
|
|
197
|
+
const parts = tableName.split('_');
|
|
198
|
+
const lastWord = parts[parts.length - 1].toLowerCase();
|
|
199
|
+
if (MASS_NOUNS.has(lastWord))
|
|
200
|
+
return pascal;
|
|
201
|
+
/* PascalCase the name, then singularize the trailing portion. */
|
|
202
|
+
if (pascal.endsWith('ies')) {
|
|
203
|
+
return pascal.slice(0, -3) + 'y';
|
|
204
|
+
}
|
|
205
|
+
if (pascal.endsWith('ses') || pascal.endsWith('zes') || pascal.endsWith('xes')) {
|
|
206
|
+
return pascal.slice(0, -2);
|
|
207
|
+
}
|
|
208
|
+
if (pascal.endsWith('s') && !pascal.endsWith('ss')) {
|
|
209
|
+
return pascal.slice(0, -1);
|
|
210
|
+
}
|
|
211
|
+
return pascal;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Map a {@link FieldType} to its TypeScript type string.
|
|
215
|
+
* Returns `[tsType, enumDef]` where `enumDef` is set when the field
|
|
216
|
+
* declares an enum (union type alias).
|
|
217
|
+
*/
|
|
218
|
+
function mapFieldToTS(field, enumTypeName) {
|
|
219
|
+
/* Enum — array form */
|
|
220
|
+
if (Array.isArray(field)) {
|
|
221
|
+
return {
|
|
222
|
+
tsType: enumTypeName,
|
|
223
|
+
enumDef: { name: enumTypeName, values: field }
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/* Enum — object form */
|
|
227
|
+
if (typeof field === 'object' && field !== null) {
|
|
228
|
+
const name = field.enumName || enumTypeName;
|
|
229
|
+
const tsType = field.nullable ? `${name} | null` : name;
|
|
230
|
+
return {
|
|
231
|
+
tsType,
|
|
232
|
+
enumDef: { name, values: field.enum }
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/* String shorthand */
|
|
236
|
+
const nullable = field.endsWith('?');
|
|
237
|
+
const base = nullable ? field.slice(0, -1) : field;
|
|
238
|
+
const typeMap = {
|
|
239
|
+
string: 'string',
|
|
240
|
+
number: 'number',
|
|
241
|
+
boolean: 'boolean',
|
|
242
|
+
uuid: 'string',
|
|
243
|
+
date: 'string',
|
|
244
|
+
timestamp: 'string',
|
|
245
|
+
json: 'unknown'
|
|
246
|
+
};
|
|
247
|
+
const tsBase = typeMap[base] ?? 'string';
|
|
248
|
+
return { tsType: nullable ? `${tsBase} | null` : tsBase };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Map a {@link FieldType} to a SQL column type string.
|
|
252
|
+
*
|
|
253
|
+
* When `fields` is present on a table config, this is used instead of
|
|
254
|
+
* the name-based {@link inferColumnType}.
|
|
255
|
+
*/
|
|
256
|
+
function mapFieldToSQL(field, fieldName) {
|
|
257
|
+
/* Enum → stored as text */
|
|
258
|
+
if (Array.isArray(field))
|
|
259
|
+
return 'text not null';
|
|
260
|
+
if (typeof field === 'object' && field !== null) {
|
|
261
|
+
return field.nullable ? 'text' : 'text not null';
|
|
262
|
+
}
|
|
263
|
+
/* String shorthand */
|
|
264
|
+
const nullable = field.endsWith('?');
|
|
265
|
+
const base = nullable ? field.slice(0, -1) : field;
|
|
266
|
+
const sqlMap = {
|
|
267
|
+
string: 'text',
|
|
268
|
+
uuid: 'uuid',
|
|
269
|
+
date: 'date',
|
|
270
|
+
timestamp: 'timestamptz',
|
|
271
|
+
boolean: 'boolean',
|
|
272
|
+
json: 'jsonb'
|
|
273
|
+
};
|
|
274
|
+
if (base === 'number') {
|
|
275
|
+
/* Use name-based inference for integer vs double precision. */
|
|
276
|
+
if (fieldName === 'order' || fieldName.endsWith('_order') || fieldName.endsWith('_position')) {
|
|
277
|
+
return nullable ? 'double precision' : 'double precision not null default 0';
|
|
278
|
+
}
|
|
279
|
+
return nullable ? 'integer' : 'integer not null default 0';
|
|
280
|
+
}
|
|
281
|
+
if (base === 'boolean') {
|
|
282
|
+
return nullable ? 'boolean' : 'boolean not null default false';
|
|
283
|
+
}
|
|
284
|
+
const sqlBase = sqlMap[base];
|
|
285
|
+
if (sqlBase) {
|
|
286
|
+
return nullable ? sqlBase : `${sqlBase} not null`;
|
|
287
|
+
}
|
|
288
|
+
/* Fallback */
|
|
289
|
+
return nullable ? 'text' : 'text not null';
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Generate TypeScript interfaces and enum types from a schema definition.
|
|
293
|
+
*
|
|
294
|
+
* Only tables with a `fields` property are included. Tables without `fields`
|
|
295
|
+
* are silently skipped (backward-compatible).
|
|
296
|
+
*
|
|
297
|
+
* @param schema - The declarative schema definition.
|
|
298
|
+
* @param options - Optional generation options.
|
|
299
|
+
* @returns The generated TypeScript source string.
|
|
300
|
+
*/
|
|
301
|
+
export function generateTypeScript(schema, options) {
|
|
302
|
+
const lines = [];
|
|
303
|
+
const includeSystem = options?.includeSystemColumns !== false;
|
|
304
|
+
const header = options?.header ?? '/** AUTO-GENERATED by stellar-engine — do not edit manually. */';
|
|
305
|
+
lines.push(header);
|
|
306
|
+
lines.push('');
|
|
307
|
+
/* First pass: collect all enum definitions. */
|
|
308
|
+
const enums = [];
|
|
309
|
+
/* Track interface generation data. */
|
|
310
|
+
const interfaces = [];
|
|
311
|
+
for (const [tableName, definition] of Object.entries(schema)) {
|
|
312
|
+
const config = typeof definition === 'string' ? { indexes: definition } : definition;
|
|
313
|
+
if (!config.fields)
|
|
314
|
+
continue;
|
|
315
|
+
const interfaceName = config.typeName || singularize(tableName);
|
|
316
|
+
const fieldEntries = [];
|
|
317
|
+
for (const [fieldName, fieldType] of Object.entries(config.fields)) {
|
|
318
|
+
const enumTypeName = `${interfaceName}${fieldToPascal(fieldName)}`;
|
|
319
|
+
const { tsType, enumDef } = mapFieldToTS(fieldType, enumTypeName);
|
|
320
|
+
if (enumDef) {
|
|
321
|
+
/* Deduplicate enums by name. */
|
|
322
|
+
if (!enums.some((e) => e.name === enumDef.name)) {
|
|
323
|
+
enums.push(enumDef);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
fieldEntries.push({ name: fieldName, type: tsType, optional: false });
|
|
327
|
+
}
|
|
328
|
+
interfaces.push({ name: interfaceName, fields: fieldEntries });
|
|
329
|
+
}
|
|
330
|
+
/* Emit enum type aliases. */
|
|
331
|
+
if (enums.length > 0) {
|
|
332
|
+
for (const e of enums) {
|
|
333
|
+
const union = e.values.map((v) => `'${v}'`).join(' | ');
|
|
334
|
+
lines.push(`export type ${e.name} = ${union};`);
|
|
335
|
+
}
|
|
336
|
+
lines.push('');
|
|
337
|
+
}
|
|
338
|
+
/* Emit interfaces. */
|
|
339
|
+
for (const iface of interfaces) {
|
|
340
|
+
lines.push(`export interface ${iface.name} {`);
|
|
341
|
+
/* System columns first. */
|
|
342
|
+
if (includeSystem) {
|
|
343
|
+
lines.push(' id: string;');
|
|
344
|
+
}
|
|
345
|
+
/* Business fields. */
|
|
346
|
+
for (const f of iface.fields) {
|
|
347
|
+
const suffix = f.optional ? '?' : '';
|
|
348
|
+
lines.push(` ${f.name}${suffix}: ${f.type};`);
|
|
349
|
+
}
|
|
350
|
+
/* System trailing columns. */
|
|
351
|
+
if (includeSystem) {
|
|
352
|
+
lines.push(' created_at: string;');
|
|
353
|
+
lines.push(' updated_at: string;');
|
|
354
|
+
lines.push(' deleted?: boolean;');
|
|
355
|
+
lines.push(' _version?: number;');
|
|
356
|
+
lines.push(' device_id?: string;');
|
|
357
|
+
}
|
|
358
|
+
lines.push('}');
|
|
359
|
+
lines.push('');
|
|
360
|
+
}
|
|
361
|
+
return lines.join('\n');
|
|
362
|
+
}
|
|
363
|
+
// =============================================================================
|
|
364
|
+
// Single Table SQL Generation
|
|
365
|
+
// =============================================================================
|
|
366
|
+
/**
|
|
367
|
+
* Generate the complete SQL for a single sync-enabled table.
|
|
368
|
+
*
|
|
369
|
+
* Produces a self-contained block of SQL that includes:
|
|
370
|
+
* 1. `CREATE TABLE` with system columns + app-specific columns
|
|
371
|
+
* 2. `ALTER TABLE ... ENABLE ROW LEVEL SECURITY`
|
|
372
|
+
* 3. `CREATE POLICY` for user ownership (users can only access their own rows)
|
|
373
|
+
* 4. Triggers for `set_user_id` (auto-populate on INSERT) and
|
|
374
|
+
* `update_updated_at` (auto-update on UPDATE)
|
|
375
|
+
* 5. Indexes on `user_id`, `updated_at`, and `deleted` (partial index)
|
|
376
|
+
* 6. `ALTER PUBLICATION` for Supabase realtime subscriptions
|
|
377
|
+
*
|
|
378
|
+
* @param tableName - The Supabase table name (snake_case).
|
|
379
|
+
* @param config - The per-table configuration (parsed from schema).
|
|
380
|
+
* @param options - Optional generation options.
|
|
381
|
+
* @returns The SQL string for this table.
|
|
382
|
+
*
|
|
383
|
+
* @example
|
|
384
|
+
* generateTableSQL('goals', {
|
|
385
|
+
* indexes: 'goal_list_id, order',
|
|
386
|
+
* sqlColumns: { title: 'text not null' },
|
|
387
|
+
* });
|
|
388
|
+
*
|
|
389
|
+
* @see {@link generateSupabaseSQL} which calls this for each table
|
|
390
|
+
*/
|
|
391
|
+
function generateTableSQL(tableName, config, options) {
|
|
392
|
+
const lines = [];
|
|
393
|
+
const appName = options?.appName || '';
|
|
394
|
+
const tableLabel = appName ? `${appName} — ${tableName}` : tableName;
|
|
395
|
+
lines.push(`-- ${tableLabel}`);
|
|
396
|
+
/* ---- CREATE TABLE ---- */
|
|
397
|
+
const columnDefs = [];
|
|
398
|
+
/* System columns first (always present on every sync table). */
|
|
399
|
+
for (const [colName, colDef] of SYSTEM_COLUMNS) {
|
|
400
|
+
columnDefs.push(` ${colName} ${colDef}`);
|
|
401
|
+
}
|
|
402
|
+
/* Track which fields have been emitted (to avoid duplicates). */
|
|
403
|
+
const emittedFields = new Set();
|
|
404
|
+
if (config.fields) {
|
|
405
|
+
/* ---- Primary column source: `fields` (with sqlColumns as override) ---- */
|
|
406
|
+
for (const [field, fieldType] of Object.entries(config.fields)) {
|
|
407
|
+
if (SYSTEM_COLUMNS.some(([name]) => name === field))
|
|
408
|
+
continue;
|
|
409
|
+
/* sqlColumns override takes precedence over FieldType mapping. */
|
|
410
|
+
const sqlType = config.sqlColumns?.[field] ?? mapFieldToSQL(fieldType, field);
|
|
411
|
+
const quotedName = quoteIfReserved(field);
|
|
412
|
+
columnDefs.push(` ${quotedName} ${sqlType}`);
|
|
413
|
+
emittedFields.add(field);
|
|
414
|
+
}
|
|
415
|
+
/* Additional sqlColumns not in fields (e.g., columns needed by SQL only). */
|
|
416
|
+
if (config.sqlColumns) {
|
|
417
|
+
for (const [field, sqlType] of Object.entries(config.sqlColumns)) {
|
|
418
|
+
if (emittedFields.has(field))
|
|
419
|
+
continue;
|
|
420
|
+
if (SYSTEM_COLUMNS.some(([name]) => name === field))
|
|
421
|
+
continue;
|
|
422
|
+
const quotedName = quoteIfReserved(field);
|
|
423
|
+
columnDefs.push(` ${quotedName} ${sqlType}`);
|
|
424
|
+
emittedFields.add(field);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
/* ---- Legacy path: infer columns from indexes + sqlColumns ---- */
|
|
430
|
+
const indexFields = parseIndexFields(config.indexes || '');
|
|
431
|
+
for (const field of indexFields) {
|
|
432
|
+
if (SYSTEM_COLUMNS.some(([name]) => name === field))
|
|
433
|
+
continue;
|
|
434
|
+
const sqlType = config.sqlColumns?.[field] ?? inferColumnType(field);
|
|
435
|
+
const quotedName = quoteIfReserved(field);
|
|
436
|
+
columnDefs.push(` ${quotedName} ${sqlType}`);
|
|
437
|
+
emittedFields.add(field);
|
|
438
|
+
}
|
|
439
|
+
if (config.sqlColumns) {
|
|
440
|
+
for (const [field, sqlType] of Object.entries(config.sqlColumns)) {
|
|
441
|
+
if (emittedFields.has(field))
|
|
442
|
+
continue;
|
|
443
|
+
if (SYSTEM_COLUMNS.some(([name]) => name === field))
|
|
444
|
+
continue;
|
|
445
|
+
const quotedName = quoteIfReserved(field);
|
|
446
|
+
columnDefs.push(` ${quotedName} ${sqlType}`);
|
|
447
|
+
emittedFields.add(field);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
lines.push(`create table ${tableName} (`);
|
|
452
|
+
lines.push(columnDefs.join(',\n'));
|
|
453
|
+
lines.push(');');
|
|
454
|
+
lines.push('');
|
|
455
|
+
/* ---- ROW LEVEL SECURITY ---- */
|
|
456
|
+
lines.push(`alter table ${tableName} enable row level security;`);
|
|
457
|
+
lines.push(`create policy "Users can manage own ${tableName}" on ${tableName} for all using (auth.uid() = user_id);`);
|
|
458
|
+
lines.push('');
|
|
459
|
+
/* ---- TRIGGERS ---- */
|
|
460
|
+
lines.push(`create trigger set_user_id_${tableName} before insert on ${tableName} for each row execute function set_user_id();`);
|
|
461
|
+
lines.push(`create trigger update_${tableName}_updated_at before update on ${tableName} for each row execute function update_updated_at_column();`);
|
|
462
|
+
lines.push('');
|
|
463
|
+
/* ---- INDEXES ---- */
|
|
464
|
+
lines.push(`create index idx_${tableName}_user_id on ${tableName}(user_id);`);
|
|
465
|
+
lines.push(`create index idx_${tableName}_updated_at on ${tableName}(updated_at);`);
|
|
466
|
+
lines.push(`create index idx_${tableName}_deleted on ${tableName}(deleted) where deleted = false;`);
|
|
467
|
+
lines.push('');
|
|
468
|
+
/* ---- REALTIME ---- */
|
|
469
|
+
lines.push(`alter publication supabase_realtime add table ${tableName};`);
|
|
470
|
+
return lines.join('\n');
|
|
471
|
+
}
|
|
472
|
+
// =============================================================================
|
|
473
|
+
// Full SQL Generation
|
|
474
|
+
// =============================================================================
|
|
475
|
+
/**
|
|
476
|
+
* Generate the complete Supabase SQL from a declarative schema definition.
|
|
477
|
+
*
|
|
478
|
+
* This is the main entry point for SQL generation. It produces a single SQL
|
|
479
|
+
* file that can be pasted directly into the Supabase SQL Editor to bootstrap
|
|
480
|
+
* the entire database.
|
|
481
|
+
*
|
|
482
|
+
* The generated SQL includes (in order):
|
|
483
|
+
* 1. Extensions (`uuid-ossp`)
|
|
484
|
+
* 2. Helper functions (`set_user_id`, `update_updated_at_column`)
|
|
485
|
+
* 3. One `CREATE TABLE` block per schema table
|
|
486
|
+
* 4. `trusted_devices` table (unless `includeDeviceVerification` is `false`)
|
|
487
|
+
* 5. `crdt_documents` table (only if `includeCRDT` is `true`)
|
|
488
|
+
*
|
|
489
|
+
* @param schema - The declarative schema definition.
|
|
490
|
+
* @param options - Optional generation options.
|
|
491
|
+
* @returns The complete SQL string ready for execution.
|
|
492
|
+
*
|
|
493
|
+
* @example
|
|
494
|
+
* const sql = generateSupabaseSQL({
|
|
495
|
+
* goals: 'goal_list_id, order',
|
|
496
|
+
* goal_lists: { indexes: 'order', sqlColumns: { name: 'text not null' } },
|
|
497
|
+
* focus_settings: { singleton: true },
|
|
498
|
+
* }, { appName: 'Stellar' });
|
|
499
|
+
*
|
|
500
|
+
* // Write to file or paste into Supabase SQL Editor
|
|
501
|
+
* fs.writeFileSync('supabase-schema.sql', sql);
|
|
502
|
+
*
|
|
503
|
+
* @see {@link SchemaDefinition} for the schema format
|
|
504
|
+
* @see {@link generateMigrationSQL} for incremental schema changes
|
|
505
|
+
*/
|
|
506
|
+
export function generateSupabaseSQL(schema, options) {
|
|
507
|
+
const parts = [];
|
|
508
|
+
const appName = options?.appName || 'App';
|
|
509
|
+
const includeHelpers = options?.includeHelperFunctions !== false;
|
|
510
|
+
const includeDeviceVerification = options?.includeDeviceVerification !== false;
|
|
511
|
+
const includeCRDT = options?.includeCRDT === true;
|
|
512
|
+
/* ---- Header ---- */
|
|
513
|
+
parts.push(`-- ${appName} Database Schema for Supabase`);
|
|
514
|
+
parts.push('-- Copy and paste this entire file into your Supabase SQL Editor');
|
|
515
|
+
parts.push('');
|
|
516
|
+
/* ---- Extensions ---- */
|
|
517
|
+
parts.push('-- ============================================================');
|
|
518
|
+
parts.push('-- EXTENSIONS');
|
|
519
|
+
parts.push('-- ============================================================');
|
|
520
|
+
parts.push('');
|
|
521
|
+
parts.push('create extension if not exists "uuid-ossp";');
|
|
522
|
+
parts.push('');
|
|
523
|
+
/* ---- Helper Functions ---- */
|
|
524
|
+
if (includeHelpers) {
|
|
525
|
+
parts.push('-- ============================================================');
|
|
526
|
+
parts.push('-- HELPER FUNCTIONS');
|
|
527
|
+
parts.push('-- ============================================================');
|
|
528
|
+
parts.push('');
|
|
529
|
+
parts.push('-- Function to automatically set user_id on insert');
|
|
530
|
+
parts.push('create or replace function set_user_id()');
|
|
531
|
+
parts.push('returns trigger as $$');
|
|
532
|
+
parts.push('begin');
|
|
533
|
+
parts.push(' new.user_id := auth.uid();');
|
|
534
|
+
parts.push(' return new;');
|
|
535
|
+
parts.push('end;');
|
|
536
|
+
parts.push("$$ language plpgsql security definer set search_path = '';");
|
|
537
|
+
parts.push('');
|
|
538
|
+
parts.push('-- Function to automatically update updated_at timestamp');
|
|
539
|
+
parts.push('create or replace function update_updated_at_column()');
|
|
540
|
+
parts.push('returns trigger as $$');
|
|
541
|
+
parts.push('begin');
|
|
542
|
+
parts.push(" new.updated_at = timezone('utc'::text, now());");
|
|
543
|
+
parts.push(' return new;');
|
|
544
|
+
parts.push('end;');
|
|
545
|
+
parts.push("$$ language plpgsql set search_path = '';");
|
|
546
|
+
parts.push('');
|
|
547
|
+
parts.push('-- Function to execute migration SQL via RPC (service_role only)');
|
|
548
|
+
parts.push('-- Used by the Vite plugin to auto-push schema migrations during dev.');
|
|
549
|
+
parts.push('create or replace function stellar_engine_migrate(sql_text text)');
|
|
550
|
+
parts.push('returns void as $$');
|
|
551
|
+
parts.push('begin');
|
|
552
|
+
parts.push(" if current_setting('request.jwt.claims', true)::json->>'role' != 'service_role' then");
|
|
553
|
+
parts.push(" raise exception 'Unauthorized: stellar_engine_migrate requires service_role';");
|
|
554
|
+
parts.push(' end if;');
|
|
555
|
+
parts.push(' execute sql_text;');
|
|
556
|
+
parts.push('end;');
|
|
557
|
+
parts.push("$$ language plpgsql security definer set search_path = '';");
|
|
558
|
+
parts.push('');
|
|
559
|
+
}
|
|
560
|
+
/* ---- App Tables ---- */
|
|
561
|
+
parts.push('-- ============================================================');
|
|
562
|
+
parts.push('-- APPLICATION TABLES');
|
|
563
|
+
parts.push('-- ============================================================');
|
|
564
|
+
parts.push('');
|
|
565
|
+
for (const [tableName, definition] of Object.entries(schema)) {
|
|
566
|
+
/* Normalize string shorthand to object form. */
|
|
567
|
+
const config = typeof definition === 'string' ? { indexes: definition } : definition;
|
|
568
|
+
parts.push(generateTableSQL(tableName, config, options));
|
|
569
|
+
parts.push('');
|
|
570
|
+
}
|
|
571
|
+
/* ---- Trusted Devices ---- */
|
|
572
|
+
if (includeDeviceVerification) {
|
|
573
|
+
parts.push('-- ============================================================');
|
|
574
|
+
parts.push('-- TRUSTED DEVICES (required for device verification)');
|
|
575
|
+
parts.push('-- ============================================================');
|
|
576
|
+
parts.push('');
|
|
577
|
+
parts.push('create table trusted_devices (');
|
|
578
|
+
parts.push(' id uuid default gen_random_uuid() primary key,');
|
|
579
|
+
parts.push(' user_id uuid references auth.users(id) on delete cascade not null,');
|
|
580
|
+
parts.push(' device_id text not null,');
|
|
581
|
+
parts.push(' device_label text,');
|
|
582
|
+
parts.push(' trusted_at timestamptz default now() not null,');
|
|
583
|
+
parts.push(' last_used_at timestamptz default now() not null,');
|
|
584
|
+
parts.push(' unique(user_id, device_id)');
|
|
585
|
+
parts.push(');');
|
|
586
|
+
parts.push('');
|
|
587
|
+
parts.push('alter table trusted_devices enable row level security;');
|
|
588
|
+
parts.push('create policy "Users can manage own devices" on trusted_devices for all using (auth.uid() = user_id);');
|
|
589
|
+
parts.push('');
|
|
590
|
+
parts.push('create trigger set_user_id_trusted_devices before insert on trusted_devices for each row execute function set_user_id();');
|
|
591
|
+
parts.push('create trigger update_trusted_devices_updated_at before update on trusted_devices for each row execute function update_updated_at_column();');
|
|
592
|
+
parts.push('');
|
|
593
|
+
parts.push('create index idx_trusted_devices_user_id on trusted_devices(user_id);');
|
|
594
|
+
parts.push('');
|
|
595
|
+
parts.push('alter publication supabase_realtime add table trusted_devices;');
|
|
596
|
+
parts.push('');
|
|
597
|
+
}
|
|
598
|
+
/* ---- CRDT Documents ---- */
|
|
599
|
+
if (includeCRDT) {
|
|
600
|
+
parts.push('-- ============================================================');
|
|
601
|
+
parts.push('-- CRDT DOCUMENT STORAGE (optional — only needed for collaborative editing)');
|
|
602
|
+
parts.push('-- ============================================================');
|
|
603
|
+
parts.push('-- Stores Yjs CRDT document state for collaborative real-time editing.');
|
|
604
|
+
parts.push('-- Each row represents the latest merged state of a single collaborative document.');
|
|
605
|
+
parts.push('-- The engine persists full Yjs binary state periodically (every ~30s), not per keystroke.');
|
|
606
|
+
parts.push('-- Real-time updates between clients are distributed via Supabase Broadcast (WebSocket),');
|
|
607
|
+
parts.push('-- so this table is only for durable persistence and offline-to-online reconciliation.');
|
|
608
|
+
parts.push('--');
|
|
609
|
+
parts.push('-- Key columns:');
|
|
610
|
+
parts.push('-- state — Full Yjs document state (Y.encodeStateAsUpdate), base64 encoded');
|
|
611
|
+
parts.push('-- state_vector — Yjs state vector (Y.encodeStateVector) for efficient delta computation');
|
|
612
|
+
parts.push('-- state_size — Byte size of state column, used for monitoring and compaction decisions');
|
|
613
|
+
parts.push('-- device_id — Identifies which device last persisted, used for echo suppression');
|
|
614
|
+
parts.push('');
|
|
615
|
+
parts.push('create table crdt_documents (');
|
|
616
|
+
parts.push(' id uuid primary key default gen_random_uuid(),');
|
|
617
|
+
parts.push(' page_id uuid not null,');
|
|
618
|
+
parts.push(' state text not null,');
|
|
619
|
+
parts.push(' state_vector text not null,');
|
|
620
|
+
parts.push(' state_size integer not null default 0,');
|
|
621
|
+
parts.push(' user_id uuid not null references auth.users(id),');
|
|
622
|
+
parts.push(' device_id text not null,');
|
|
623
|
+
parts.push(' updated_at timestamptz not null default now(),');
|
|
624
|
+
parts.push(' created_at timestamptz not null default now()');
|
|
625
|
+
parts.push(');');
|
|
626
|
+
parts.push('');
|
|
627
|
+
parts.push('alter table crdt_documents enable row level security;');
|
|
628
|
+
parts.push('');
|
|
629
|
+
parts.push('create policy "Users can manage own CRDT documents"');
|
|
630
|
+
parts.push(' on crdt_documents for all');
|
|
631
|
+
parts.push(' using (auth.uid() = user_id);');
|
|
632
|
+
parts.push('');
|
|
633
|
+
parts.push('create trigger set_crdt_documents_user_id');
|
|
634
|
+
parts.push(' before insert on crdt_documents');
|
|
635
|
+
parts.push(' for each row execute function set_user_id();');
|
|
636
|
+
parts.push('');
|
|
637
|
+
parts.push('create trigger update_crdt_documents_updated_at');
|
|
638
|
+
parts.push(' before update on crdt_documents');
|
|
639
|
+
parts.push(' for each row execute function update_updated_at_column();');
|
|
640
|
+
parts.push('');
|
|
641
|
+
parts.push('create index idx_crdt_documents_page_id on crdt_documents(page_id);');
|
|
642
|
+
parts.push('create index idx_crdt_documents_user_id on crdt_documents(user_id);');
|
|
643
|
+
parts.push('');
|
|
644
|
+
parts.push('-- Unique constraint per page per user (upsert target for persistence)');
|
|
645
|
+
parts.push('create unique index idx_crdt_documents_page_user on crdt_documents(page_id, user_id);');
|
|
646
|
+
parts.push('');
|
|
647
|
+
}
|
|
648
|
+
/* ---- Realtime Reminder ---- */
|
|
649
|
+
parts.push('-- ============================================================');
|
|
650
|
+
parts.push('-- REALTIME: All tables above have been added to supabase_realtime');
|
|
651
|
+
parts.push('-- ============================================================');
|
|
652
|
+
return parts.join('\n');
|
|
653
|
+
}
|
|
654
|
+
// =============================================================================
|
|
655
|
+
// Migration SQL Generation
|
|
656
|
+
// =============================================================================
|
|
657
|
+
/**
|
|
658
|
+
* Generate migration SQL by diffing two schema definitions.
|
|
659
|
+
*
|
|
660
|
+
* Compares the current (deployed) schema against the new (desired) schema
|
|
661
|
+
* and produces `ALTER TABLE` statements for the differences:
|
|
662
|
+
* - **New tables** → full `CREATE TABLE` (via {@link generateTableSQL})
|
|
663
|
+
* - **Removed tables** → commented-out `DROP TABLE` (safety: requires manual review)
|
|
664
|
+
* - **New columns** → `ALTER TABLE ... ADD COLUMN`
|
|
665
|
+
* - **Removed columns** → commented-out `ALTER TABLE ... DROP COLUMN`
|
|
666
|
+
*
|
|
667
|
+
* This function intentionally does NOT handle column type changes — those
|
|
668
|
+
* require careful manual migration (data conversion, backfill, etc.).
|
|
669
|
+
*
|
|
670
|
+
* @param currentSchema - The currently deployed schema definition.
|
|
671
|
+
* @param newSchema - The desired (target) schema definition.
|
|
672
|
+
* @returns The migration SQL string. Empty string if no changes detected.
|
|
673
|
+
*
|
|
674
|
+
* @example
|
|
675
|
+
* const migration = generateMigrationSQL(
|
|
676
|
+
* { goals: 'goal_list_id, order' },
|
|
677
|
+
* {
|
|
678
|
+
* goals: 'goal_list_id, order, priority', // added column
|
|
679
|
+
* tags: 'name', // new table
|
|
680
|
+
* }
|
|
681
|
+
* );
|
|
682
|
+
*
|
|
683
|
+
* @see {@link generateSupabaseSQL} for generating the initial schema
|
|
684
|
+
*/
|
|
685
|
+
export function generateMigrationSQL(currentSchema, newSchema) {
|
|
686
|
+
const parts = [];
|
|
687
|
+
parts.push('-- Migration SQL');
|
|
688
|
+
parts.push(`-- Generated at ${new Date().toISOString()}`);
|
|
689
|
+
parts.push('');
|
|
690
|
+
const currentTables = Object.keys(currentSchema);
|
|
691
|
+
const newTables = Object.keys(newSchema);
|
|
692
|
+
/*
|
|
693
|
+
* Build a set of old table names that are being renamed so they are NOT
|
|
694
|
+
* treated as "removed tables" in the diff below.
|
|
695
|
+
*/
|
|
696
|
+
const renamedFromSet = new Set();
|
|
697
|
+
for (const [, def] of Object.entries(newSchema)) {
|
|
698
|
+
const config = typeof def === 'string' ? { indexes: def } : def;
|
|
699
|
+
if (config.renamedFrom)
|
|
700
|
+
renamedFromSet.add(config.renamedFrom);
|
|
701
|
+
}
|
|
702
|
+
/* ---- Table Renames ---- */
|
|
703
|
+
const renameStatements = [];
|
|
704
|
+
for (const [tableName, definition] of Object.entries(newSchema)) {
|
|
705
|
+
const config = typeof definition === 'string' ? { indexes: definition } : definition;
|
|
706
|
+
if (!config.renamedFrom || !currentTables.includes(config.renamedFrom))
|
|
707
|
+
continue;
|
|
708
|
+
const oldName = config.renamedFrom;
|
|
709
|
+
/* Rename the table itself. */
|
|
710
|
+
renameStatements.push(`alter table ${oldName} rename to ${tableName};`);
|
|
711
|
+
/* Rename associated triggers. */
|
|
712
|
+
renameStatements.push(`alter trigger set_user_id_${oldName} on ${tableName} rename to set_user_id_${tableName};`);
|
|
713
|
+
renameStatements.push(`alter trigger update_${oldName}_updated_at on ${tableName} rename to update_${tableName}_updated_at;`);
|
|
714
|
+
/* Rename associated RLS policy. */
|
|
715
|
+
renameStatements.push(`alter policy "Users can manage own ${oldName}" on ${tableName} rename to "Users can manage own ${tableName}";`);
|
|
716
|
+
/* Rename associated indexes. */
|
|
717
|
+
renameStatements.push(`alter index idx_${oldName}_user_id rename to idx_${tableName}_user_id;`);
|
|
718
|
+
renameStatements.push(`alter index idx_${oldName}_updated_at rename to idx_${tableName}_updated_at;`);
|
|
719
|
+
renameStatements.push(`alter index idx_${oldName}_deleted rename to idx_${tableName}_deleted;`);
|
|
720
|
+
/* Rename columns if specified. */
|
|
721
|
+
if (config.renamedColumns) {
|
|
722
|
+
for (const [newCol, oldCol] of Object.entries(config.renamedColumns)) {
|
|
723
|
+
const quotedOld = quoteIfReserved(oldCol);
|
|
724
|
+
const quotedNew = quoteIfReserved(newCol);
|
|
725
|
+
renameStatements.push(`alter table ${tableName} rename column ${quotedOld} to ${quotedNew};`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
/* Update realtime publication (remove old, add new). */
|
|
729
|
+
renameStatements.push(`alter publication supabase_realtime drop table ${oldName};`);
|
|
730
|
+
renameStatements.push(`alter publication supabase_realtime add table ${tableName};`);
|
|
731
|
+
}
|
|
732
|
+
if (renameStatements.length > 0) {
|
|
733
|
+
parts.push('-- ============================================================');
|
|
734
|
+
parts.push('-- TABLE RENAMES');
|
|
735
|
+
parts.push('-- ============================================================');
|
|
736
|
+
parts.push('');
|
|
737
|
+
parts.push(...renameStatements);
|
|
738
|
+
parts.push('');
|
|
739
|
+
}
|
|
740
|
+
/* ---- New tables (in new schema but not current, excluding renames) ---- */
|
|
741
|
+
const addedTables = newTables.filter((t) => {
|
|
742
|
+
if (currentTables.includes(t))
|
|
743
|
+
return false;
|
|
744
|
+
/* If this table has a renamedFrom that exists in current, it's a rename, not new. */
|
|
745
|
+
const def = newSchema[t];
|
|
746
|
+
const config = typeof def === 'string' ? { indexes: def } : def;
|
|
747
|
+
if (config.renamedFrom && currentTables.includes(config.renamedFrom))
|
|
748
|
+
return false;
|
|
749
|
+
return true;
|
|
750
|
+
});
|
|
751
|
+
if (addedTables.length > 0) {
|
|
752
|
+
parts.push('-- ============================================================');
|
|
753
|
+
parts.push('-- NEW TABLES');
|
|
754
|
+
parts.push('-- ============================================================');
|
|
755
|
+
parts.push('');
|
|
756
|
+
for (const tableName of addedTables) {
|
|
757
|
+
const definition = newSchema[tableName];
|
|
758
|
+
const config = typeof definition === 'string' ? { indexes: definition } : definition;
|
|
759
|
+
parts.push(generateTableSQL(tableName, config));
|
|
760
|
+
parts.push('');
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
/* ---- Removed tables (in current but not new, excluding renamed-from tables) ---- */
|
|
764
|
+
const removedTables = currentTables.filter((t) => !newTables.includes(t) && !renamedFromSet.has(t));
|
|
765
|
+
if (removedTables.length > 0) {
|
|
766
|
+
parts.push('-- ============================================================');
|
|
767
|
+
parts.push('-- REMOVED TABLES (commented out for safety — review before uncommenting)');
|
|
768
|
+
parts.push('-- ============================================================');
|
|
769
|
+
parts.push('');
|
|
770
|
+
for (const tableName of removedTables) {
|
|
771
|
+
parts.push(`-- drop table if exists ${tableName} cascade;`);
|
|
772
|
+
}
|
|
773
|
+
parts.push('');
|
|
774
|
+
}
|
|
775
|
+
/* ---- Column-level changes for tables that exist in both schemas ---- */
|
|
776
|
+
const sharedTables = newTables.filter((t) => currentTables.includes(t));
|
|
777
|
+
const columnChanges = [];
|
|
778
|
+
for (const tableName of sharedTables) {
|
|
779
|
+
const currentDef = currentSchema[tableName];
|
|
780
|
+
const newDef = newSchema[tableName];
|
|
781
|
+
const currentConfig = typeof currentDef === 'string' ? { indexes: currentDef } : currentDef;
|
|
782
|
+
const newConfig = typeof newDef === 'string' ? { indexes: newDef } : newDef;
|
|
783
|
+
/* Collect all columns from each schema version (indexes + sqlColumns). */
|
|
784
|
+
const currentFields = collectFields(currentConfig);
|
|
785
|
+
const newFields = collectFields(newConfig);
|
|
786
|
+
/*
|
|
787
|
+
* Build a reverse rename map for this table so that a renamed column
|
|
788
|
+
* is not treated as both "removed" and "added".
|
|
789
|
+
*/
|
|
790
|
+
const renamedNewToOld = newConfig.renamedColumns || {};
|
|
791
|
+
const renamedOldNames = new Set(Object.values(renamedNewToOld));
|
|
792
|
+
const renamedNewNames = new Set(Object.keys(renamedNewToOld));
|
|
793
|
+
/* New columns → ALTER TABLE ADD COLUMN (skip renamed columns). */
|
|
794
|
+
for (const field of newFields) {
|
|
795
|
+
if (renamedNewNames.has(field))
|
|
796
|
+
continue;
|
|
797
|
+
if (!currentFields.has(field)) {
|
|
798
|
+
const sqlType = resolveColumnType(newConfig, field);
|
|
799
|
+
const quotedName = quoteIfReserved(field);
|
|
800
|
+
columnChanges.push(`alter table ${tableName} add column ${quotedName} ${sqlType};`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/* Removed columns → commented-out ALTER TABLE DROP COLUMN (skip renamed columns). */
|
|
804
|
+
for (const field of currentFields) {
|
|
805
|
+
if (renamedOldNames.has(field))
|
|
806
|
+
continue;
|
|
807
|
+
if (!newFields.has(field)) {
|
|
808
|
+
const quotedName = quoteIfReserved(field);
|
|
809
|
+
columnChanges.push(`-- alter table ${tableName} drop column ${quotedName};`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/* Column renames on shared tables (table name unchanged but columns renamed). */
|
|
813
|
+
if (newConfig.renamedColumns) {
|
|
814
|
+
for (const [newCol, oldCol] of Object.entries(newConfig.renamedColumns)) {
|
|
815
|
+
if (currentFields.has(oldCol)) {
|
|
816
|
+
const quotedOld = quoteIfReserved(oldCol);
|
|
817
|
+
const quotedNew = quoteIfReserved(newCol);
|
|
818
|
+
columnChanges.push(`alter table ${tableName} rename column ${quotedOld} to ${quotedNew};`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (columnChanges.length > 0) {
|
|
824
|
+
parts.push('-- ============================================================');
|
|
825
|
+
parts.push('-- COLUMN CHANGES');
|
|
826
|
+
parts.push('-- ============================================================');
|
|
827
|
+
parts.push('');
|
|
828
|
+
parts.push(...columnChanges);
|
|
829
|
+
parts.push('');
|
|
830
|
+
}
|
|
831
|
+
/* If no changes were detected, return an empty string. */
|
|
832
|
+
const hasChanges = renameStatements.length > 0 ||
|
|
833
|
+
addedTables.length > 0 ||
|
|
834
|
+
removedTables.length > 0 ||
|
|
835
|
+
columnChanges.length > 0;
|
|
836
|
+
if (!hasChanges) {
|
|
837
|
+
return '';
|
|
838
|
+
}
|
|
839
|
+
return parts.join('\n');
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Resolve the SQL column type for a field, checking `fields` first, then
|
|
843
|
+
* `sqlColumns`, then falling back to name-based inference.
|
|
844
|
+
*
|
|
845
|
+
* @param config - The per-table configuration.
|
|
846
|
+
* @param fieldName - The column name.
|
|
847
|
+
* @returns The SQL type string.
|
|
848
|
+
*/
|
|
849
|
+
function resolveColumnType(config, fieldName) {
|
|
850
|
+
if (config.fields?.[fieldName]) {
|
|
851
|
+
return config.sqlColumns?.[fieldName] ?? mapFieldToSQL(config.fields[fieldName], fieldName);
|
|
852
|
+
}
|
|
853
|
+
return config.sqlColumns?.[fieldName] ?? inferColumnType(fieldName);
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Collect all app-specific field names from a table config.
|
|
857
|
+
*
|
|
858
|
+
* Combines fields from the index string and the `sqlColumns` map, excluding
|
|
859
|
+
* system columns (which are always present and never user-managed).
|
|
860
|
+
*
|
|
861
|
+
* @param config - The per-table configuration.
|
|
862
|
+
* @returns A set of field names.
|
|
863
|
+
*/
|
|
864
|
+
function collectFields(config) {
|
|
865
|
+
const systemColumnNames = new Set(SYSTEM_COLUMNS.map(([name]) => name));
|
|
866
|
+
const fields = new Set();
|
|
867
|
+
/* Fields from the declarative `fields` map. */
|
|
868
|
+
if (config.fields) {
|
|
869
|
+
for (const field of Object.keys(config.fields)) {
|
|
870
|
+
if (!systemColumnNames.has(field)) {
|
|
871
|
+
fields.add(field);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/* Fields from the index string. */
|
|
876
|
+
for (const field of parseIndexFields(config.indexes || '')) {
|
|
877
|
+
if (!systemColumnNames.has(field)) {
|
|
878
|
+
fields.add(field);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
/* Fields from explicit sqlColumns. */
|
|
882
|
+
if (config.sqlColumns) {
|
|
883
|
+
for (const field of Object.keys(config.sqlColumns)) {
|
|
884
|
+
if (!systemColumnNames.has(field)) {
|
|
885
|
+
fields.add(field);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return fields;
|
|
890
|
+
}
|
|
891
|
+
//# sourceMappingURL=schema.js.map
|