@steve31415/baselib 3.48.2 → 3.49.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/dist/db.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // (per-checkout instance — see TESTING.md). Cloud Run: the Cloud SQL Node
3
3
  // connector with IAM authentication (CLOUD_SQL_INSTANCE + DB_IAM_USER +
4
4
  // DB_NAME env vars) — no database passwords anywhere in the serving path.
5
+ import { createHash } from 'node:crypto';
5
6
  import { readdir, readFile } from 'node:fs/promises';
6
7
  import { join } from 'node:path';
7
8
  import pg from 'pg';
@@ -367,8 +368,10 @@ export async function endPool(pool) {
367
368
  await allClosed;
368
369
  }
369
370
  // Plain-SQL migrations: numbered files (001-*.sql, 002-*.sql, …) applied in
370
- // order, recorded in schema_migrations, each inside a transaction, guarded by
371
- // an advisory lock so concurrent boots can't race. Applied at service boot.
371
+ // order, recorded in schema_migrations with their content's sha256, each
372
+ // inside a transaction, guarded by an advisory lock so concurrent boots can't
373
+ // race. Applied at service boot; an applied file whose content has since
374
+ // changed fails the boot.
372
375
  // Lock loss (see logPoolErrors): all the work runs on the lock's own client,
373
376
  // so a dead connection fails the next statement and the boot aborts — no
374
377
  // window in which a migration runs unlocked.
@@ -380,18 +383,48 @@ export async function migrate(pool, migrationsDir, logger) {
380
383
  await client.query('select pg_advisory_lock(hashtext($1))', ['baselib_migrate']);
381
384
  await client.query(`create table if not exists schema_migrations (
382
385
  name text primary key,
383
- applied_at timestamptz not null default now()
386
+ applied_at timestamptz not null default now(),
387
+ sha256 text
384
388
  )`);
385
- const { rows } = await client.query('select name from schema_migrations');
386
- const done = new Set(rows.map((r) => r.name));
389
+ await client.query('alter table schema_migrations add column if not exists sha256 text');
390
+ const { rows } = await client.query('select name, sha256 from schema_migrations');
391
+ const recorded = new Map(rows.map((r) => [r.name, r.sha256]));
392
+ const contents = new Map();
387
393
  for (const file of files) {
388
- if (done.has(file))
389
- continue;
390
394
  const sql = await readFile(join(migrationsDir, file), 'utf8');
395
+ contents.set(file, { sql, sha256: createHash('sha256').update(sql).digest('hex') });
396
+ }
397
+ // A shipped migration file is never edited (plasticine-way docs/DATABASE.md): the
398
+ // runner skips a recorded name, so an edit would never reach the database while the
399
+ // code relying on it ships. Refuse to boot instead, before applying anything, so the
400
+ // new revision fails its start and the old one keeps serving (people, 2026-11-02).
401
+ // Rows recorded before hashes were kept get the file's current hash, once.
402
+ const edited = [];
403
+ for (const file of files) {
404
+ if (!recorded.has(file))
405
+ continue;
406
+ const { sha256 } = contents.get(file);
407
+ const was = recorded.get(file);
408
+ if (was == null) {
409
+ await client.query('update schema_migrations set sha256 = $2 where name = $1', [file, sha256]);
410
+ }
411
+ else if (was !== sha256) {
412
+ edited.push(file);
413
+ }
414
+ }
415
+ if (edited.length > 0) {
416
+ throw new Error(`migration ${edited.join(', ')} was edited after it was applied; a shipped migration is never ` +
417
+ 'edited: restore the file and put the change in a new migration (a local database holding an ' +
418
+ 'unshipped draft: drop it and re-run)');
419
+ }
420
+ for (const file of files) {
421
+ if (recorded.has(file))
422
+ continue;
423
+ const { sql, sha256 } = contents.get(file);
391
424
  try {
392
425
  await client.query('begin');
393
426
  await client.query(sql);
394
- await client.query('insert into schema_migrations (name) values ($1)', [file]);
427
+ await client.query('insert into schema_migrations (name, sha256) values ($1, $2)', [file, sha256]);
395
428
  await client.query('commit');
396
429
  }
397
430
  catch (err) {
@@ -46,13 +46,25 @@ export declare function codeIdOf(files: Record<string, string>): Promise<string>
46
46
  */
47
47
  export declare function diskCode(): Promise<string | null>;
48
48
  /**
49
- * Record the code Chrome just loaded. Call from runtime.onInstalled and
50
- * runtime.onStartup — the moments Chrome reads the extension from disk — and
51
- * nowhere else: a worker restarting after eviction runs the code loaded
52
- * earlier, whatever the disk holds now.
49
+ * Record the code Chrome just loaded. Call from runtime.onInstalled ONLY: that
50
+ * is when Chrome registers the worker script afresh from disk (install,
51
+ * chrome.runtime.reload(), a click on reload). Not from runtime.onStartup: a
52
+ * browser restart re-reads an unpacked extension's manifest and content
53
+ * scripts from disk but keeps running the worker script it registered
54
+ * earlier, even when the file on disk changed (measured 2026-11-02: Lurch's
55
+ * card said 2.6 after a restart while its worker still ran 2.5's code). And
56
+ * never on a worker restart after eviction, which also runs the registered
57
+ * script whatever the disk holds now.
53
58
  */
54
59
  export declare function recordLoadedCode(): Promise<string>;
55
- /** The code this extension runs: the record from its last load (re-taken when the record predates this manifest). */
60
+ /**
61
+ * The code this extension runs: the record from its last load. 'unverified'
62
+ * when there is no record, or when the record predates the manifest Chrome now
63
+ * shows (a browser restart picked up a new manifest while the worker may still
64
+ * be the old script) — never re-read from disk, which would label an old
65
+ * worker as the new code. 'unverified' differs from every deployed id, so the
66
+ * extension reloads itself into the deployed code once it is on disk.
67
+ */
56
68
  export declare function loadedCode(): Promise<string>;
57
69
  /** Mark the extension busy for BUSY_LEASE_MS (an open overlay calls this through the worker every ~10 s). */
58
70
  export declare function extendBusyLease(now?: number): Promise<void>;
@@ -105,10 +105,15 @@ export async function diskCode() {
105
105
  return id;
106
106
  }
107
107
  /**
108
- * Record the code Chrome just loaded. Call from runtime.onInstalled and
109
- * runtime.onStartup — the moments Chrome reads the extension from disk — and
110
- * nowhere else: a worker restarting after eviction runs the code loaded
111
- * earlier, whatever the disk holds now.
108
+ * Record the code Chrome just loaded. Call from runtime.onInstalled ONLY: that
109
+ * is when Chrome registers the worker script afresh from disk (install,
110
+ * chrome.runtime.reload(), a click on reload). Not from runtime.onStartup: a
111
+ * browser restart re-reads an unpacked extension's manifest and content
112
+ * scripts from disk but keeps running the worker script it registered
113
+ * earlier, even when the file on disk changed (measured 2026-11-02: Lurch's
114
+ * card said 2.6 after a restart while its worker still ran 2.5's code). And
115
+ * never on a worker restart after eviction, which also runs the registered
116
+ * script whatever the disk holds now.
112
117
  */
113
118
  export async function recordLoadedCode() {
114
119
  const code = (await diskCode()) ?? 'unverified';
@@ -116,13 +121,20 @@ export async function recordLoadedCode() {
116
121
  await chrome.storage.local.set({ [LOADED_KEY]: record });
117
122
  return code;
118
123
  }
119
- /** The code this extension runs: the record from its last load (re-taken when the record predates this manifest). */
124
+ /**
125
+ * The code this extension runs: the record from its last load. 'unverified'
126
+ * when there is no record, or when the record predates the manifest Chrome now
127
+ * shows (a browser restart picked up a new manifest while the worker may still
128
+ * be the old script) — never re-read from disk, which would label an old
129
+ * worker as the new code. 'unverified' differs from every deployed id, so the
130
+ * extension reloads itself into the deployed code once it is on disk.
131
+ */
120
132
  export async function loadedCode() {
121
133
  const { [LOADED_KEY]: stored } = await chrome.storage.local.get(LOADED_KEY);
122
134
  const record = stored;
123
135
  if (record && typeof record.code === 'string' && record.version === chrome.runtime.getManifest().version)
124
136
  return record.code;
125
- return recordLoadedCode();
137
+ return 'unverified';
126
138
  }
127
139
  /** Mark the extension busy for BUSY_LEASE_MS (an open overlay calls this through the worker every ~10 s). */
128
140
  export async function extendBusyLease(now = Date.now()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.48.2",
3
+ "version": "3.49.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",