@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 +11 -0
- package/dist/types/incoming-json.d.ts +12 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mime.d.ts +21 -0
- package/dist/types/slugify.d.ts +4 -0
- package/dist/types/sqlite.d.ts +12 -0
- package/package.json +2 -2
- package/src/incoming-json.ts +15 -0
- package/src/index.ts +1 -0
- package/src/mime.ts +44 -0
- package/src/ptree.ts +5 -2
- package/src/slugify.ts +11 -0
- package/src/sqlite.ts +12 -2
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. */
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/types/mime.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/sqlite.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
60
|
+
"@oh-my-pi/pi-natives": "18.11.2"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@types/bun": "^1.3.14"
|
package/src/incoming-json.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`;
|