@trypattern/sdk 0.1.22 → 0.1.24
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/dist/cli/app.js +112 -5
- package/dist/cli/startingValues.d.ts +9 -0
- package/dist/cli/startingValues.js +58 -0
- package/dist/cli/updates.d.ts +4 -0
- package/dist/cli/updates.js +68 -0
- package/package.json +1 -1
package/dist/cli/app.js
CHANGED
|
@@ -22,7 +22,9 @@ import { readStarter, starterContentOf, withStarter } from "./starter.js";
|
|
|
22
22
|
import { addLiveMedia, login, publish, pull, restore, versions as listVersions } from "./publishing.js";
|
|
23
23
|
import { CHANGE_SET_ROWS } from "../publishWire.js";
|
|
24
24
|
import { DATA_USAGE, data } from "./data.js";
|
|
25
|
+
import { withStartingValues } from "./startingValues.js";
|
|
25
26
|
import { toolBin } from "./tools.js";
|
|
27
|
+
import { isNewer, latestPattern, newerPattern, updateNote } from "./updates.js";
|
|
26
28
|
function dependsOnPattern(directory) {
|
|
27
29
|
try {
|
|
28
30
|
const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'));
|
|
@@ -80,6 +82,7 @@ const CONTACT_SHEET = join(SCREENSHOTS_DIR, 'contact-sheet.png');
|
|
|
80
82
|
const SCREENSHOT_WORKER = join(SDK_SOURCE, 'cli', `screenshot${SDK_EXTENSION}`);
|
|
81
83
|
const VISUAL_URL_ENV = 'PATTERN_VISUAL_URL';
|
|
82
84
|
const GLOBAL_FLAGS = ['--json', '--help', '-h'];
|
|
85
|
+
const NUDGED_COMMANDS = new Set(['start', 'check', 'publish', 'data']);
|
|
83
86
|
const VALUE_FLAGS = new Set([
|
|
84
87
|
'--commands',
|
|
85
88
|
'--name',
|
|
@@ -213,7 +216,10 @@ changing seed.ts or starter/. Works while the dev server runs; reload the page t
|
|
|
213
216
|
details: `app migration add_due_dates
|
|
214
217
|
Compares the tables in schema.ts (plus Pattern's users, sessions and settings tables) with server/migrations and writes the
|
|
215
218
|
SQL that moves the database from one to the other, named <number>_<name>.sql. app start applies new
|
|
216
|
-
migrations to the local database. Prints "No schema changes" when the migrations already match
|
|
219
|
+
migrations to the local database. Prints "No schema changes" when the migrations already match.
|
|
220
|
+
A new required column starts as empty, 0 or false on rows that already exist (the live site has rows),
|
|
221
|
+
and a column made required fills its empty rows the same way; each is noted. To start them on a
|
|
222
|
+
real value, add an UPDATE after it in the migration file.`,
|
|
217
223
|
run: migration,
|
|
218
224
|
takesArgument: true
|
|
219
225
|
},
|
|
@@ -373,6 +379,15 @@ collected changes. Up to ${CHANGE_SET_ROWS} changes per request. For this comput
|
|
|
373
379
|
argumentCount: 3,
|
|
374
380
|
flags: ['--row', '--set', '--file', '--asked']
|
|
375
381
|
},
|
|
382
|
+
update: {
|
|
383
|
+
summary: 'Bring this project to the newest Pattern',
|
|
384
|
+
details: `Installs the newest @trypattern/sdk from npm into this project (pnpm add, exact version).
|
|
385
|
+
start, check, publish and data say when a newer Pattern is out. The live site keeps the old one until
|
|
386
|
+
the next app publish, so run app check, then publish it (straight away when nothing else is in
|
|
387
|
+
progress). New Pattern features on the live site, such as the owner approving app data changes,
|
|
388
|
+
need that publish.`,
|
|
389
|
+
run: update
|
|
390
|
+
},
|
|
376
391
|
versions: {
|
|
377
392
|
summary: 'List the versions that went live, newest first',
|
|
378
393
|
details: 'Each version with its number, whether it went live, when, and who published it. app restore <number> puts one back.',
|
|
@@ -1284,18 +1299,63 @@ async function migration(name) {
|
|
|
1284
1299
|
'--name',
|
|
1285
1300
|
name
|
|
1286
1301
|
]);
|
|
1302
|
+
const notes = code === 0 ? startNewColumns(name) : [];
|
|
1287
1303
|
const warning = code === 0 ? ownerBackfillWarning(name) : undefined;
|
|
1288
1304
|
return {
|
|
1289
1305
|
code,
|
|
1290
|
-
data: {
|
|
1291
|
-
|
|
1306
|
+
data: {
|
|
1307
|
+
output: output.trimEnd(),
|
|
1308
|
+
startingValues: notes,
|
|
1309
|
+
...(warning && { warning })
|
|
1310
|
+
},
|
|
1311
|
+
text: `${code === 0 ? '✓' : '✗'} app migration ${name}\n\n${indent(output.trimEnd())}${notes.map((note) => `\n note ${note}`).join('')}${warning === undefined ? '' : `\n\n! ${warning}`}`
|
|
1292
1312
|
};
|
|
1293
1313
|
}
|
|
1294
|
-
function
|
|
1295
|
-
|
|
1314
|
+
function newestMigration(name) {
|
|
1315
|
+
return readdirSync(MIGRATIONS_DIR)
|
|
1296
1316
|
.filter((entry) => entry.endsWith(`_${name}.sql`))
|
|
1297
1317
|
.sort()
|
|
1298
1318
|
.at(-1);
|
|
1319
|
+
}
|
|
1320
|
+
function previousTables(file) {
|
|
1321
|
+
const journal = join(MIGRATIONS_DIR, 'meta', '_journal.json');
|
|
1322
|
+
if (!existsSync(journal))
|
|
1323
|
+
return {};
|
|
1324
|
+
const entries = z
|
|
1325
|
+
.object({
|
|
1326
|
+
entries: z.array(z.object({ idx: z.number(), tag: z.string() }))
|
|
1327
|
+
})
|
|
1328
|
+
.parse(JSON.parse(readText(journal))).entries;
|
|
1329
|
+
const at = entries.findIndex((entry) => `${entry.tag}.sql` === file);
|
|
1330
|
+
const before = at > 0 ? entries[at - 1] : undefined;
|
|
1331
|
+
if (before === undefined)
|
|
1332
|
+
return {};
|
|
1333
|
+
const snapshot = join(MIGRATIONS_DIR, 'meta', `${String(before.idx).padStart(4, '0')}_snapshot.json`);
|
|
1334
|
+
if (!existsSync(snapshot))
|
|
1335
|
+
return {};
|
|
1336
|
+
return z
|
|
1337
|
+
.object({
|
|
1338
|
+
tables: z.record(z.string(), z.object({
|
|
1339
|
+
columns: z.record(z.string(), z.object({ notNull: z.boolean() }))
|
|
1340
|
+
}))
|
|
1341
|
+
})
|
|
1342
|
+
.parse(JSON.parse(readText(snapshot))).tables;
|
|
1343
|
+
}
|
|
1344
|
+
function startNewColumns(name) {
|
|
1345
|
+
const file = newestMigration(name);
|
|
1346
|
+
if (file === undefined)
|
|
1347
|
+
return [];
|
|
1348
|
+
const path = join(MIGRATIONS_DIR, file);
|
|
1349
|
+
const tables = Object.fromEntries(Object.entries(previousTables(file)).map(([table, { columns }]) => [
|
|
1350
|
+
table,
|
|
1351
|
+
columns
|
|
1352
|
+
]));
|
|
1353
|
+
const { sql, notes } = withStartingValues(readText(path), tables);
|
|
1354
|
+
writeFileSync(path, sql);
|
|
1355
|
+
return notes.map((note) => `${file}: ${note}`);
|
|
1356
|
+
}
|
|
1357
|
+
function ownerBackfillWarning(name) {
|
|
1358
|
+
const file = newestMigration(name);
|
|
1299
1359
|
if (file === undefined)
|
|
1300
1360
|
return undefined;
|
|
1301
1361
|
const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8');
|
|
@@ -1492,6 +1552,43 @@ function installShim() {
|
|
|
1492
1552
|
path: SHIM_PATH
|
|
1493
1553
|
};
|
|
1494
1554
|
}
|
|
1555
|
+
async function update() {
|
|
1556
|
+
const current = sdkVersion();
|
|
1557
|
+
const latest = await latestPattern(true);
|
|
1558
|
+
if (latest === undefined) {
|
|
1559
|
+
return {
|
|
1560
|
+
code: 1,
|
|
1561
|
+
data: { current, error: 'npm did not answer' },
|
|
1562
|
+
text: '✗ Couldn’t reach npm to find the newest Pattern. Check the internet connection and try again.'
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
if (!isNewer(latest, current)) {
|
|
1566
|
+
return {
|
|
1567
|
+
code: 0,
|
|
1568
|
+
data: { current, latest, updated: false },
|
|
1569
|
+
text: `✓ Pattern is up to date (${current})`
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
say(`Updating Pattern ${current} → ${latest}`);
|
|
1573
|
+
const installed = await capture('pnpm', ['add', `@trypattern/sdk@${latest}`]);
|
|
1574
|
+
if (installed.code !== 0) {
|
|
1575
|
+
return {
|
|
1576
|
+
code: 1,
|
|
1577
|
+
data: {
|
|
1578
|
+
current,
|
|
1579
|
+
latest,
|
|
1580
|
+
error: 'pnpm add failed',
|
|
1581
|
+
output: installed.output
|
|
1582
|
+
},
|
|
1583
|
+
text: `✗ pnpm couldn’t install Pattern ${latest}:\n${installed.output.trim().split('\n').slice(-LOG_TAIL_LINES).join('\n')}`
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
return {
|
|
1587
|
+
code: 0,
|
|
1588
|
+
data: { current, latest, updated: true },
|
|
1589
|
+
text: `✓ Updated Pattern ${current} → ${latest}\n next run app check, then app publish --message 'Updated Pattern' (straight away if nothing else is in progress) so the live site gets it`
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1495
1592
|
async function setup() {
|
|
1496
1593
|
const pins = readPins();
|
|
1497
1594
|
const haveNode = process.versions.node;
|
|
@@ -1635,6 +1732,16 @@ async function main(argv) {
|
|
|
1635
1732
|
}
|
|
1636
1733
|
else {
|
|
1637
1734
|
outcome = await command.run(positional[1], options, positional.slice(1));
|
|
1735
|
+
if (NUDGED_COMMANDS.has(name)) {
|
|
1736
|
+
const latest = await newerPattern(sdkVersion());
|
|
1737
|
+
if (latest !== undefined) {
|
|
1738
|
+
outcome = {
|
|
1739
|
+
...outcome,
|
|
1740
|
+
data: { ...outcome.data, newerPattern: latest },
|
|
1741
|
+
text: `${outcome.text}\n${updateNote(latest, sdkVersion())}`
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1638
1745
|
}
|
|
1639
1746
|
if (jsonMode) {
|
|
1640
1747
|
const payload = {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type Columns = Readonly<Record<string, {
|
|
2
|
+
notNull: boolean;
|
|
3
|
+
}>>;
|
|
4
|
+
export type PreviousTables = Readonly<Record<string, Columns>>;
|
|
5
|
+
export declare function withStartingValues(sql: string, previous: PreviousTables): {
|
|
6
|
+
sql: string;
|
|
7
|
+
notes: string[];
|
|
8
|
+
};
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const ADDED = /^ALTER TABLE `(\w+)` ADD `(\w+)` (text|integer|real) NOT NULL;/gm;
|
|
2
|
+
const REBUILT = /CREATE TABLE `__new_(\w+)` \(([^;]*?)\n\);(\n(?:--> statement-breakpoint\n)?\n?)INSERT INTO `__new_\1`\(([^)]*)\) SELECT ([^;]*) FROM `\1`;/g;
|
|
3
|
+
const REQUIRED = /^(\s*`(\w+)` (text|integer|real)) NOT NULL(,?)$/;
|
|
4
|
+
const STARTING_VALUES = {
|
|
5
|
+
text: "''",
|
|
6
|
+
integer: '0',
|
|
7
|
+
real: '0'
|
|
8
|
+
};
|
|
9
|
+
const SPOKEN = {
|
|
10
|
+
text: 'empty',
|
|
11
|
+
integer: '0 (or "no")',
|
|
12
|
+
real: '0'
|
|
13
|
+
};
|
|
14
|
+
function quoted(names) {
|
|
15
|
+
return names.split(',').map((name) => name.trim().replaceAll('"', ''));
|
|
16
|
+
}
|
|
17
|
+
export function withStartingValues(sql, previous) {
|
|
18
|
+
const started = [];
|
|
19
|
+
const added = sql.replaceAll(ADDED, (_line, table, column, type) => {
|
|
20
|
+
started.push({ table, column, type });
|
|
21
|
+
return `ALTER TABLE \`${table}\` ADD \`${column}\` ${type} NOT NULL DEFAULT ${STARTING_VALUES[type] ?? "''"};`;
|
|
22
|
+
});
|
|
23
|
+
const rebuilt = added.replaceAll(REBUILT, (whole, table, body, between, into, from) => {
|
|
24
|
+
const old = previous[table];
|
|
25
|
+
if (old === undefined)
|
|
26
|
+
return whole;
|
|
27
|
+
const types = new Map();
|
|
28
|
+
const lines = body.split('\n').map((line) => {
|
|
29
|
+
const match = REQUIRED.exec(line);
|
|
30
|
+
if (match === null || line.includes('PRIMARY KEY'))
|
|
31
|
+
return line;
|
|
32
|
+
const [, start = '', column = '', type = '', comma = ''] = match;
|
|
33
|
+
types.set(column, type);
|
|
34
|
+
return `${start} NOT NULL DEFAULT ${STARTING_VALUES[type] ?? "''"}${comma}`;
|
|
35
|
+
});
|
|
36
|
+
const selected = quoted(from).map((column) => {
|
|
37
|
+
const type = types.get(column);
|
|
38
|
+
const before = old[column];
|
|
39
|
+
if (type === undefined)
|
|
40
|
+
return `"${column}"`;
|
|
41
|
+
const value = STARTING_VALUES[type] ?? "''";
|
|
42
|
+
if (before === undefined) {
|
|
43
|
+
started.push({ table, column, type });
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
if (!before.notNull) {
|
|
47
|
+
started.push({ table, column, type });
|
|
48
|
+
return `coalesce("${column}", ${value})`;
|
|
49
|
+
}
|
|
50
|
+
return `"${column}"`;
|
|
51
|
+
});
|
|
52
|
+
return `CREATE TABLE \`__new_${table}\` (${lines.join('\n')}\n);${between}INSERT INTO \`__new_${table}\`(${into}) SELECT ${selected.join(', ')} FROM \`${table}\`;`;
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
sql: rebuilt,
|
|
56
|
+
notes: started.map(({ table, column, type }) => `${table}.${column} starts as ${SPOKEN[type] ?? 'empty'} on rows that already exist. To start them on a real value, add UPDATE \`${table}\` SET \`${column}\` = … after it in the migration.`)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function isNewer(candidate: string, current: string): boolean;
|
|
2
|
+
export declare function latestPattern(fresh?: boolean): Promise<string | undefined>;
|
|
3
|
+
export declare function newerPattern(current: string): Promise<string | undefined>;
|
|
4
|
+
export declare function updateNote(latest: string, current: string): string;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
const LATEST_URL = 'https://registry.npmjs.org/@trypattern/sdk/latest';
|
|
6
|
+
const CACHE_FILE = join(homedir(), '.config', 'pattern', 'latest-sdk.json');
|
|
7
|
+
const CHECK_MS = 6 * 3_600_000;
|
|
8
|
+
const TIMEOUT_MS = 1500;
|
|
9
|
+
const cached = z.object({ version: z.string(), checkedAt: z.number() });
|
|
10
|
+
const published = z.object({ version: z.string() });
|
|
11
|
+
function partsOf(version) {
|
|
12
|
+
return (version
|
|
13
|
+
.split('-')[0]
|
|
14
|
+
?.split('.')
|
|
15
|
+
.map((part) => Number.parseInt(part, 10) || 0) ?? [0]);
|
|
16
|
+
}
|
|
17
|
+
export function isNewer(candidate, current) {
|
|
18
|
+
const next = partsOf(candidate);
|
|
19
|
+
const now = partsOf(current);
|
|
20
|
+
for (let index = 0; index < Math.max(next.length, now.length); index += 1) {
|
|
21
|
+
const difference = (next[index] ?? 0) - (now[index] ?? 0);
|
|
22
|
+
if (difference !== 0)
|
|
23
|
+
return difference > 0;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
function readCache() {
|
|
28
|
+
if (!existsSync(CACHE_FILE))
|
|
29
|
+
return undefined;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = cached.safeParse(JSON.parse(readFileSync(CACHE_FILE, 'utf8')));
|
|
32
|
+
return parsed.success ? parsed.data : undefined;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function latestPattern(fresh = false) {
|
|
39
|
+
const remembered = readCache();
|
|
40
|
+
if (!fresh &&
|
|
41
|
+
remembered !== undefined &&
|
|
42
|
+
Date.now() - remembered.checkedAt < CHECK_MS) {
|
|
43
|
+
return remembered.version;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetch(LATEST_URL, {
|
|
47
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
48
|
+
});
|
|
49
|
+
const body = published.safeParse(await response.json());
|
|
50
|
+
if (!response.ok || !body.success)
|
|
51
|
+
return remembered?.version;
|
|
52
|
+
mkdirSync(dirname(CACHE_FILE), { recursive: true });
|
|
53
|
+
writeFileSync(CACHE_FILE, JSON.stringify({ version: body.data.version, checkedAt: Date.now() }));
|
|
54
|
+
return body.data.version;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return remembered?.version;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function newerPattern(current) {
|
|
61
|
+
if (process.env['PATTERN_NO_UPDATE_CHECK'] !== undefined)
|
|
62
|
+
return undefined;
|
|
63
|
+
const latest = await latestPattern();
|
|
64
|
+
return latest !== undefined && isNewer(latest, current) ? latest : undefined;
|
|
65
|
+
}
|
|
66
|
+
export function updateNote(latest, current) {
|
|
67
|
+
return ` update Pattern ${latest} is out (this project has ${current}). Run app update now, then app check. Publish it straight away if nothing else is in progress, otherwise with the next publish, so the live site gets it too. Tell the owner in a few words.`;
|
|
68
|
+
}
|