@happyvertical/smrt-template-sveltekit 0.43.10 → 0.44.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/AGENTS.md +31 -6
- package/README.md +26 -10
- package/index.js +53 -2
- package/package.json +19 -5
- package/template/.dockerignore +12 -0
- package/template/.env.cloud.example +9 -0
- package/template/.env.example +15 -3
- package/template/.env.self-hosted.example +11 -0
- package/template/AGENTS.md +32 -0
- package/template/Dockerfile +23 -0
- package/template/INSTALL_PROMPT.md +11 -0
- package/template/README.md +74 -32
- package/template/compose.yaml +56 -0
- package/template/gitignore.template +45 -0
- package/template/package.json +30 -15
- package/template/scripts/smrt-app.mjs +884 -0
- package/template/scripts/smrt-operation-lock.mjs +102 -0
- package/template/scripts/smrt-portability-assets.mjs +707 -0
- package/template/scripts/smrt-portability.mjs +470 -0
- package/template/scripts/smrt-prepare-migration.mjs +93 -0
- package/template/scripts/smrt-process.mjs +82 -0
- package/template/scripts/smrt-provider-readiness.mjs +33 -0
- package/template/scripts/smrt-runtime-identity.mjs +238 -0
- package/template/scripts/smrt-vite.mjs +45 -0
- package/template/scripts/smrt-web.mjs +10 -0
- package/template/scripts/smrt-worker.mjs +86 -0
- package/template/scripts/smrt-writer-lease.mjs +199 -0
- package/template/smrt.config.ts +34 -2
- package/template/src/hooks.server.ts +4 -1
- package/template/src/lib/RuntimeDiagnosticsWebMcp.svelte +11 -0
- package/template/src/lib/runtime-diagnostics-webmcp.ts +108 -0
- package/template/src/lib/server/application-runtime.ts +262 -0
- package/template/src/lib/server/runtime-diagnostics.ts +114 -0
- package/template/src/lib/server/smrt.ts +4 -4
- package/template/src/routes/+layout.svelte +2 -0
- package/template/src/routes/api/_runtime/diagnostics/+server.ts +5 -0
- package/template/src/routes/api/_runtime/health/+server.ts +30 -0
- package/template/src/routes/setup/+page.server.ts +96 -0
- package/template/src/routes/setup/+page.svelte +31 -0
- package/template/svelte.config.js +1 -1
- package/template/tsconfig.json +1 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
linkSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { basename, dirname, join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { getDatabase } from '@happyvertical/sql';
|
|
13
|
+
import {
|
|
14
|
+
MAX_BUNDLE_BYTES,
|
|
15
|
+
bundleContentDigest,
|
|
16
|
+
collectFilesystemAssets,
|
|
17
|
+
finishFilesystemAssets,
|
|
18
|
+
publishFilesystemAssets,
|
|
19
|
+
readSensitiveBundle,
|
|
20
|
+
recoverFilesystemAssets,
|
|
21
|
+
rollbackFilesystemAssets,
|
|
22
|
+
stageFilesystemAssets,
|
|
23
|
+
verifyFilesystemAssets,
|
|
24
|
+
verifyPublishedFilesystemAssets,
|
|
25
|
+
} from './smrt-portability-assets.mjs';
|
|
26
|
+
import { assertExternalArtifactPath } from './smrt-runtime-identity.mjs';
|
|
27
|
+
|
|
28
|
+
const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
29
|
+
const NON_PORTABLE_TABLE_PATTERN =
|
|
30
|
+
/(?:^|_)(?:api_keys?|audit_logs?|auth_approve_limits?|auth_requests?|bootstrap|credentials?|magic_link_tokens?|secrets?|sessions?|tokens?)(?:_|$)/;
|
|
31
|
+
const NON_PORTABLE_COLUMN_PATTERN =
|
|
32
|
+
/(?:^|_)(?:api_key|ciphertext|cookie|credential|encrypted|encryption|password|private_key|privkey|secret|token)(?:_|$)/;
|
|
33
|
+
|
|
34
|
+
function quoteIdentifier(value) {
|
|
35
|
+
if (!SAFE_IDENTIFIER.test(value)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'The generated manifest contains an unsafe SQL identifier.',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return `"${value}"`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function containsNonPortableRows(table) {
|
|
44
|
+
return (
|
|
45
|
+
NON_PORTABLE_TABLE_PATTERN.test(table.name) ||
|
|
46
|
+
table.columns.some((column) => NON_PORTABLE_COLUMN_PATTERN.test(column))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function manifestTables(sourceRoot) {
|
|
51
|
+
const manifestPath = join(sourceRoot, '.smrt', 'manifest.json');
|
|
52
|
+
if (!existsSync(manifestPath)) {
|
|
53
|
+
throw new Error('Build the application before exporting or importing data.');
|
|
54
|
+
}
|
|
55
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
56
|
+
const tables = new Map();
|
|
57
|
+
for (const definition of Object.values(manifest.objects || {})) {
|
|
58
|
+
const schema = definition?.schema;
|
|
59
|
+
if (definition?.className?.endsWith('Collection')) continue;
|
|
60
|
+
if (!schema?.tableName || schema.tableName.startsWith('_smrt_')) continue;
|
|
61
|
+
const columnDefinitions = schema.columns || {};
|
|
62
|
+
const columns = Object.keys(columnDefinitions);
|
|
63
|
+
if (columns.length === 0) continue;
|
|
64
|
+
const foreignKeys = Object.entries(columnDefinitions).flatMap(
|
|
65
|
+
([column, definition]) =>
|
|
66
|
+
definition.foreignKey?.table
|
|
67
|
+
? [{ column, referencesTable: definition.foreignKey.table }]
|
|
68
|
+
: [],
|
|
69
|
+
);
|
|
70
|
+
const primaryKeys = Object.entries(columnDefinitions)
|
|
71
|
+
.filter(([, definition]) => definition.primaryKey)
|
|
72
|
+
.map(([column]) => column);
|
|
73
|
+
tables.set(schema.tableName, {
|
|
74
|
+
name: schema.tableName,
|
|
75
|
+
columns,
|
|
76
|
+
columnDefinitions,
|
|
77
|
+
foreignKeys,
|
|
78
|
+
primaryKeys,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return [...tables.values()].sort((left, right) =>
|
|
82
|
+
left.name.localeCompare(right.name),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parent-first insertion plan with nullable cycle edges deferred to updates. */
|
|
87
|
+
export function planImportTables(tables) {
|
|
88
|
+
const remaining = new Map(tables.map((table) => [table.name, table]));
|
|
89
|
+
const inserted = new Set();
|
|
90
|
+
const plan = [];
|
|
91
|
+
while (remaining.size > 0) {
|
|
92
|
+
const ready = [...remaining.values()]
|
|
93
|
+
.filter((table) =>
|
|
94
|
+
table.foreignKeys.every(
|
|
95
|
+
(foreignKey) =>
|
|
96
|
+
foreignKey.referencesTable === table.name ||
|
|
97
|
+
!remaining.has(foreignKey.referencesTable) ||
|
|
98
|
+
inserted.has(foreignKey.referencesTable),
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
102
|
+
const table =
|
|
103
|
+
ready[0] ||
|
|
104
|
+
[...remaining.values()].sort((left, right) =>
|
|
105
|
+
left.name.localeCompare(right.name),
|
|
106
|
+
)[0];
|
|
107
|
+
const deferredColumns = new Set(
|
|
108
|
+
table.foreignKeys
|
|
109
|
+
.filter(
|
|
110
|
+
(foreignKey) =>
|
|
111
|
+
foreignKey.referencesTable === table.name ||
|
|
112
|
+
remaining.has(foreignKey.referencesTable),
|
|
113
|
+
)
|
|
114
|
+
.map((foreignKey) => foreignKey.column),
|
|
115
|
+
);
|
|
116
|
+
plan.push({ ...table, deferredColumns });
|
|
117
|
+
inserted.add(table.name);
|
|
118
|
+
remaining.delete(table.name);
|
|
119
|
+
}
|
|
120
|
+
return plan;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function withDatabase(context, callback) {
|
|
124
|
+
const options = {
|
|
125
|
+
type: context.runtime.providers.database.engine,
|
|
126
|
+
url: context.env.DATABASE_URL,
|
|
127
|
+
};
|
|
128
|
+
if (context.runtime.profile === 'local') {
|
|
129
|
+
options.secureFile = {
|
|
130
|
+
driver: 'node:sqlite',
|
|
131
|
+
custody: 'trusted-parent',
|
|
132
|
+
root: context.paths.root,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const db = await getDatabase(options);
|
|
136
|
+
try {
|
|
137
|
+
return await callback(db);
|
|
138
|
+
} finally {
|
|
139
|
+
await db.close?.();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function validateImportBundle(bundle, expected) {
|
|
144
|
+
const providedNames = bundle.tables.map((table) => table?.name);
|
|
145
|
+
if (
|
|
146
|
+
providedNames.length !== expected.size ||
|
|
147
|
+
new Set(providedNames).size !== expected.size ||
|
|
148
|
+
providedNames.some((name) => !expected.has(name))
|
|
149
|
+
) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
'The export does not contain the complete application schema.',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
for (const exported of bundle.tables) {
|
|
155
|
+
const table = expected.get(exported.name);
|
|
156
|
+
if (
|
|
157
|
+
!table ||
|
|
158
|
+
!Array.isArray(exported.rows) ||
|
|
159
|
+
!Array.isArray(exported.columns)
|
|
160
|
+
) {
|
|
161
|
+
throw new Error('The export does not match the generated application schema.');
|
|
162
|
+
}
|
|
163
|
+
if (
|
|
164
|
+
exported.columns.length !== table.columns.length ||
|
|
165
|
+
new Set(exported.columns).size !== table.columns.length ||
|
|
166
|
+
exported.columns.some((column) => !table.columns.includes(column))
|
|
167
|
+
) {
|
|
168
|
+
throw new Error(`The export schema does not match ${table.name}.`);
|
|
169
|
+
}
|
|
170
|
+
for (const row of exported.rows) {
|
|
171
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
|
172
|
+
throw new Error(`The export has an invalid row for ${table.name}.`);
|
|
173
|
+
}
|
|
174
|
+
const columns = Object.keys(row);
|
|
175
|
+
if (
|
|
176
|
+
columns.length !== table.columns.length ||
|
|
177
|
+
columns.some((column) => !table.columns.includes(column))
|
|
178
|
+
) {
|
|
179
|
+
throw new Error(`The export has incomplete columns for ${table.name}.`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** JSON cannot represent SQLite's lossless BigInt results; decimal strings do. */
|
|
186
|
+
export function serializeExportBundle(bundle) {
|
|
187
|
+
return JSON.stringify(
|
|
188
|
+
bundle,
|
|
189
|
+
(_key, value) => (typeof value === 'bigint' ? value.toString() : value),
|
|
190
|
+
2,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function exportApplication(context) {
|
|
195
|
+
const tables = manifestTables(context.sourceRoot);
|
|
196
|
+
let bundle;
|
|
197
|
+
await withDatabase(context, async (db) => {
|
|
198
|
+
if (typeof db.transaction !== 'function') {
|
|
199
|
+
throw new Error('Logical export requires transactional database support.');
|
|
200
|
+
}
|
|
201
|
+
bundle = await db.transaction(async (tx) => {
|
|
202
|
+
if (context.runtime.providers.database.engine === 'postgres') {
|
|
203
|
+
await tx.query(
|
|
204
|
+
'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY',
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const exported = {
|
|
208
|
+
schemaVersion: 2,
|
|
209
|
+
application: context.appId,
|
|
210
|
+
profile: context.runtime.profile,
|
|
211
|
+
exportedAt: new Date().toISOString(),
|
|
212
|
+
tables: [],
|
|
213
|
+
};
|
|
214
|
+
for (const table of tables) {
|
|
215
|
+
exported.tables.push({
|
|
216
|
+
name: table.name,
|
|
217
|
+
columns: table.columns,
|
|
218
|
+
rows: containsNonPortableRows(table)
|
|
219
|
+
? []
|
|
220
|
+
: (
|
|
221
|
+
await tx.query(
|
|
222
|
+
`SELECT ${table.columns.map(quoteIdentifier).join(', ')} FROM ${quoteIdentifier(table.name)}`,
|
|
223
|
+
)
|
|
224
|
+
).rows,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
exported.assets = collectFilesystemAssets({
|
|
228
|
+
tables: exported.tables,
|
|
229
|
+
sourceRoot: context.sourceRoot,
|
|
230
|
+
assetRoot: context.assetRoot || context.paths?.assets,
|
|
231
|
+
});
|
|
232
|
+
return exported;
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
const outputPath = assertExternalArtifactPath({
|
|
236
|
+
sourceRoot: context.sourceRoot,
|
|
237
|
+
path:
|
|
238
|
+
context.path ||
|
|
239
|
+
join(
|
|
240
|
+
context.stateRoot,
|
|
241
|
+
'exports',
|
|
242
|
+
`${context.appId}-${Date.now()}.json`,
|
|
243
|
+
),
|
|
244
|
+
label: 'Export destination',
|
|
245
|
+
});
|
|
246
|
+
mkdirSync(dirname(outputPath), { recursive: true, mode: 0o700 });
|
|
247
|
+
const temporaryPath = join(
|
|
248
|
+
dirname(outputPath),
|
|
249
|
+
`.${basename(outputPath)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`,
|
|
250
|
+
);
|
|
251
|
+
const serialized = `${serializeExportBundle(bundle)}\n`;
|
|
252
|
+
if (Buffer.byteLength(serialized) > MAX_BUNDLE_BYTES) {
|
|
253
|
+
throw new Error('The portability bundle exceeds the supported size limit.');
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
writeFileSync(temporaryPath, serialized, {
|
|
257
|
+
flag: 'wx',
|
|
258
|
+
mode: 0o600,
|
|
259
|
+
});
|
|
260
|
+
// A same-filesystem hard link publishes the complete mode-0600 inode
|
|
261
|
+
// atomically and fails rather than following or replacing a destination.
|
|
262
|
+
try {
|
|
263
|
+
linkSync(temporaryPath, outputPath);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (
|
|
266
|
+
error &&
|
|
267
|
+
typeof error === 'object' &&
|
|
268
|
+
'code' in error &&
|
|
269
|
+
error.code === 'EEXIST'
|
|
270
|
+
) {
|
|
271
|
+
throw new Error(`Export destination already exists: ${outputPath}`);
|
|
272
|
+
}
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
} finally {
|
|
276
|
+
rmSync(temporaryPath, { force: true });
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
path: outputPath,
|
|
280
|
+
tableCount: bundle.tables.length,
|
|
281
|
+
assetsIncluded: true,
|
|
282
|
+
assetCount: bundle.assets.entries.length,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Execute a prevalidated parent-first import against one database transaction. */
|
|
287
|
+
export async function executeImportPlan(tx, plan, exportedByName) {
|
|
288
|
+
const deferredUpdates = [];
|
|
289
|
+
let rowCount = 0;
|
|
290
|
+
for (const table of plan) {
|
|
291
|
+
const exported = exportedByName.get(table.name);
|
|
292
|
+
const count = await tx.query(
|
|
293
|
+
`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`,
|
|
294
|
+
);
|
|
295
|
+
if (Number(count.rows[0]?.count || 0) !== 0) {
|
|
296
|
+
throw new Error(`Import target table ${table.name} is not empty.`);
|
|
297
|
+
}
|
|
298
|
+
for (const row of exported.rows) {
|
|
299
|
+
const columns = Object.keys(row);
|
|
300
|
+
await tx.query(
|
|
301
|
+
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`,
|
|
302
|
+
...columns.map((column) =>
|
|
303
|
+
table.deferredColumns.has(column) ? null : row[column],
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
for (const column of table.deferredColumns) {
|
|
307
|
+
if (row[column] != null) {
|
|
308
|
+
deferredUpdates.push({
|
|
309
|
+
table,
|
|
310
|
+
column,
|
|
311
|
+
value: row[column],
|
|
312
|
+
primaryKey: table.primaryKeys[0],
|
|
313
|
+
primaryValue: row[table.primaryKeys[0]],
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
rowCount += 1;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
for (const update of deferredUpdates) {
|
|
321
|
+
await tx.query(
|
|
322
|
+
`UPDATE ${quoteIdentifier(update.table.name)} SET ${quoteIdentifier(update.column)} = ? WHERE ${quoteIdentifier(update.primaryKey)} = ?`,
|
|
323
|
+
update.value,
|
|
324
|
+
update.primaryValue,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
return rowCount;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function inspectImportTarget(db, plan, exportedByName) {
|
|
331
|
+
let empty = true;
|
|
332
|
+
let complete = true;
|
|
333
|
+
for (const table of plan) {
|
|
334
|
+
const count = await db.query(
|
|
335
|
+
`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`,
|
|
336
|
+
);
|
|
337
|
+
const actual = Number(count.rows[0]?.count || 0);
|
|
338
|
+
const expected = exportedByName.get(table.name).rows.length;
|
|
339
|
+
if (actual !== 0) empty = false;
|
|
340
|
+
if (actual !== expected) complete = false;
|
|
341
|
+
}
|
|
342
|
+
if (empty) return 'empty';
|
|
343
|
+
if (complete) return 'complete';
|
|
344
|
+
return 'dirty';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function importApplication(context) {
|
|
348
|
+
if (!context.path) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
'Usage: pnpm app:import -- /absolute/path/export.json',
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
const serialized = readSensitiveBundle(context.path);
|
|
354
|
+
const bundle = JSON.parse(serialized);
|
|
355
|
+
if (bundle.schemaVersion === 1) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
'Database-only logical export bundles are not importable; create a new asset-aware export.',
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
if (bundle.schemaVersion !== 2 || !Array.isArray(bundle.tables)) {
|
|
361
|
+
throw new Error('Unsupported logical export bundle.');
|
|
362
|
+
}
|
|
363
|
+
if (bundle.application !== context.appId) {
|
|
364
|
+
throw new Error('The export belongs to a different application.');
|
|
365
|
+
}
|
|
366
|
+
const expected = new Map(
|
|
367
|
+
manifestTables(context.sourceRoot).map((table) => [table.name, table]),
|
|
368
|
+
);
|
|
369
|
+
validateImportBundle(bundle, expected);
|
|
370
|
+
const exportedByName = new Map(
|
|
371
|
+
bundle.tables.map((table) => [table.name, table]),
|
|
372
|
+
);
|
|
373
|
+
const plan = planImportTables([...expected.values()]);
|
|
374
|
+
for (const table of plan) {
|
|
375
|
+
const exported = exportedByName.get(table.name);
|
|
376
|
+
for (const column of table.deferredColumns) {
|
|
377
|
+
if (
|
|
378
|
+
table.columnDefinitions[column]?.notNull &&
|
|
379
|
+
exported.rows.some((row) => row[column] != null)
|
|
380
|
+
) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`Import cannot safely defer required cyclic reference ${table.name}.${column}.`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (table.deferredColumns.size > 0 && table.primaryKeys.length !== 1) {
|
|
387
|
+
throw new Error(
|
|
388
|
+
`Import cannot update cyclic references for ${table.name} without one primary key.`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const assetRoot = context.assetRoot || context.paths?.assets;
|
|
393
|
+
const verifiedAssets = verifyFilesystemAssets({
|
|
394
|
+
assetBundle: bundle.assets,
|
|
395
|
+
tables: bundle.tables,
|
|
396
|
+
sourceRoot: context.sourceRoot,
|
|
397
|
+
assetRoot,
|
|
398
|
+
});
|
|
399
|
+
const bundleDigest = bundleContentDigest(serialized);
|
|
400
|
+
let rowCount = 0;
|
|
401
|
+
await withDatabase(context, async (db) => {
|
|
402
|
+
if (typeof db.transaction !== 'function') {
|
|
403
|
+
throw new Error('Logical import requires transactional database support.');
|
|
404
|
+
}
|
|
405
|
+
let targetState = await inspectImportTarget(db, plan, exportedByName);
|
|
406
|
+
if (verifiedAssets.root) {
|
|
407
|
+
const recovery = recoverFilesystemAssets({
|
|
408
|
+
stateRoot: context.stateRoot,
|
|
409
|
+
appId: context.appId,
|
|
410
|
+
bundleDigest,
|
|
411
|
+
assetRoot: verifiedAssets.root,
|
|
412
|
+
targetState,
|
|
413
|
+
});
|
|
414
|
+
if (recovery === 'complete') {
|
|
415
|
+
rowCount = bundle.tables.reduce(
|
|
416
|
+
(count, table) => count + table.rows.length,
|
|
417
|
+
0,
|
|
418
|
+
);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (recovery === 'retry') {
|
|
422
|
+
targetState = await inspectImportTarget(db, plan, exportedByName);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (targetState !== 'empty') {
|
|
426
|
+
throw new Error('Import target is not empty.');
|
|
427
|
+
}
|
|
428
|
+
const staged = stageFilesystemAssets({
|
|
429
|
+
verified: verifiedAssets,
|
|
430
|
+
stateRoot: context.stateRoot,
|
|
431
|
+
appId: context.appId,
|
|
432
|
+
bundleDigest,
|
|
433
|
+
});
|
|
434
|
+
try {
|
|
435
|
+
await context.onImportPhase?.('assets-staged');
|
|
436
|
+
await db.transaction(async (tx) => {
|
|
437
|
+
publishFilesystemAssets(staged);
|
|
438
|
+
await context.onImportPhase?.('assets-published');
|
|
439
|
+
rowCount = await executeImportPlan(tx, plan, exportedByName);
|
|
440
|
+
await context.onImportPhase?.('database-staged');
|
|
441
|
+
verifyPublishedFilesystemAssets(staged);
|
|
442
|
+
});
|
|
443
|
+
finishFilesystemAssets(staged);
|
|
444
|
+
} catch (error) {
|
|
445
|
+
const stateAfterFailure = await inspectImportTarget(
|
|
446
|
+
db,
|
|
447
|
+
plan,
|
|
448
|
+
exportedByName,
|
|
449
|
+
);
|
|
450
|
+
if (stateAfterFailure === 'complete') {
|
|
451
|
+
finishFilesystemAssets(staged);
|
|
452
|
+
rowCount = bundle.tables.reduce(
|
|
453
|
+
(count, table) => count + table.rows.length,
|
|
454
|
+
0,
|
|
455
|
+
);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (stateAfterFailure === 'empty') {
|
|
459
|
+
rollbackFilesystemAssets(staged);
|
|
460
|
+
}
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
return {
|
|
465
|
+
path: context.path,
|
|
466
|
+
rowCount,
|
|
467
|
+
assetsIncluded: true,
|
|
468
|
+
assetCount: bundle.assets.entries.length,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
prepareLocalDatabaseStorage,
|
|
7
|
+
resolveLocalRuntimePaths,
|
|
8
|
+
} from '@happyvertical/smrt-app-runtime';
|
|
9
|
+
import {
|
|
10
|
+
loadConfig,
|
|
11
|
+
resolveConfiguredApplicationRuntime,
|
|
12
|
+
} from '@happyvertical/smrt-config';
|
|
13
|
+
import {
|
|
14
|
+
prepareApplicationStateRoot,
|
|
15
|
+
resolveApplicationId,
|
|
16
|
+
} from './smrt-runtime-identity.mjs';
|
|
17
|
+
import { withOperationLock } from './smrt-operation-lock.mjs';
|
|
18
|
+
import { readActiveWriterLease } from './smrt-writer-lease.mjs';
|
|
19
|
+
|
|
20
|
+
const sourceRoot = process.cwd();
|
|
21
|
+
try {
|
|
22
|
+
process.loadEnvFile(join(sourceRoot, '.env'));
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (
|
|
25
|
+
!(
|
|
26
|
+
error &&
|
|
27
|
+
typeof error === 'object' &&
|
|
28
|
+
'code' in error &&
|
|
29
|
+
error.code === 'ENOENT'
|
|
30
|
+
)
|
|
31
|
+
) {
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const packageJson = JSON.parse(
|
|
36
|
+
readFileSync(join(sourceRoot, 'package.json'), 'utf8'),
|
|
37
|
+
);
|
|
38
|
+
const appId = resolveApplicationId({
|
|
39
|
+
sourceRoot,
|
|
40
|
+
packageName: packageJson.name,
|
|
41
|
+
explicitId: process.env.SMRT_APP_ID,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await loadConfig({ cache: false });
|
|
45
|
+
const runtime = resolveConfiguredApplicationRuntime();
|
|
46
|
+
const stateRoot = prepareApplicationStateRoot({
|
|
47
|
+
appId,
|
|
48
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
49
|
+
sourceRoot,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await withOperationLock(stateRoot, 'db:migrate', async () => {
|
|
53
|
+
const env = { ...process.env, SMRT_APP_ID: appId };
|
|
54
|
+
if (runtime.profile === 'local') {
|
|
55
|
+
if (readActiveWriterLease(stateRoot)) {
|
|
56
|
+
throw new Error('Stop the local application before preparing its schema.');
|
|
57
|
+
}
|
|
58
|
+
const paths = resolveLocalRuntimePaths({
|
|
59
|
+
appId,
|
|
60
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
61
|
+
sourceRoot,
|
|
62
|
+
});
|
|
63
|
+
await prepareLocalDatabaseStorage({
|
|
64
|
+
appId,
|
|
65
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
66
|
+
sourceRoot,
|
|
67
|
+
});
|
|
68
|
+
env.DATABASE_TYPE = 'sqlite';
|
|
69
|
+
env.DATABASE_URL = paths.database;
|
|
70
|
+
env.SMRT_ASSETS_DIR = paths.assets;
|
|
71
|
+
}
|
|
72
|
+
const npmExecPath = process.env.npm_execpath;
|
|
73
|
+
const packageManager =
|
|
74
|
+
npmExecPath && basename(npmExecPath).toLowerCase().startsWith('pnpm')
|
|
75
|
+
? npmExecPath
|
|
76
|
+
: null;
|
|
77
|
+
const windowsFallback = !packageManager && process.platform === 'win32';
|
|
78
|
+
const result = spawnSync(
|
|
79
|
+
packageManager ? process.execPath : windowsFallback ? 'pnpm.cmd' : 'pnpm',
|
|
80
|
+
packageManager
|
|
81
|
+
? [packageManager, 'exec', 'smrt', 'db:migrate']
|
|
82
|
+
: ['exec', 'smrt', 'db:migrate'],
|
|
83
|
+
{
|
|
84
|
+
cwd: sourceRoot,
|
|
85
|
+
env,
|
|
86
|
+
shell: windowsFallback,
|
|
87
|
+
stdio: 'inherit',
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
if (result.error || result.status !== 0) {
|
|
91
|
+
throw result.error || new Error('s-m-r-t database migration failed.');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { platform } from 'node:os';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
function processCommand(pid) {
|
|
6
|
+
if (platform() === 'win32') {
|
|
7
|
+
const result = spawnSync(
|
|
8
|
+
'powershell.exe',
|
|
9
|
+
[
|
|
10
|
+
'-NoProfile',
|
|
11
|
+
'-NonInteractive',
|
|
12
|
+
'-Command',
|
|
13
|
+
`(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine`,
|
|
14
|
+
],
|
|
15
|
+
{ encoding: 'utf8', windowsHide: true },
|
|
16
|
+
);
|
|
17
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
18
|
+
}
|
|
19
|
+
const result = spawnSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
});
|
|
22
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function writeProcessRecord(path, record) {
|
|
26
|
+
writeFileSync(path, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function sendTerminationSignal(pid, killProcess = process.kill) {
|
|
30
|
+
try {
|
|
31
|
+
killProcess(pid, 'SIGTERM');
|
|
32
|
+
return true;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error?.code === 'ESRCH') return false;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function matchesApplicationProcess(record, command) {
|
|
40
|
+
return (
|
|
41
|
+
typeof command === 'string' &&
|
|
42
|
+
command.includes('smrt-web.mjs') &&
|
|
43
|
+
command.includes(`--smrt-instance=${record.instance}`)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readOwnedProcess(path) {
|
|
48
|
+
let record;
|
|
49
|
+
try {
|
|
50
|
+
record = JSON.parse(readFileSync(path, 'utf8'));
|
|
51
|
+
if (
|
|
52
|
+
!Number.isSafeInteger(record.pid) ||
|
|
53
|
+
record.pid < 1 ||
|
|
54
|
+
typeof record.instance !== 'string' ||
|
|
55
|
+
!/^[a-f0-9]{32}$/.test(record.instance)
|
|
56
|
+
) {
|
|
57
|
+
throw new Error('Invalid process record.');
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
rmSync(path, { force: true });
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
process.kill(record.pid, 0);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error?.code !== 'EPERM' && error?.code !== 'EACCES') {
|
|
67
|
+
rmSync(path, { force: true });
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const command = processCommand(record.pid);
|
|
72
|
+
if (command === null) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Application process ${record.pid} is live but its identity cannot be verified.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (!matchesApplicationProcess(record, command)) {
|
|
78
|
+
rmSync(path, { force: true });
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return record;
|
|
82
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const PROVIDER_SETTINGS = {
|
|
2
|
+
authentication: 'SMRT_AUTH_READINESS_MODULE',
|
|
3
|
+
assets: 'SMRT_ASSETS_READINESS_MODULE',
|
|
4
|
+
secrets: 'SMRT_SECRETS_READINESS_MODULE',
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build a provider-owned readiness probe. The installed adapter module must
|
|
9
|
+
* export `checkReadiness()` (or a default function) and return `true` or
|
|
10
|
+
* `{ ready: true }` only after checking its real backing service.
|
|
11
|
+
* @param {'authentication' | 'assets' | 'secrets'} component
|
|
12
|
+
* @param {{profile: string, provider: string}} context
|
|
13
|
+
*/
|
|
14
|
+
export function createProviderReadinessProbe(component, context) {
|
|
15
|
+
return async () => {
|
|
16
|
+
const setting = PROVIDER_SETTINGS[component];
|
|
17
|
+
const specifier = process.env[setting];
|
|
18
|
+
if (!specifier) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`${setting} must name an installed provider readiness module.`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const module = await import(specifier);
|
|
24
|
+
const probe = module.checkReadiness || module.default;
|
|
25
|
+
if (typeof probe !== 'function') {
|
|
26
|
+
throw new Error(`${setting} does not export a readiness probe.`);
|
|
27
|
+
}
|
|
28
|
+
const result = await probe({ component, ...context });
|
|
29
|
+
if (result !== true && result?.ready !== true) {
|
|
30
|
+
throw new Error(`${component} provider readiness check failed.`);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|