millas 0.2.12-beta → 0.2.12-beta-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/package.json +3 -16
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +400 -167
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +309 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +383 -97
- package/src/admin/static/admin.css +1341 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +65 -1013
- package/src/admin/views/pages/detail.njk +40 -16
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +145 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +476 -0
- package/src/admin/views/partials/form-widget.njk +296 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/admin.zip +0 -0
- package/src/auth/Auth.js +31 -10
- package/src/auth/AuthController.js +3 -1
- package/src/auth/AuthUser.js +119 -0
- package/src/cli.js +4 -2
- package/src/commands/createsuperuser.js +254 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +82 -110
- package/src/container/AppInitializer.js +215 -0
- package/src/container/Application.js +278 -253
- package/src/container/HttpServer.js +156 -0
- package/src/container/MillasApp.js +29 -279
- package/src/container/MillasConfig.js +192 -0
- package/src/core/admin.js +5 -0
- package/src/core/auth.js +9 -0
- package/src/core/db.js +9 -0
- package/src/core/foundation.js +59 -0
- package/src/core/http.js +11 -0
- package/src/core/lang.js +1 -0
- package/src/core/mail.js +6 -0
- package/src/core/queue.js +7 -0
- package/src/core/validation.js +29 -0
- package/src/facades/Admin.js +1 -1
- package/src/facades/Auth.js +22 -39
- package/src/facades/Cache.js +21 -10
- package/src/facades/Database.js +1 -1
- package/src/facades/Events.js +18 -17
- package/src/facades/Facade.js +197 -0
- package/src/facades/Http.js +42 -45
- package/src/facades/Log.js +25 -49
- package/src/facades/Mail.js +27 -32
- package/src/facades/Queue.js +22 -15
- package/src/facades/Storage.js +18 -10
- package/src/facades/Url.js +53 -0
- package/src/http/HttpClient.js +673 -0
- package/src/http/ResponseDispatcher.js +18 -111
- package/src/http/UrlGenerator.js +375 -0
- package/src/http/WelcomePage.js +273 -0
- package/src/http/adapters/ExpressAdapter.js +315 -0
- package/src/http/adapters/HttpAdapter.js +168 -0
- package/src/http/adapters/index.js +9 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +635 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/index.js +5 -144
- package/src/logger/formatters/PrettyFormatter.js +103 -57
- package/src/logger/internal.js +2 -2
- package/src/logger/patchConsole.js +91 -81
- package/src/middleware/MiddlewareRegistry.js +62 -82
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +412 -344
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +46 -7
- package/src/providers/CacheStorageServiceProvider.js +5 -3
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/EventServiceProvider.js +2 -1
- package/src/providers/LogServiceProvider.js +7 -3
- package/src/providers/MailServiceProvider.js +4 -3
- package/src/providers/QueueServiceProvider.js +4 -3
- package/src/router/Router.js +119 -152
- package/src/scaffold/templates.js +83 -26
- package/src/facades/Validation.js +0 -69
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DefaultValueParser
|
|
5
|
+
*
|
|
6
|
+
* Evaluates user-provided default value expressions in a safe, restricted
|
|
7
|
+
* context during makemigrations.
|
|
8
|
+
*
|
|
9
|
+
* ── What it does ─────────────────────────────────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* 1. Receives raw string input from the developer (e.g. "42", "'hello'",
|
|
12
|
+
* "Date.now", "crypto.randomUUID", "() => new Date().toISOString()")
|
|
13
|
+
*
|
|
14
|
+
* 2. Classifies it as:
|
|
15
|
+
* literal — a plain value that can be serialised to JSON
|
|
16
|
+
* (number, string, boolean, null, array, object)
|
|
17
|
+
* callable — a reference or arrow function that must be called
|
|
18
|
+
* per-row at migration time (Date.now, uuid, etc.)
|
|
19
|
+
*
|
|
20
|
+
* 3. For literals: evaluates the expression and returns the typed value
|
|
21
|
+
* 4. For callables: stores the expression string as-is (never evaluated here)
|
|
22
|
+
*
|
|
23
|
+
* ── Safe context ─────────────────────────────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* The evaluation sandbox pre-imports a whitelist of safe helpers:
|
|
26
|
+
* Date — current time
|
|
27
|
+
* crypto — randomUUID, randomBytes
|
|
28
|
+
* Math — floor, random, etc.
|
|
29
|
+
*
|
|
30
|
+
* Blocked:
|
|
31
|
+
* require, process, __dirname, __filename, Buffer (file/OS/network access)
|
|
32
|
+
* eval, Function constructor (code injection)
|
|
33
|
+
*
|
|
34
|
+
* ── Result shape ─────────────────────────────────────────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* Literal:
|
|
37
|
+
* { kind: 'literal', value: 42, expression: '42' }
|
|
38
|
+
*
|
|
39
|
+
* Callable:
|
|
40
|
+
* { kind: 'callable', expression: 'Date.now' }
|
|
41
|
+
* { kind: 'callable', expression: '() => crypto.randomUUID()' }
|
|
42
|
+
*
|
|
43
|
+
* ── How it's stored in migration files ───────────────────────────────────────
|
|
44
|
+
*
|
|
45
|
+
* Literal: oneOffDefault: { kind: 'literal', value: 42 }
|
|
46
|
+
* Rendered as: { kind: 'literal', value: 42 }
|
|
47
|
+
*
|
|
48
|
+
* Callable: oneOffDefault: { kind: 'callable', expression: 'Date.now' }
|
|
49
|
+
* Rendered as: { kind: 'callable', expression: 'Date.now' }
|
|
50
|
+
*
|
|
51
|
+
* ── How it's applied at migrate time ─────────────────────────────────────────
|
|
52
|
+
*
|
|
53
|
+
* Literal: db(table).whereNull(col).update({ col: value })
|
|
54
|
+
* — single UPDATE for all rows
|
|
55
|
+
*
|
|
56
|
+
* Callable: called once per row, each row gets its own value
|
|
57
|
+
* — used for uuid, timestamps, random values
|
|
58
|
+
*
|
|
59
|
+
* ── Determinism ──────────────────────────────────────────────────────────────
|
|
60
|
+
*
|
|
61
|
+
* The migration file always stores the EXPRESSION, never the evaluated
|
|
62
|
+
* result. This means:
|
|
63
|
+
* - `Date.now` in the file → each deployment gets the current timestamp
|
|
64
|
+
* - `42` in the file → always 42, deterministic across deployments
|
|
65
|
+
* The developer chooses which behaviour they want by what they type.
|
|
66
|
+
*/
|
|
67
|
+
class DefaultValueParser {
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse a raw string input from the developer.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} raw — what the developer typed
|
|
73
|
+
* @param {string} fieldType — 'string' | 'integer' | 'boolean' | etc.
|
|
74
|
+
* @returns {{ kind: 'literal'|'callable', value?: *, expression: string }}
|
|
75
|
+
* @throws {Error} if input is unsafe or unparseable
|
|
76
|
+
*/
|
|
77
|
+
parse(raw, fieldType) {
|
|
78
|
+
const trimmed = raw.trim();
|
|
79
|
+
|
|
80
|
+
if (!trimmed) {
|
|
81
|
+
throw new Error('No value provided. Enter a value or expression.');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Detect callable ───────────────────────────────────────────────────────
|
|
85
|
+
if (this._isCallable(trimmed)) {
|
|
86
|
+
this._assertSafe(trimmed);
|
|
87
|
+
return { kind: 'callable', expression: trimmed };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Evaluate as literal ───────────────────────────────────────────────────
|
|
91
|
+
this._assertSafe(trimmed);
|
|
92
|
+
const value = this._evalLiteral(trimmed, fieldType);
|
|
93
|
+
return { kind: 'literal', value, expression: this._serialiseExpression(value) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Detection ────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Detect if the input looks like a callable reference or function.
|
|
100
|
+
*
|
|
101
|
+
* Callables:
|
|
102
|
+
* Date.now — property reference (no call)
|
|
103
|
+
* crypto.randomUUID — property reference
|
|
104
|
+
* () => new Date().toISOString() — arrow function
|
|
105
|
+
* function() { return 1; } — function expression
|
|
106
|
+
* Date.now() — already called → callable (per-row)
|
|
107
|
+
*/
|
|
108
|
+
_isCallable(expr) {
|
|
109
|
+
// Arrow function
|
|
110
|
+
if (/^\(?\s*\w*\s*\)?\s*=>/.test(expr)) return true;
|
|
111
|
+
// function keyword
|
|
112
|
+
if (/^function\s*\(/.test(expr)) return true;
|
|
113
|
+
// Property reference that maps to a known callable
|
|
114
|
+
if (CALLABLE_REFS.has(expr)) return true;
|
|
115
|
+
// Dot-notation that ends in a name (not a string/number/bool)
|
|
116
|
+
// e.g. Date.now, crypto.randomUUID, Math.random
|
|
117
|
+
if (/^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)+$/.test(expr)) {
|
|
118
|
+
// Only treat as callable if first segment is a known safe object
|
|
119
|
+
const root = expr.split('.')[0];
|
|
120
|
+
return SAFE_GLOBALS.has(root);
|
|
121
|
+
}
|
|
122
|
+
// Function call expression e.g. Date.now() or crypto.randomUUID()
|
|
123
|
+
if (/^[A-Za-z_$][A-Za-z0-9_$.]+\(\)$/.test(expr)) return true;
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Safety ───────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Throw if the expression contains forbidden patterns.
|
|
131
|
+
* This is a defense-in-depth check — the sandbox also blocks these,
|
|
132
|
+
* but we want a clear error message before even attempting evaluation.
|
|
133
|
+
*/
|
|
134
|
+
_assertSafe(expr) {
|
|
135
|
+
const forbidden = [
|
|
136
|
+
/\brequire\s*\(/, // require()
|
|
137
|
+
/\bimport\s*\(/, // dynamic import
|
|
138
|
+
/\bprocess\b/, // process.env, process.exit
|
|
139
|
+
/\b__dirname\b/, // filesystem
|
|
140
|
+
/\b__filename\b/, // filesystem
|
|
141
|
+
/\bfs\b/, // fs module
|
|
142
|
+
/\bchild_process\b/, // shell
|
|
143
|
+
/\bexec\b\s*\(/, // exec()
|
|
144
|
+
/\bspawn\b\s*\(/, // spawn()
|
|
145
|
+
/\bfetch\b\s*\(/, // network
|
|
146
|
+
/\bXMLHttpRequest\b/, // network
|
|
147
|
+
/\bnew\s+Function\b/, // Function constructor
|
|
148
|
+
/\beval\s*\(/, // eval
|
|
149
|
+
/\bsetTimeout\b/, // async timing
|
|
150
|
+
/\bsetInterval\b/, // async timing
|
|
151
|
+
/\bglobalThis\b/, // global escape
|
|
152
|
+
/\bself\b/, // global escape
|
|
153
|
+
/\bwindow\b/, // DOM escape
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
for (const pattern of forbidden) {
|
|
157
|
+
if (pattern.test(expr)) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Unsafe expression: "${expr}" contains a forbidden pattern.\n` +
|
|
160
|
+
`Only safe expressions are allowed (literals, Date, Math, crypto).`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Literal evaluation ───────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
_evalLiteral(expr, fieldType) {
|
|
169
|
+
// ── null ──────────────────────────────────────────────────────────────────
|
|
170
|
+
if (expr === 'null' || expr === 'NULL') return null;
|
|
171
|
+
|
|
172
|
+
// ── boolean ───────────────────────────────────────────────────────────────
|
|
173
|
+
if (expr === 'true' || expr === 'True') return true;
|
|
174
|
+
if (expr === 'false' || expr === 'False') return false;
|
|
175
|
+
|
|
176
|
+
// ── quoted string ─────────────────────────────────────────────────────────
|
|
177
|
+
if ((expr.startsWith('"') && expr.endsWith('"')) ||
|
|
178
|
+
(expr.startsWith("'") && expr.endsWith("'"))) {
|
|
179
|
+
return expr.slice(1, -1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── number ────────────────────────────────────────────────────────────────
|
|
183
|
+
if (/^-?\d+(\.\d+)?$/.test(expr)) {
|
|
184
|
+
const n = Number(expr);
|
|
185
|
+
if (!isNaN(n)) return n;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── JSON array / object ───────────────────────────────────────────────────
|
|
189
|
+
if ((expr.startsWith('[') && expr.endsWith(']')) ||
|
|
190
|
+
(expr.startsWith('{') && expr.endsWith('}'))) {
|
|
191
|
+
try { return JSON.parse(expr); } catch {}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Field-type coercion for bare unquoted strings ─────────────────────────
|
|
195
|
+
if (fieldType === 'integer' || fieldType === 'bigInteger') {
|
|
196
|
+
const n = parseInt(expr, 10);
|
|
197
|
+
if (!isNaN(n)) return n;
|
|
198
|
+
throw new Error(`"${expr}" is not a valid integer.`);
|
|
199
|
+
}
|
|
200
|
+
if (fieldType === 'float' || fieldType === 'decimal') {
|
|
201
|
+
const f = parseFloat(expr);
|
|
202
|
+
if (!isNaN(f)) return f;
|
|
203
|
+
throw new Error(`"${expr}" is not a valid number.`);
|
|
204
|
+
}
|
|
205
|
+
if (fieldType === 'boolean') {
|
|
206
|
+
if (['1', 'yes', 'y'].includes(expr.toLowerCase())) return true;
|
|
207
|
+
if (['0', 'no', 'n'].includes(expr.toLowerCase())) return false;
|
|
208
|
+
throw new Error(`"${expr}" is not a valid boolean.`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Safe Date/Math expressions ────────────────────────────────────────────
|
|
212
|
+
// e.g. "new Date().toISOString()" evaluated once as a literal snapshot
|
|
213
|
+
if (/^new\s+Date\s*\(/.test(expr) || /^Math\./.test(expr)) {
|
|
214
|
+
try {
|
|
215
|
+
const result = this._sandboxEval(expr);
|
|
216
|
+
return result;
|
|
217
|
+
} catch (e) {
|
|
218
|
+
throw new Error(`Could not evaluate "${expr}": ${e.message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Bare unquoted string (last resort for string/text/enum fields) ────────
|
|
223
|
+
if (['string', 'text', 'enum', 'uuid', 'date', 'timestamp'].includes(fieldType)) {
|
|
224
|
+
return expr;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Cannot interpret "${expr}" as a ${fieldType}.\n` +
|
|
229
|
+
`For strings, wrap in quotes: '${expr}'\n` +
|
|
230
|
+
`For callables (uuid, timestamp), they will be called per-row at migrate time.`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Minimal sandbox for safe Date/Math expressions only.
|
|
236
|
+
* Uses Function constructor with a clean scope — no globals exposed.
|
|
237
|
+
*/
|
|
238
|
+
_sandboxEval(expr) {
|
|
239
|
+
// eslint-disable-next-line no-new-func
|
|
240
|
+
const fn = new Function('Date', 'Math', `'use strict'; return (${expr});`);
|
|
241
|
+
return fn(Date, Math);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─── Serialisation ────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Convert a literal JS value back to a clean expression string.
|
|
248
|
+
* Used to display confirmation back to the developer.
|
|
249
|
+
*/
|
|
250
|
+
_serialiseExpression(value) {
|
|
251
|
+
if (value === null) return 'null';
|
|
252
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
253
|
+
if (typeof value === 'number') return String(value);
|
|
254
|
+
if (typeof value === 'boolean') return String(value);
|
|
255
|
+
return JSON.stringify(value);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Safe callable registry ───────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Exact callable references the developer can type.
|
|
263
|
+
* These are resolved to actual functions at migration time.
|
|
264
|
+
*/
|
|
265
|
+
const CALLABLE_REFS = new Map([
|
|
266
|
+
['Date.now', () => Date.now()],
|
|
267
|
+
['Date.now()', () => Date.now()],
|
|
268
|
+
['new Date()', () => new Date().toISOString()],
|
|
269
|
+
['crypto.randomUUID', () => require('crypto').randomUUID()],
|
|
270
|
+
['crypto.randomUUID()', () => require('crypto').randomUUID()],
|
|
271
|
+
['Math.random', () => Math.random()],
|
|
272
|
+
['Math.random()', () => Math.random()],
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Safe root objects that are allowed in callable expressions.
|
|
277
|
+
*/
|
|
278
|
+
const SAFE_GLOBALS = new Set(['Date', 'Math', 'crypto', 'JSON', 'Number', 'String', 'Boolean']);
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Resolve a stored `oneOffDefault` descriptor into a concrete value or function.
|
|
282
|
+
*
|
|
283
|
+
* Called at migration time (inside AddField.up()), NOT at makemigrations time.
|
|
284
|
+
*
|
|
285
|
+
* @param {{ kind: 'literal'|'callable', value?: *, expression?: string }} descriptor
|
|
286
|
+
* @returns {* | () => *} — a literal value, or a zero-arg function for callables
|
|
287
|
+
*/
|
|
288
|
+
function resolveDefault(descriptor) {
|
|
289
|
+
if (!descriptor) return undefined;
|
|
290
|
+
|
|
291
|
+
// Legacy: plain primitive stored directly (backward compat with old migrations)
|
|
292
|
+
if (typeof descriptor !== 'object' || !('kind' in descriptor)) {
|
|
293
|
+
return descriptor;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (descriptor.kind === 'literal') {
|
|
297
|
+
return descriptor.value;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (descriptor.kind === 'callable') {
|
|
301
|
+
const expr = descriptor.expression;
|
|
302
|
+
|
|
303
|
+
// Known safe callable → return the pre-registered function
|
|
304
|
+
if (CALLABLE_REFS.has(expr)) {
|
|
305
|
+
return CALLABLE_REFS.get(expr);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Arrow function or function expression → compile in safe context
|
|
309
|
+
try {
|
|
310
|
+
// eslint-disable-next-line no-new-func
|
|
311
|
+
const fn = new Function('Date', 'Math', 'crypto',
|
|
312
|
+
`'use strict'; return (${expr});`
|
|
313
|
+
)(Date, Math, require('crypto'));
|
|
314
|
+
if (typeof fn === 'function') return fn;
|
|
315
|
+
// Expression evaluated to a value — treat as literal
|
|
316
|
+
return () => fn;
|
|
317
|
+
} catch (e) {
|
|
318
|
+
throw new Error(`Cannot resolve callable default "${expr}": ${e.message}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
throw new Error(`Unknown oneOffDefault descriptor kind: "${descriptor.kind}"`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
module.exports = { DefaultValueParser, resolveDefault, CALLABLE_REFS, SAFE_GLOBALS };
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
const { DefaultValueParser } = require('./DefaultValueParser');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* InteractiveResolver
|
|
8
|
+
*
|
|
9
|
+
* During makemigrations, when a new non-nullable field without a default
|
|
10
|
+
* is being added to an existing table, this resolver prompts the developer.
|
|
11
|
+
*
|
|
12
|
+
* Options:
|
|
13
|
+
* 1) Provide a one-off default value or expression
|
|
14
|
+
* — Supports literals (42, 'hello', true) and callables (Date.now,
|
|
15
|
+
* crypto.randomUUID, () => ...)
|
|
16
|
+
* — Stored in migration file as code, NOT evaluated at makemigrations time
|
|
17
|
+
* — Applied at migrate time: callable = per row, literal = single UPDATE
|
|
18
|
+
*
|
|
19
|
+
* 2) Make the field nullable temporarily
|
|
20
|
+
* — Safest option, can be tightened later with a follow-up migration
|
|
21
|
+
*
|
|
22
|
+
* 3) Abort
|
|
23
|
+
* — Developer must fix their model first, then re-run makemigrations
|
|
24
|
+
*
|
|
25
|
+
* In non-interactive mode (CI / --noinput):
|
|
26
|
+
* Throws with a clear message naming the field and showing all three fixes.
|
|
27
|
+
*/
|
|
28
|
+
class InteractiveResolver {
|
|
29
|
+
constructor(options = {}) {
|
|
30
|
+
this._nonInteractive = options.nonInteractive || !process.stdin.isTTY;
|
|
31
|
+
this._parser = new DefaultValueParser();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a single dangerous AddField op interactively.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} op — { type: 'AddField', table, column, field, _needsDefault: true }
|
|
38
|
+
* @returns {object} — resolved op
|
|
39
|
+
* @throws {Error} — on abort
|
|
40
|
+
*/
|
|
41
|
+
async resolve(op) {
|
|
42
|
+
const { table, column, field } = op;
|
|
43
|
+
|
|
44
|
+
if (this._nonInteractive) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`\nField "${table}.${column}" is non-nullable with no default.\n` +
|
|
47
|
+
`\nIn non-interactive mode, resolve this before running makemigrations:\n` +
|
|
48
|
+
` Option A: Add a default to your model:\n` +
|
|
49
|
+
` ${column}: fields.${field.type || 'string'}({ default: 'some_value' })\n` +
|
|
50
|
+
` Option B: Make the field nullable:\n` +
|
|
51
|
+
` ${column}: fields.${field.type || 'string'}({ nullable: true })\n` +
|
|
52
|
+
` Option C: Run makemigrations interactively to provide a one-off default.\n`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this._print('');
|
|
57
|
+
this._print(` It is impossible to add a non-nullable field '${column}' to the`);
|
|
58
|
+
this._print(` '${table}' table without specifying a default. This is because the`);
|
|
59
|
+
this._print(` database needs something to populate existing rows.`);
|
|
60
|
+
this._print('');
|
|
61
|
+
this._print(` Please select a fix:`);
|
|
62
|
+
this._print(` 1) Provide a one-off default now (used only for existing rows)`);
|
|
63
|
+
this._print(` 2) Quit and make '${column}' nullable in your model (recommended)`);
|
|
64
|
+
this._print(` 3) Quit and add a permanent default to your model`);
|
|
65
|
+
this._print('');
|
|
66
|
+
|
|
67
|
+
const choice = await this._prompt(' Select an option: ');
|
|
68
|
+
const trimmed = choice.trim();
|
|
69
|
+
|
|
70
|
+
if (trimmed === '2') {
|
|
71
|
+
this._print('');
|
|
72
|
+
this._print(` Quitting. Add nullable=true to '${column}' in your model:`);
|
|
73
|
+
this._print(` ${column}: fields.${field.type || 'string'}({ nullable: true })`);
|
|
74
|
+
this._print('');
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Aborted. Make '${column}' nullable in your model, then re-run makemigrations.`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (trimmed === '3') {
|
|
81
|
+
this._print('');
|
|
82
|
+
this._print(` Quitting. Add a default to '${column}' in your model:`);
|
|
83
|
+
this._print(` ${column}: fields.${field.type || 'string'}({ default: 'your_value' })`);
|
|
84
|
+
this._print('');
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Aborted. Add a default to '${column}' in your model, then re-run makemigrations.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (trimmed === '1') {
|
|
91
|
+
return this._promptForDefault(op);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Unrecognised — re-ask
|
|
95
|
+
this._print(` Invalid choice "${trimmed}". Please enter 1, 2, or 3.`);
|
|
96
|
+
return this.resolve(op);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve all dangerous ops in a list.
|
|
101
|
+
*/
|
|
102
|
+
async resolveAll(ops) {
|
|
103
|
+
const resolved = [];
|
|
104
|
+
for (const op of ops) {
|
|
105
|
+
if (op._needsDefault) {
|
|
106
|
+
resolved.push(await this.resolve(op));
|
|
107
|
+
} else {
|
|
108
|
+
resolved.push(op);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return resolved;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Default value prompt ─────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
async _promptForDefault(op) {
|
|
117
|
+
const { table, column, field } = op;
|
|
118
|
+
|
|
119
|
+
this._print('');
|
|
120
|
+
this._print(` Please enter the default value for '${column}' (${field.type}).`);
|
|
121
|
+
this._print(` This will ONLY be used to populate existing rows — it will not`);
|
|
122
|
+
this._print(` be added to your model definition.`);
|
|
123
|
+
this._print('');
|
|
124
|
+
this._print(` You can enter:`);
|
|
125
|
+
this._print(` A literal value: 42 | 'hello' | true | null`);
|
|
126
|
+
this._print(` A callable: Date.now | crypto.randomUUID | () => new Date().toISOString()`);
|
|
127
|
+
this._print(` (Callables are called once per row at migrate time)`);
|
|
128
|
+
this._print('');
|
|
129
|
+
|
|
130
|
+
const raw = await this._prompt(` Enter default for '${column}': `);
|
|
131
|
+
const trimmed = raw.trim();
|
|
132
|
+
|
|
133
|
+
if (!trimmed) {
|
|
134
|
+
this._print(` No value entered. Please try again.`);
|
|
135
|
+
return this._promptForDefault(op);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = this._parser.parse(trimmed, field.type);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
this._print('');
|
|
143
|
+
this._print(` ✗ ${err.message}`);
|
|
144
|
+
this._print('');
|
|
145
|
+
return this._promptForDefault(op);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Confirm with the developer ────────────────────────────────────────
|
|
149
|
+
this._print('');
|
|
150
|
+
if (parsed.kind === 'callable') {
|
|
151
|
+
this._print(` ✔ Callable: ${parsed.expression}`);
|
|
152
|
+
this._print(` Each existing row will get its own value when migrate runs.`);
|
|
153
|
+
} else {
|
|
154
|
+
this._print(` ✔ Literal: ${parsed.expression}`);
|
|
155
|
+
this._print(` All existing rows will be set to this value.`);
|
|
156
|
+
}
|
|
157
|
+
this._print('');
|
|
158
|
+
|
|
159
|
+
const confirm = await this._prompt(` Use this? [Y/n]: `);
|
|
160
|
+
if (confirm.trim().toLowerCase() === 'n') {
|
|
161
|
+
return this._promptForDefault(op);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
...op,
|
|
166
|
+
oneOffDefault: parsed, // { kind, value?, expression }
|
|
167
|
+
_needsDefault: false,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Internals ─────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
_print(msg) {
|
|
174
|
+
process.stdout.write(msg + '\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
_prompt(question) {
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
const rl = readline.createInterface({
|
|
180
|
+
input: process.stdin,
|
|
181
|
+
output: process.stdout,
|
|
182
|
+
});
|
|
183
|
+
rl.question(question, (answer) => {
|
|
184
|
+
rl.close();
|
|
185
|
+
resolve(answer);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = InteractiveResolver;
|