@oh-my-pi/pi-utils 18.11.0 → 18.11.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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.11.2] - 2026-10-16
6
+
7
+ ### Added
8
+
9
+ - Exported `sqliteFileIdentity()`, so a caller holding a long-lived SQLite connection can tell when its path now names a different file (e.g. after a corrupt store was quarantined and replaced) ([#14361](https://github.com/can1357/oh-my-pi/pull/14361) by [@F0Rextasy](https://github.com/F0Rextasy))
10
+ - Added `slugify()`, the shared lowercase ASCII kebab-case helper with an optional `maxLength` ([#14283](https://github.com/can1357/oh-my-pi/pull/14283) by [@jaredlyon](https://github.com/jaredlyon))
11
+
12
+ ### Fixed
13
+
14
+ - Fixed an unhandled rejection when a subprocess is spawned with an already-aborted signal ([#15750](https://github.com/can1357/oh-my-pi/pull/15750) by [@harshaygadekar](https://github.com/harshaygadekar))
15
+
5
16
  ## [18.10.0] - 2026-10-12
6
17
 
7
18
  ### Added
@@ -48,6 +48,18 @@
48
48
  * await args.key("path").value<string>(); // "a.ts"
49
49
  * ```
50
50
  */
51
+ /**
52
+ * @internal exported for tests — monotonic count of members re-lexed by
53
+ * selecting scans: one per object member or array element a scan walked over
54
+ * on the way to the member it was asked for.
55
+ *
56
+ * Cursor resumption (see {@link ContainerIndex}) is what keeps a full
57
+ * traversal linear in the document instead of quadratic, and losing it
58
+ * changes nothing about the pulled values — only how many members get
59
+ * re-lexed. Read the live binding before and after a traversal to measure
60
+ * exactly that.
61
+ */
62
+ export declare let rescannedMembers: number;
51
63
  /** Location component in a pulled JSON path: an object member name or an array index. */
52
64
  export type PullPathSegment = string | number;
53
65
  /** JSON shape observed by a started pull. */
@@ -31,6 +31,7 @@ export * as ptree from "./ptree.js";
31
31
  export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree.js";
32
32
  export * from "./runtime-install.js";
33
33
  export * from "./sanitize-text.js";
34
+ export * from "./slugify.js";
34
35
  export * from "./snowflake.js";
35
36
  export * from "./sqlite.js";
36
37
  export * from "./stderr-guard.js";
@@ -26,5 +26,26 @@ export type ImageMetadata = {
26
26
  hasAlpha?: boolean;
27
27
  };
28
28
  export declare function parseImageMetadata(header: Uint8Array): ImageMetadata | null;
29
+ /** Why base64 image data is unusable; `undefined` when it is canonical and non-empty. */
30
+ export type Base64ImageDataFailure = "invalid base64 image data" | "empty image data";
31
+ /**
32
+ * Non-empty decoded bytes on success, or a validation failure reason, never both.
33
+ * Check `reason` before using `bytes`; successful base64 decoding does not establish
34
+ * that the bytes represent a supported or decodable image.
35
+ */
36
+ export type Base64ImageDecodeResult = {
37
+ bytes: Buffer;
38
+ reason?: never;
39
+ } | {
40
+ bytes?: never;
41
+ reason: Base64ImageDataFailure;
42
+ };
43
+ /**
44
+ * Validate standard base64 image data without decoding it. Padding may be omitted, but
45
+ * present padding must be canonical, the trailing bits must be zero, and the data non-empty.
46
+ */
47
+ export declare function base64ImageDataFailureReason(data: string): Base64ImageDataFailure | undefined;
48
+ /** Decode standard base64 image data under the {@link base64ImageDataFailureReason} rules. */
49
+ export declare function decodeBase64ImageData(data: string): Base64ImageDecodeResult;
29
50
  export declare function readImageMetadataSync(filePath: string, maxBytes?: number): ImageMetadata | null;
30
51
  export declare function readImageMetadata(filePath: string, maxBytes?: number): Promise<ImageMetadata | null>;
@@ -0,0 +1,4 @@
1
+ /** Lowercase ASCII kebab slug: [^a-z0-9]+ → "-", edge dashes trimmed; optional maxLength truncates then re-trims trailing dashes; throws RangeError unless maxLength is a non-negative integer. Returns "" when nothing survives. */
2
+ export declare function slugify(input: string, options?: {
3
+ maxLength?: number;
4
+ }): string;
@@ -1,5 +1,17 @@
1
1
  /** Shared SQLite opening, error attribution, and result-code classification for persistent stores. */
2
2
  import { Database } from "bun:sqlite";
3
+ /**
4
+ * A store's identity as `dev:ino:birthtimeMs`, `null` when the path holds no
5
+ * file, or `undefined` when the stat itself failed and no conclusion is safe.
6
+ */
7
+ export type SqliteFileIdentity = string | null | undefined;
8
+ /**
9
+ * Identity of the file currently at `dbPath`. Quarantine moves a corrupt store
10
+ * aside and recreates one at the same path, so a connection cached on the path
11
+ * alone keeps writing to the unlinked inode: callers that hold a long-lived
12
+ * handle compare this before reusing it.
13
+ */
14
+ export declare function sqliteFileIdentity(dbPath: string): SqliteFileIdentity;
3
15
  /** Controls opt-in replacement of an unrecoverably corrupt SQLite store. */
4
16
  export interface SqliteOpenOptions {
5
17
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.11.0",
3
+ "version": "18.11.2",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -57,7 +57,7 @@
57
57
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
58
58
  },
59
59
  "dependencies": {
60
- "@oh-my-pi/pi-natives": "18.11.0"
60
+ "@oh-my-pi/pi-natives": "18.11.2"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
@@ -67,6 +67,19 @@ import { parseJsonWithRepair } from "./json-parse";
67
67
  /** Maximum container nesting before a scan reports the value as pending forever. */
68
68
  const MAX_DEPTH = 128;
69
69
 
70
+ /**
71
+ * @internal exported for tests — monotonic count of members re-lexed by
72
+ * selecting scans: one per object member or array element a scan walked over
73
+ * on the way to the member it was asked for.
74
+ *
75
+ * Cursor resumption (see {@link ContainerIndex}) is what keeps a full
76
+ * traversal linear in the document instead of quadratic, and losing it
77
+ * changes nothing about the pulled values — only how many members get
78
+ * re-lexed. Read the live binding before and after a traversal to measure
79
+ * exactly that.
80
+ */
81
+ export let rescannedMembers = 0;
82
+
70
83
  /** Location component in a pulled JSON path: an object member name or an array index. */
71
84
  export type PullPathSegment = string | number;
72
85
 
@@ -325,6 +338,7 @@ function selectKey(
325
338
  lex.ws();
326
339
  if (lex.atEnd) return PENDING;
327
340
  if (key === wanted) return selectValue(lex, path, at, ended, depth + 1, slot);
341
+ rescannedMembers++;
328
342
  const skipped = scanValue(lex, ended, depth + 1);
329
343
  if (skipped.tag !== "located" || skipped.end === undefined) return PENDING;
330
344
  lex.ws();
@@ -370,6 +384,7 @@ function selectIndex(
370
384
  }
371
385
  const slot = index.elements[element] ?? (index.tail ??= { offset: lex.pos });
372
386
  if (element === wanted) return selectValue(lex, path, at, ended, depth + 1, slot);
387
+ rescannedMembers++;
373
388
  const skipped = scanValue(lex, ended, depth + 1);
374
389
  if (skipped.tag !== "located" || skipped.end === undefined) return PENDING;
375
390
  if (element === index.elements.length) {
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ export * as ptree from "./ptree";
31
31
  export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
32
32
  export * from "./runtime-install";
33
33
  export * from "./sanitize-text";
34
+ export * from "./slugify";
34
35
  export * from "./snowflake";
35
36
  export * from "./sqlite";
36
37
  export * from "./stderr-guard";
package/src/mime.ts CHANGED
@@ -143,6 +143,50 @@ export function parseImageMetadata(header: Uint8Array): ImageMetadata | null {
143
143
  );
144
144
  }
145
145
 
146
+ /** Why base64 image data is unusable; `undefined` when it is canonical and non-empty. */
147
+ export type Base64ImageDataFailure = "invalid base64 image data" | "empty image data";
148
+
149
+ /**
150
+ * Non-empty decoded bytes on success, or a validation failure reason, never both.
151
+ * Check `reason` before using `bytes`; successful base64 decoding does not establish
152
+ * that the bytes represent a supported or decodable image.
153
+ */
154
+ export type Base64ImageDecodeResult =
155
+ | { bytes: Buffer; reason?: never }
156
+ | { bytes?: never; reason: Base64ImageDataFailure };
157
+
158
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
159
+
160
+ /**
161
+ * Validate standard base64 image data without decoding it. Padding may be omitted, but
162
+ * present padding must be canonical, the trailing bits must be zero, and the data non-empty.
163
+ */
164
+ export function base64ImageDataFailureReason(data: string): Base64ImageDataFailure | undefined {
165
+ if (data.length === 0) return "empty image data";
166
+
167
+ const match = /^([A-Za-z0-9+/]+)(={0,2})$/.exec(data);
168
+ if (!match) return "invalid base64 image data";
169
+ const encoded = match[1];
170
+ const paddingLength = match[2].length;
171
+ const remainder = encoded.length % 4;
172
+ if (remainder === 1 || (paddingLength === 1 && remainder !== 3) || (paddingLength === 2 && remainder !== 2)) {
173
+ return "invalid base64 image data";
174
+ }
175
+
176
+ const lastSextet = BASE64_ALPHABET.indexOf(encoded[encoded.length - 1]);
177
+ if ((remainder === 2 && (lastSextet & 0b1111) !== 0) || (remainder === 3 && (lastSextet & 0b11) !== 0)) {
178
+ return "invalid base64 image data";
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ /** Decode standard base64 image data under the {@link base64ImageDataFailureReason} rules. */
184
+ export function decodeBase64ImageData(data: string): Base64ImageDecodeResult {
185
+ const reason = base64ImageDataFailureReason(data);
186
+ if (reason) return { reason };
187
+ return { bytes: Buffer.from(data, "base64") };
188
+ }
189
+
146
190
  export function readImageMetadataSync(filePath: string, maxBytes = IMAGE_METADATA_HEADER_BYTES): ImageMetadata | null {
147
191
  return peekFileSync(filePath, maxBytes, parseImageMetadata);
148
192
  }
package/src/ptree.ts CHANGED
@@ -443,7 +443,9 @@ export class ChildProcess<In extends InMask = InMask> {
443
443
  // ── Output helpers ───────────────────────────────────────────────────
444
444
 
445
445
  async #throwIfAborted(): Promise<void> {
446
- const exitReason = this.exitReason;
446
+ // A kill() issued before Bun reports the root's exit only records the
447
+ // reason as pending; output readers that do not wait for exit must still see it.
448
+ const exitReason = this.#exitReason ?? this.#exitReasonPending;
447
449
  if (!exitReason?.aborted) return;
448
450
  if (this.#terminating) await this.#terminating;
449
451
  throw exitReason;
@@ -589,10 +591,11 @@ export class ChildProcess<In extends InMask = InMask> {
589
591
  // ── Signal / timeout ─────────────────────────────────────────────────
590
592
 
591
593
  attachSignal(signal: AbortSignal): void {
594
+ const cleanup = this.#exited.catch(() => {});
592
595
  const onAbort = () => this.kill(new AbortError(signal.reason, "<cancelled>"));
593
596
  if (signal.aborted) return void onAbort();
594
597
  signal.addEventListener("abort", onAbort, { once: true });
595
- this.#exited.catch(() => {}).finally(() => signal.removeEventListener("abort", onAbort));
598
+ cleanup.finally(() => signal.removeEventListener("abort", onAbort));
596
599
  }
597
600
 
598
601
  #clearTimeout(): void {
package/src/slugify.ts ADDED
@@ -0,0 +1,11 @@
1
+ /** Lowercase ASCII kebab slug: [^a-z0-9]+ → "-", edge dashes trimmed; optional maxLength truncates then re-trims trailing dashes; throws RangeError unless maxLength is a non-negative integer. Returns "" when nothing survives. */
2
+ export function slugify(input: string, options?: { maxLength?: number }): string {
3
+ if (options?.maxLength !== undefined && !(Number.isInteger(options.maxLength) && options.maxLength >= 0)) {
4
+ throw new RangeError(`slugify maxLength must be a non-negative integer, got ${options.maxLength}`);
5
+ }
6
+ const slug = input
7
+ .toLowerCase()
8
+ .replace(/[^a-z0-9]+/g, "-")
9
+ .replace(/^-+|-+$/g, "");
10
+ return options?.maxLength === undefined ? slug : slug.slice(0, options.maxLength).replace(/-+$/g, "");
11
+ }
package/src/sqlite.ts CHANGED
@@ -11,7 +11,11 @@ const BUSY_MAX_ATTEMPTS = 4;
11
11
  const BUSY_BASE_DELAY_MS = 100;
12
12
  const SQLITE_STORE_SUFFIXES = ["-wal", "-shm", "-journal", ""];
13
13
 
14
- type SqliteFileIdentity = string | null | undefined;
14
+ /**
15
+ * A store's identity as `dev:ino:birthtimeMs`, `null` when the path holds no
16
+ * file, or `undefined` when the stat itself failed and no conclusion is safe.
17
+ */
18
+ export type SqliteFileIdentity = string | null | undefined;
15
19
 
16
20
  /**
17
21
  * Corrupt handles held open by in-process openers awaiting recovery, keyed by
@@ -77,7 +81,13 @@ class SqliteAttemptFailure extends Error {
77
81
  }
78
82
  }
79
83
 
80
- function sqliteFileIdentity(dbPath: string): SqliteFileIdentity {
84
+ /**
85
+ * Identity of the file currently at `dbPath`. Quarantine moves a corrupt store
86
+ * aside and recreates one at the same path, so a connection cached on the path
87
+ * alone keeps writing to the unlinked inode: callers that hold a long-lived
88
+ * handle compare this before reusing it.
89
+ */
90
+ export function sqliteFileIdentity(dbPath: string): SqliteFileIdentity {
81
91
  try {
82
92
  const stat = fs.statSync(dbPath);
83
93
  return `${stat.dev}:${stat.ino}:${stat.birthtimeMs}`;