@hlw2326/jsonl 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hlw2326
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,126 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @hlw2326/jsonl
2
+
3
+ 轻量、零运行时依赖的 JSONL 文件读写包。自动处理逐行解析、空行、换行和文件写入,不包含数据库、日志权限或业务字段校验。
4
+
5
+ 支持 Node.js 22+、ESM、CommonJS 和 TypeScript。npm 包仅包含编译后的 JavaScript、类型声明和文档,不发布 `src`。
6
+
7
+ ## 安装
8
+
9
+ ```sh
10
+ pnpm add @hlw2326/jsonl
11
+ ```
12
+
13
+ ## 使用
14
+
15
+ ```ts
16
+ import { Jsonl } from '@hlw2326/jsonl'
17
+
18
+ interface Log {
19
+ appId: string
20
+ message: string
21
+ }
22
+
23
+ const logs = new Jsonl<Log>('./data/logs.jsonl')
24
+
25
+ await logs.append({ appId: 'app-a', message: '已启动' })
26
+ await logs.append([
27
+ { appId: 'app-a', message: '已连接' },
28
+ { appId: 'app-b', message: '已启动' }
29
+ ])
30
+
31
+ const records = await logs.read()
32
+ const mine = records.filter(record => record.appId === 'app-a')
33
+
34
+ const removed = await logs.remove(record => record.appId === 'app-a')
35
+ await logs.write(mine)
36
+ await logs.clear()
37
+ ```
38
+
39
+ CommonJS:
40
+
41
+ ```js
42
+ const { Jsonl } = require('@hlw2326/jsonl')
43
+
44
+ const file = new Jsonl('./data/records.jsonl')
45
+ ```
46
+
47
+ ## API
48
+
49
+ ### `new Jsonl<T>(file, { strict?: boolean })`
50
+
51
+ `file` 相对当前工作目录解析。默认 `strict: false`,读取时跳过 JSON 语法损坏的行;空行始终忽略,支持 UTF-8 BOM、CRLF 和末尾没有换行的文件。
52
+
53
+ ```ts
54
+ const file = new Jsonl<Log>('./logs.jsonl', { strict: true })
55
+ ```
56
+
57
+ 严格模式遇到损坏行时抛出包含文件路径及行号的 `SyntaxError`。它只检查 JSON 语法,不检查业务字段;泛型 `T` 只是类型提示,外部不可信数据仍需自行校验。
58
+
59
+ ### `read(): Promise<T[]>`
60
+
61
+ 读取全部有效记录;文件不存在时返回 `[]`,其他文件系统错误正常抛出。
62
+
63
+ ### `append(record | records): Promise<void>`
64
+
65
+ 追加一条或多条记录,自动创建父目录,并补齐已有文件末尾缺失的换行。空数组不写入。
66
+
67
+ 数组参数表示多条记录;如果记录本身就是数组,外面再包一层:
68
+
69
+ ```ts
70
+ const arrays = new Jsonl<number[]>('./arrays.jsonl')
71
+ await arrays.append([[1, 2, 3]])
72
+ ```
73
+
74
+ ### `write(records): Promise<void>`
75
+
76
+ 用新记录替换文件。先完整序列化,再写入同目录临时文件,最后重命名替换目标。
77
+
78
+ ### `remove(predicate): Promise<number>`
79
+
80
+ 删除符合条件的记录,返回删除数量。默认模式保留原文件中无法解析的行;严格模式遇到损坏行时拒绝操作,不修改原文件。没有匹配记录时不重写文件。
81
+
82
+ ### `clear(): Promise<void>`
83
+
84
+ 清空全部内容,文件保留;文件不存在时创建空文件。
85
+
86
+ ## 行为边界
87
+
88
+ - 所有记录按标准 `JSON.stringify` 规则保存,`undefined`、函数、Symbol 等无法作为顶层 JSON 值写入;循环引用、BigInt 等序列化错误会拒绝本次写入。批量写入先完成全部序列化,避免因后续记录无法序列化而只写入部分数据。
89
+ - 同一模块实例内、同一绝对路径的操作按调用顺序串行执行,包括不同 `Jsonl` 对象;一次操作失败不会阻塞后续操作。
90
+ - 不提供跨进程、Worker、不同模块副本或符号链接别名之间的文件锁;请让一个进程统一管理同一文件。
91
+ - `write`、`remove`、`clear` 使用临时文件替换,替换前失败会保留旧文件;`append` 是直接追加,底层写入失败可能留下部分数据。这里不承诺断电持久性,也不执行 `fsync`。
92
+ - `read`、`remove` 会读取整个文件,适合按天分文件的日志及中小型 JSONL 数据,不是大文件流式查询引擎。
93
+ - 包不自动轮转文件、不判断应用身份,也不管理日志保留时间。
94
+
95
+ ## 开发与发布
96
+
97
+ ```sh
98
+ pnpm install
99
+ pnpm typecheck
100
+ pnpm test
101
+ ```
102
+
103
+ `pnpm test` 会先编译。`pnpm build` 生成:
104
+
105
+ ```text
106
+ dist/
107
+ ├── esm/
108
+ │ ├── index.js
109
+ │ └── index.d.ts
110
+ └── cjs/
111
+ ├── index.js
112
+ ├── index.d.ts
113
+ └── package.json
114
+ ```
115
+
116
+ 发布前检查实际包内容,再发布:
117
+
118
+ ```sh
119
+ npm pack --dry-run
120
+ npm login
121
+ npm publish --access public
122
+ ```
123
+
124
+ `prepack` 自动执行构建和测试,`files` 白名单仅包含 `dist`、`README.md`、`LICENSE`;`package.json` 由 npm 自动包含。源码、测试、构建脚本、配置和 source map 均不发布。
125
+
126
+ 发布账号必须拥有 `@hlw2326` 作用域的发布权限。再次发布前需要修改版本号;具体双因素认证或令牌要求以 npm 提示为准。
@@ -0,0 +1,15 @@
1
+ export declare class Jsonl<T = unknown> {
2
+ private readonly file;
3
+ private readonly strict;
4
+ constructor(file: string, options?: {
5
+ strict?: boolean;
6
+ });
7
+ read(): Promise<T[]>;
8
+ /** 数组参数表示多条记录;数组本身作为一条记录时使用 append([array])。 */
9
+ append(value: T | readonly T[]): Promise<void>;
10
+ write(records: readonly T[]): Promise<void>;
11
+ remove(predicate: (record: T) => boolean): Promise<number>;
12
+ clear(): Promise<void>;
13
+ private parse;
14
+ private replace;
15
+ }
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Jsonl = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const promises_1 = require("node:fs/promises");
6
+ const node_path_1 = require("node:path");
7
+ const queues = new Map();
8
+ function enqueue(file, operation) {
9
+ const result = (queues.get(file) ?? Promise.resolve()).then(operation);
10
+ const tail = result.then(() => { }, () => { });
11
+ queues.set(file, tail);
12
+ void tail.then(() => {
13
+ if (queues.get(file) === tail)
14
+ queues.delete(file);
15
+ });
16
+ return result;
17
+ }
18
+ async function load(file) {
19
+ try {
20
+ return await (0, promises_1.readFile)(file, 'utf8');
21
+ }
22
+ catch (error) {
23
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
24
+ return '';
25
+ throw error;
26
+ }
27
+ }
28
+ function serialize(records) {
29
+ const lines = records.map((record) => {
30
+ const line = JSON.stringify(record);
31
+ if (line === undefined)
32
+ throw new TypeError('Record cannot be serialized as JSON');
33
+ return line;
34
+ });
35
+ return lines.length ? `${lines.join('\n')}\n` : '';
36
+ }
37
+ class Jsonl {
38
+ file;
39
+ strict;
40
+ constructor(file, options) {
41
+ this.file = (0, node_path_1.resolve)(file);
42
+ this.strict = options?.strict ?? false;
43
+ }
44
+ read() {
45
+ return enqueue(this.file, async () => {
46
+ const lines = (await load(this.file)).split(/(?<=\n)/);
47
+ const records = [];
48
+ for (const [index, line] of lines.entries()) {
49
+ const parsed = this.parse(line, index);
50
+ if (parsed)
51
+ records.push(parsed.value);
52
+ }
53
+ return records;
54
+ });
55
+ }
56
+ /** 数组参数表示多条记录;数组本身作为一条记录时使用 append([array])。 */
57
+ append(value) {
58
+ return enqueue(this.file, async () => {
59
+ const data = serialize(Array.isArray(value) ? value : [value]);
60
+ if (!data)
61
+ return;
62
+ await (0, promises_1.mkdir)((0, node_path_1.dirname)(this.file), { recursive: true });
63
+ const file = await (0, promises_1.open)(this.file, 'a+');
64
+ try {
65
+ const { size } = await file.stat();
66
+ const last = Buffer.alloc(1);
67
+ if (size)
68
+ await file.read(last, 0, 1, size - 1);
69
+ const separator = size && last[0] !== 10 ? '\n' : '';
70
+ await file.writeFile(separator + data, 'utf8');
71
+ }
72
+ finally {
73
+ await file.close();
74
+ }
75
+ });
76
+ }
77
+ write(records) {
78
+ return enqueue(this.file, async () => {
79
+ await this.replace(serialize(records));
80
+ });
81
+ }
82
+ remove(predicate) {
83
+ return enqueue(this.file, async () => {
84
+ const lines = (await load(this.file)).split(/(?<=\n)/);
85
+ let removed = 0;
86
+ const remaining = lines.filter((line, index) => {
87
+ const parsed = this.parse(line, index);
88
+ if (!parsed || !predicate(parsed.value))
89
+ return true;
90
+ removed++;
91
+ return false;
92
+ });
93
+ if (removed)
94
+ await this.replace(remaining.join(''));
95
+ return removed;
96
+ });
97
+ }
98
+ clear() {
99
+ return enqueue(this.file, async () => {
100
+ await this.replace('');
101
+ });
102
+ }
103
+ parse(line, index) {
104
+ const text = line.trim();
105
+ if (!text)
106
+ return undefined;
107
+ try {
108
+ return { value: JSON.parse(text) };
109
+ }
110
+ catch (error) {
111
+ if (this.strict) {
112
+ const detail = error instanceof Error ? error.message : String(error);
113
+ throw new SyntaxError(`${this.file}:${index + 1}: Invalid JSON: ${detail}`, {
114
+ cause: error,
115
+ });
116
+ }
117
+ return undefined;
118
+ }
119
+ }
120
+ async replace(data) {
121
+ const directory = (0, node_path_1.dirname)(this.file);
122
+ await (0, promises_1.mkdir)(directory, { recursive: true });
123
+ const temporary = (0, node_path_1.join)(directory, `.jsonl-${(0, node_crypto_1.randomUUID)()}.tmp`);
124
+ try {
125
+ await (0, promises_1.writeFile)(temporary, data, { encoding: 'utf8', flag: 'wx' });
126
+ await (0, promises_1.rename)(temporary, this.file);
127
+ }
128
+ finally {
129
+ await (0, promises_1.unlink)(temporary).catch(() => { });
130
+ }
131
+ }
132
+ }
133
+ exports.Jsonl = Jsonl;
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -0,0 +1,15 @@
1
+ export declare class Jsonl<T = unknown> {
2
+ private readonly file;
3
+ private readonly strict;
4
+ constructor(file: string, options?: {
5
+ strict?: boolean;
6
+ });
7
+ read(): Promise<T[]>;
8
+ /** 数组参数表示多条记录;数组本身作为一条记录时使用 append([array])。 */
9
+ append(value: T | readonly T[]): Promise<void>;
10
+ write(records: readonly T[]): Promise<void>;
11
+ remove(predicate: (record: T) => boolean): Promise<number>;
12
+ clear(): Promise<void>;
13
+ private parse;
14
+ private replace;
15
+ }
@@ -0,0 +1,129 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, open, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ const queues = new Map();
5
+ function enqueue(file, operation) {
6
+ const result = (queues.get(file) ?? Promise.resolve()).then(operation);
7
+ const tail = result.then(() => { }, () => { });
8
+ queues.set(file, tail);
9
+ void tail.then(() => {
10
+ if (queues.get(file) === tail)
11
+ queues.delete(file);
12
+ });
13
+ return result;
14
+ }
15
+ async function load(file) {
16
+ try {
17
+ return await readFile(file, 'utf8');
18
+ }
19
+ catch (error) {
20
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
21
+ return '';
22
+ throw error;
23
+ }
24
+ }
25
+ function serialize(records) {
26
+ const lines = records.map((record) => {
27
+ const line = JSON.stringify(record);
28
+ if (line === undefined)
29
+ throw new TypeError('Record cannot be serialized as JSON');
30
+ return line;
31
+ });
32
+ return lines.length ? `${lines.join('\n')}\n` : '';
33
+ }
34
+ export class Jsonl {
35
+ file;
36
+ strict;
37
+ constructor(file, options) {
38
+ this.file = resolve(file);
39
+ this.strict = options?.strict ?? false;
40
+ }
41
+ read() {
42
+ return enqueue(this.file, async () => {
43
+ const lines = (await load(this.file)).split(/(?<=\n)/);
44
+ const records = [];
45
+ for (const [index, line] of lines.entries()) {
46
+ const parsed = this.parse(line, index);
47
+ if (parsed)
48
+ records.push(parsed.value);
49
+ }
50
+ return records;
51
+ });
52
+ }
53
+ /** 数组参数表示多条记录;数组本身作为一条记录时使用 append([array])。 */
54
+ append(value) {
55
+ return enqueue(this.file, async () => {
56
+ const data = serialize(Array.isArray(value) ? value : [value]);
57
+ if (!data)
58
+ return;
59
+ await mkdir(dirname(this.file), { recursive: true });
60
+ const file = await open(this.file, 'a+');
61
+ try {
62
+ const { size } = await file.stat();
63
+ const last = Buffer.alloc(1);
64
+ if (size)
65
+ await file.read(last, 0, 1, size - 1);
66
+ const separator = size && last[0] !== 10 ? '\n' : '';
67
+ await file.writeFile(separator + data, 'utf8');
68
+ }
69
+ finally {
70
+ await file.close();
71
+ }
72
+ });
73
+ }
74
+ write(records) {
75
+ return enqueue(this.file, async () => {
76
+ await this.replace(serialize(records));
77
+ });
78
+ }
79
+ remove(predicate) {
80
+ return enqueue(this.file, async () => {
81
+ const lines = (await load(this.file)).split(/(?<=\n)/);
82
+ let removed = 0;
83
+ const remaining = lines.filter((line, index) => {
84
+ const parsed = this.parse(line, index);
85
+ if (!parsed || !predicate(parsed.value))
86
+ return true;
87
+ removed++;
88
+ return false;
89
+ });
90
+ if (removed)
91
+ await this.replace(remaining.join(''));
92
+ return removed;
93
+ });
94
+ }
95
+ clear() {
96
+ return enqueue(this.file, async () => {
97
+ await this.replace('');
98
+ });
99
+ }
100
+ parse(line, index) {
101
+ const text = line.trim();
102
+ if (!text)
103
+ return undefined;
104
+ try {
105
+ return { value: JSON.parse(text) };
106
+ }
107
+ catch (error) {
108
+ if (this.strict) {
109
+ const detail = error instanceof Error ? error.message : String(error);
110
+ throw new SyntaxError(`${this.file}:${index + 1}: Invalid JSON: ${detail}`, {
111
+ cause: error,
112
+ });
113
+ }
114
+ return undefined;
115
+ }
116
+ }
117
+ async replace(data) {
118
+ const directory = dirname(this.file);
119
+ await mkdir(directory, { recursive: true });
120
+ const temporary = join(directory, `.jsonl-${randomUUID()}.tmp`);
121
+ try {
122
+ await writeFile(temporary, data, { encoding: 'utf8', flag: 'wx' });
123
+ await rename(temporary, this.file);
124
+ }
125
+ finally {
126
+ await unlink(temporary).catch(() => { });
127
+ }
128
+ }
129
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
- {
2
- "name": "@hlw2326/jsonl",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@hlw2326/jsonl",
3
+ "version": "0.1.0",
4
+ "description": "Small, dependency-free JSONL file storage for Node.js",
5
+ "type": "module",
6
+ "main": "./dist/cjs/index.js",
7
+ "module": "./dist/esm/index.js",
8
+ "types": "./dist/esm/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/esm/index.d.ts",
13
+ "default": "./dist/esm/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/cjs/index.d.ts",
17
+ "default": "./dist/cjs/index.js"
18
+ }
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "build": "node scripts/build.mjs",
28
+ "typecheck": "tsc --noEmit",
29
+ "test": "node scripts/build.mjs && node --test tests/jsonl.test.mjs tests/package.test.mjs",
30
+ "prepack": "npm test"
31
+ },
32
+ "engines": {
33
+ "node": ">=22"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "keywords": [
39
+ "jsonl",
40
+ "ndjson",
41
+ "json-lines",
42
+ "file-storage",
43
+ "typescript"
44
+ ],
45
+ "author": "hlw2326",
46
+ "license": "MIT",
47
+ "devDependencies": {
48
+ "@types/node": "^22.0.0",
49
+ "typescript": "^5.9.3"
50
+ }
51
+ }