@steve31415/baselib 3.4.0 → 3.5.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/README.md CHANGED
@@ -6,8 +6,8 @@ What it provides and why: `docs/SPEC.md`. How it's put together:
6
6
  `~/migration/research/base-services-design.md` (step 5).
7
7
 
8
8
  Server subpath exports: `config`, `log`, `auth`, `s2s`, `db`, `http`, `sync`,
9
- `app-update`, `llm`, and `email`. Browser exports: `log-browser`, `rum`, `sync-browser`, and
10
- `app-update-browser`.
9
+ `app-update`, `llm`, and `email`. Browser exports: `log-browser`, `rum`,
10
+ `slow-fetch`, `sync-browser`, and `app-update-browser`.
11
11
 
12
12
  `sync` provides the Postgres event-log and server protocol primitives;
13
13
  `sync-browser` provides the IndexedDB outbox and offline-first client.
@@ -34,4 +34,9 @@ deploy (`~/plasticine-way/docs/OPERATIONS.md`, "Deployment safety").
34
34
  first step of any production investigation (same doc, "Investigating
35
35
  production state").
36
36
 
37
+ Releasing: bump `version`, commit, and run `npm run deploy` (Coder runs it
38
+ automatically when integrating a baselib branch) — it pushes the `v<version>`
39
+ tag, whose workflow gates on verify and publishes via OIDC, and waits for the
40
+ registry (`~/plasticine-way/docs/OPERATIONS.md`, "Publishing baselib").
41
+
37
42
  Reflects Plasticine Way commit: see `docs/IMPL.md` footer.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ // baselib's `npm run deploy` — release the checked-out version (tag push +
3
+ // wait for the registry; the tag-triggered workflow does the actual
4
+ // publish). All logic lives in ../release.ts.
5
+ import { realRunner } from '../deploy/exec.js';
6
+ import { release } from '../release.js';
7
+ try {
8
+ await release(realRunner, {
9
+ log: (m) => console.log(m),
10
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
11
+ });
12
+ }
13
+ catch (e) {
14
+ console.error(e instanceof Error ? e.message : String(e));
15
+ process.exit(1);
16
+ }
@@ -1,4 +1,4 @@
1
- import { GoogleAuth, OAuth2Client } from 'google-auth-library';
1
+ import { type AuthClient } from 'google-auth-library';
2
2
  import type { Runner } from '../deploy/types.js';
3
3
  export type GcpCredential = {
4
4
  kind: 'adc';
@@ -22,4 +22,16 @@ export interface CredentialDeps {
22
22
  export declare function resolveGcpCredential(deps?: CredentialDeps): Promise<GcpCredential>;
23
23
  /** Short human description for the tool's progress line. */
24
24
  export declare function describeCredential(cred: GcpCredential): string;
25
- export declare function authClientFor(cred: GcpCredential): GoogleAuth | OAuth2Client;
25
+ /** The client the connector will mint the IAM login token from. Always a
26
+ * concrete AuthClient (JWT for a service-account key, UserRefreshClient for
27
+ * an authorized-user ADC file, OAuth2Client around a ready token), never a
28
+ * GoogleAuth: the connector tells the two apart with `instanceof GoogleAuth`
29
+ * against ITS copy of google-auth-library, and when a consumer's install
30
+ * hoists a different copy than baselib's (coder2, 2026-09-04 — connector
31
+ * 1.12 wants ^11, baselib ^10) a GoogleAuth of ours fails that check, gets
32
+ * wrapped as if it were an AuthClient, and the wrapper's getAccessToken()
33
+ * reads `.token` off the bare string GoogleAuth returns: "Failed to get
34
+ * access token for automatic IAM authentication". An AuthClient crosses the
35
+ * package boundary by duck typing alone, so the client carries both Cloud
36
+ * SQL scopes itself rather than relying on the wrapper's. */
37
+ export declare function authClientFor(cred: GcpCredential): Promise<AuthClient>;
@@ -54,11 +54,23 @@ export function describeCredential(cred) {
54
54
  return `gcloud access token for ${cred.account}`;
55
55
  }
56
56
  }
57
- export function authClientFor(cred) {
57
+ /** The client the connector will mint the IAM login token from. Always a
58
+ * concrete AuthClient (JWT for a service-account key, UserRefreshClient for
59
+ * an authorized-user ADC file, OAuth2Client around a ready token), never a
60
+ * GoogleAuth: the connector tells the two apart with `instanceof GoogleAuth`
61
+ * against ITS copy of google-auth-library, and when a consumer's install
62
+ * hoists a different copy than baselib's (coder2, 2026-09-04 — connector
63
+ * 1.12 wants ^11, baselib ^10) a GoogleAuth of ours fails that check, gets
64
+ * wrapped as if it were an AuthClient, and the wrapper's getAccessToken()
65
+ * reads `.token` off the bare string GoogleAuth returns: "Failed to get
66
+ * access token for automatic IAM authentication". An AuthClient crosses the
67
+ * package boundary by duck typing alone, so the client carries both Cloud
68
+ * SQL scopes itself rather than relying on the wrapper's. */
69
+ export async function authClientFor(cred) {
58
70
  if (cred.kind === 'access-token') {
59
71
  const client = new OAuth2Client();
60
72
  client.setCredentials({ access_token: cred.token });
61
73
  return client;
62
74
  }
63
- return new GoogleAuth({ keyFilename: cred.path, scopes: CLOUD_SQL_SCOPES });
75
+ return new GoogleAuth({ keyFilename: cred.path, scopes: CLOUD_SQL_SCOPES }).getClient();
64
76
  }
package/dist/ops/sql.js CHANGED
@@ -104,7 +104,7 @@ function outputTypes() {
104
104
  export async function connectCloudSql(target, log, runner) {
105
105
  const cred = await resolveGcpCredential({ runner });
106
106
  log(`connecting to ${target.instance} db=${target.database} as ${target.user} via ${describeCredential(cred)}`);
107
- const { options: connection, connector } = await cloudSqlClientOptions(target.instance, authClientFor(cred));
107
+ const { options: connection, connector } = await cloudSqlClientOptions(target.instance, await authClientFor(cred));
108
108
  const client = new pg.Client({
109
109
  ...connection,
110
110
  user: target.user,
@@ -0,0 +1,15 @@
1
+ import type { Runner } from './deploy/types.js';
2
+ export declare const PUBLISH_POLL_MS = 20000;
3
+ export declare const PUBLISH_TIMEOUT_MS: number;
4
+ export interface ReleaseIo {
5
+ log: (message: string) => void;
6
+ sleep: (ms: number) => Promise<void>;
7
+ }
8
+ export interface ReleaseOptions {
9
+ cwd?: string;
10
+ pollMs?: number;
11
+ timeoutMs?: number;
12
+ }
13
+ /** Release the checked-out version; throws (with a pointer to the publish
14
+ * workflow) on drift or when the registry never serves it. */
15
+ export declare function release(runner: Runner, io: ReleaseIo, opts?: ReleaseOptions): Promise<void>;
@@ -0,0 +1,81 @@
1
+ // baselib's own `npm run deploy`: release the version in package.json by
2
+ // pushing the `v<version>` tag and waiting for the registry to serve it.
3
+ // The actual publish stays where it is — the tag triggers
4
+ // .github/workflows/publish.yml, which gates on `npm run verify` and
5
+ // publishes via npm Trusted Publishing (OIDC) — so this script adds no
6
+ // publish authority; it only automates the tag push that used to be
7
+ // Steve's manual step (decision 2026-09-06). Giving baselib a `deploy`
8
+ // script also slots releases into Coder's ordered per-project deploys:
9
+ // a task can bump baselib and, once it serves, deploy consumers after it.
10
+ //
11
+ // Idempotent and drift-guarded:
12
+ // - version already on the registry, no source drift since its tag → no-op
13
+ // (docs-only changes release nothing);
14
+ // - version already on the registry but src/ or package.json changed since
15
+ // its tag → FAIL loudly ("bump the version") instead of silently shipping
16
+ // nothing;
17
+ // - tag already pushed but not yet on the registry (a publish in flight, or
18
+ // a rerun after a workflow failure) → skip the push, wait for the registry.
19
+ import { readFileSync } from 'node:fs';
20
+ import { must } from './deploy/exec.js';
21
+ const PACKAGE = '@steve31415/baselib';
22
+ const ACTIONS_URL = 'https://github.com/plasticine-apps/baselib/actions';
23
+ export const PUBLISH_POLL_MS = 20_000;
24
+ export const PUBLISH_TIMEOUT_MS = 15 * 60_000;
25
+ // Files whose change must not go unreleased. docs/, test/, and workflow
26
+ // changes ride along with the next real release.
27
+ const RELEASED_PATHS = ['src', 'package.json', 'package-lock.json'];
28
+ /** The version on the registry for `spec`, or null when absent. */
29
+ async function publishedVersion(runner, version, cwd) {
30
+ const r = await runner('npm', ['view', `${PACKAGE}@${version}`, 'version'], { cwd });
31
+ const out = r.stdout.trim();
32
+ return r.code === 0 && out === version ? version : null;
33
+ }
34
+ async function tagOnOrigin(runner, tag, cwd) {
35
+ const r = await must(runner, 'git', ['ls-remote', 'origin', `refs/tags/${tag}`], { cwd });
36
+ return r.stdout.trim().length > 0;
37
+ }
38
+ /** Release the checked-out version; throws (with a pointer to the publish
39
+ * workflow) on drift or when the registry never serves it. */
40
+ export async function release(runner, io, opts = {}) {
41
+ const cwd = opts.cwd;
42
+ const pkg = JSON.parse(readFileSync(`${cwd ?? '.'}/package.json`, 'utf8'));
43
+ if (!pkg.version)
44
+ throw new Error('package.json has no version');
45
+ const version = pkg.version;
46
+ const tag = `v${version}`;
47
+ if (await publishedVersion(runner, version, cwd)) {
48
+ // Already released. Guard the footgun: released paths changed since the
49
+ // tag means someone forgot the bump — fail rather than silently ship
50
+ // nothing. (Ensure the tag exists locally first; a fresh clone may not
51
+ // have fetched it.)
52
+ const local = await runner('git', ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`], { cwd });
53
+ if (local.code !== 0)
54
+ await must(runner, 'git', ['fetch', 'origin', 'tag', tag], { cwd });
55
+ const drift = await runner('git', ['diff', '--quiet', tag, 'HEAD', '--', ...RELEASED_PATHS], { cwd });
56
+ if (drift.code !== 0) {
57
+ throw new Error(`${RELEASED_PATHS.join('/')} changed since ${tag} was released — bump the version in package.json to release the change`);
58
+ }
59
+ io.log(`${PACKAGE}@${version} is already on the registry and matches ${tag}; nothing to release`);
60
+ return;
61
+ }
62
+ if (await tagOnOrigin(runner, tag, cwd)) {
63
+ io.log(`tag ${tag} is already on origin (publish in flight or previously failed); waiting for the registry`);
64
+ }
65
+ else {
66
+ await must(runner, 'git', ['tag', tag], { cwd });
67
+ await must(runner, 'git', ['push', 'origin', tag], { cwd });
68
+ io.log(`pushed ${tag}; the publish workflow (verify gate + OIDC publish) is running`);
69
+ }
70
+ const timeoutMs = opts.timeoutMs ?? PUBLISH_TIMEOUT_MS;
71
+ const pollMs = opts.pollMs ?? PUBLISH_POLL_MS;
72
+ for (let waited = 0; waited < timeoutMs; waited += pollMs) {
73
+ await io.sleep(pollMs);
74
+ if (await publishedVersion(runner, version, cwd)) {
75
+ io.log(`${PACKAGE}@${version} is live on the registry`);
76
+ return;
77
+ }
78
+ io.log(`waiting for ${PACKAGE}@${version} on the registry (${Math.round((waited + pollMs) / 1000)}s)`);
79
+ }
80
+ throw new Error(`${PACKAGE}@${version} did not appear on the registry within ${Math.round(timeoutMs / 60_000)} min — check the publish workflow: ${ACTIONS_URL}`);
81
+ }
@@ -0,0 +1,21 @@
1
+ import { type Logger } from './log-core.js';
2
+ export declare const DEFAULT_SLOW_FETCH_MS = 2000;
3
+ export declare const DEFAULT_MAX_REPORTS_PER_PAGE = 20;
4
+ export interface SlowFetchOptions {
5
+ /** Successful fetches at or above this many ms are logged (default 2000). */
6
+ slowMs?: number;
7
+ /** Ceiling on reports per page life, slow and failed combined (default 20). */
8
+ maxReportsPerPage?: number;
9
+ }
10
+ /** Wire the reporter once at startup; until then reportFetchOutcome no-ops
11
+ * (so unit tests exercising the fetch wrapper stay silent). Also widens the
12
+ * resource-timing buffer and clears it when full: a polling app fills the
13
+ * default 250-entry buffer within minutes, after which timing lookups come
14
+ * back empty for the rest of the tab's life. Nothing else reads resource
15
+ * entries; if a consumer does, it should own the buffer instead. */
16
+ export declare function initSlowFetchLogging(l: Logger, opts?: SlowFetchOptions): void;
17
+ /** Call on every fetch settle from the app's fetch wrapper. Logs the slow
18
+ * successes and the failures; fast successes and deliberate aborts are
19
+ * silent. `status` is the HTTP status on success, null on rejection;
20
+ * `error` is the thrown value on rejection, omitted on success. */
21
+ export declare function reportFetchOutcome(path: string, method: string, startedMs: number, status: number | null, error?: unknown): void;
@@ -0,0 +1,87 @@
1
+ // Diagnostic timing for slow or failed browser fetches. A request can stall
2
+ // entirely client-side — dead connection reuse after an idle gap, QUIC→TCP
3
+ // fallback, DNS — where server logs show nothing: a coder2 enqueue on
4
+ // 2026-09-06 took ~10 s at the browser against 0.94 s on the server, and no
5
+ // log could attribute the gap. An app wires reportFetchOutcome into its own
6
+ // fetch wrapper (the one place all API traffic settles); any fetch slower
7
+ // than the threshold, or one that rejects, ships a browser log carrying the
8
+ // Resource Timing milestones — connection setup vs first-byte wait vs
9
+ // download, plus the negotiated protocol (h2 vs h3) — which is exactly the
10
+ // split those incidents need.
11
+ //
12
+ // Browser-only module — do not import server-side.
13
+ import { serializeError } from './log-core.js';
14
+ export const DEFAULT_SLOW_FETCH_MS = 2000;
15
+ // A broken network could make every poll slow: cap the reports per page life.
16
+ export const DEFAULT_MAX_REPORTS_PER_PAGE = 20;
17
+ let logger = null;
18
+ let slowMs = DEFAULT_SLOW_FETCH_MS;
19
+ let maxReports = DEFAULT_MAX_REPORTS_PER_PAGE;
20
+ let reported = 0;
21
+ /** Wire the reporter once at startup; until then reportFetchOutcome no-ops
22
+ * (so unit tests exercising the fetch wrapper stay silent). Also widens the
23
+ * resource-timing buffer and clears it when full: a polling app fills the
24
+ * default 250-entry buffer within minutes, after which timing lookups come
25
+ * back empty for the rest of the tab's life. Nothing else reads resource
26
+ * entries; if a consumer does, it should own the buffer instead. */
27
+ export function initSlowFetchLogging(l, opts = {}) {
28
+ logger = l;
29
+ slowMs = opts.slowMs ?? DEFAULT_SLOW_FETCH_MS;
30
+ maxReports = opts.maxReportsPerPage ?? DEFAULT_MAX_REPORTS_PER_PAGE;
31
+ reported = 0;
32
+ performance.setResourceTimingBufferSize?.(1000);
33
+ performance.addEventListener?.('resourcetimingbufferfull', () => performance.clearResourceTimings());
34
+ }
35
+ /** The fetch's Resource Timing milestones, ms relative to its start; null
36
+ * when the entry is gone (a failed request records none, or the buffer was
37
+ * cleared) or the environment lacks the API. A milestone of null means the
38
+ * phase was absent — e.g. no connect_* on a reused connection. */
39
+ function timingFor(url, startedMs, endedMs) {
40
+ if (typeof performance.getEntriesByName !== 'function')
41
+ return null;
42
+ const entries = performance.getEntriesByName(url, 'resource');
43
+ // Polls reuse URLs: take the last entry that started in this fetch's window.
44
+ const entry = entries.filter((e) => e.startTime >= startedMs - 50 && e.startTime <= endedMs).at(-1);
45
+ if (!entry)
46
+ return null;
47
+ const rel = (t) => (t === 0 ? null : Math.round(t - entry.startTime));
48
+ return {
49
+ protocol: entry.nextHopProtocol || null,
50
+ fetch_start_ms: rel(entry.fetchStart),
51
+ domain_lookup_start_ms: rel(entry.domainLookupStart),
52
+ connect_start_ms: rel(entry.connectStart),
53
+ secure_connection_start_ms: rel(entry.secureConnectionStart),
54
+ connect_end_ms: rel(entry.connectEnd),
55
+ request_start_ms: rel(entry.requestStart),
56
+ response_start_ms: rel(entry.responseStart),
57
+ response_end_ms: rel(entry.responseEnd),
58
+ transfer_size: entry.transferSize,
59
+ };
60
+ }
61
+ /** Call on every fetch settle from the app's fetch wrapper. Logs the slow
62
+ * successes and the failures; fast successes and deliberate aborts are
63
+ * silent. `status` is the HTTP status on success, null on rejection;
64
+ * `error` is the thrown value on rejection, omitted on success. */
65
+ export function reportFetchOutcome(path, method, startedMs, status, error) {
66
+ if (!logger || reported >= maxReports)
67
+ return;
68
+ // Deliberate aborts (a page navigation cancelling its poll) are not failures.
69
+ if (error instanceof DOMException && error.name === 'AbortError')
70
+ return;
71
+ const endedMs = performance.now();
72
+ const durationMs = Math.round(endedMs - startedMs);
73
+ const failed = error !== undefined;
74
+ if (!failed && durationMs < slowMs)
75
+ return;
76
+ reported += 1;
77
+ logger.warn(failed ? 'fetch_failed' : 'slow_fetch', {
78
+ path,
79
+ method,
80
+ status,
81
+ duration_ms: durationMs,
82
+ online: navigator.onLine,
83
+ visibility: document.visibilityState,
84
+ ...(failed ? { error: serializeError(error) } : {}),
85
+ timing: timingFor(new URL(path, location.href).href, startedMs, endedMs),
86
+ });
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.4.0",
3
+ "version": "3.5.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",
@@ -45,6 +45,10 @@
45
45
  "types": "./dist/rum.d.ts",
46
46
  "default": "./dist/rum.js"
47
47
  },
48
+ "./slow-fetch": {
49
+ "types": "./dist/slow-fetch.d.ts",
50
+ "default": "./dist/slow-fetch.js"
51
+ },
48
52
  "./sync": {
49
53
  "types": "./dist/sync/index.d.ts",
50
54
  "default": "./dist/sync/index.js"
@@ -82,6 +86,7 @@
82
86
  },
83
87
  "scripts": {
84
88
  "build": "tsc -p tsconfig.build.json",
89
+ "deploy": "tsx src/bin/release.ts",
85
90
  "prepack": "npm run build",
86
91
  "typecheck": "tsc --noEmit",
87
92
  "test": "vitest run",