@incoqnito.io/bajadab 1.2.2 → 1.3.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.
@@ -1,207 +1,270 @@
1
- import { IBCollectionConfig, IBDatabase, IBDatabaseConfig, IBFunctionRegistry, IBSnapshotOptions, } from "./base.js";
2
- import { BCollection, BDatabase, DEFAULT_DATABASE_CONFIG, } from "./basics.js";
3
- import { uuidv7 } from "uuidv7";
4
- import { access, chmod, constants, cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
5
- import { join } from "node:path";
6
-
7
- interface IStoredDatabase {
8
- config: IBDatabaseConfig;
9
- nameIDMap: { [k: string]: string; };
10
- }
11
-
12
- interface IStoredCollection<T> {
13
- collectionID: string;
14
- collectionName: string;
15
- collectionConfig: IBCollectionConfig;
16
- path: string;
17
- }
18
-
19
- const DB_META_FILE_NAME = "dbmeta.json";
20
- const DB_COLLECTION_FOLDER_SUFFIX = "_data";
21
- const DB_COLLECTION_META_FILE_SUFFIX = ".json";
22
- const DB_COLLECTION_DATA_FILE = "data.json";
23
- const RESTRICTED_FILE_MODE = 0o600;
24
- const RESTRICTED_DIR_MODE = 0o700;
25
- const SNAPSHOT_VERSION_PATTERN = /^\d{8}T\d{9}Z(_[A-Za-z0-9_-]+)?$/;
26
- const SNAPSHOT_LABEL_PATTERN = /^[A-Za-z0-9_-]+$/;
27
-
28
- function snapshotVersionName(label?: string): string {
29
- const timestamp = new Date().toISOString().replace(/[-:.]/g, "");
30
- return label ? `${timestamp}_${label}` : timestamp;
31
- }
32
-
33
- // re-applies the restricted mode explicitly rather than relying on fs.cp() to have preserved it
34
- async function restrictPermissions(path: string): Promise<void> {
35
- const info = await stat(path);
36
- if (info.isDirectory()) {
37
- await chmod(path, RESTRICTED_DIR_MODE);
38
- await Promise.all((await readdir(path)).map(entry => restrictPermissions(join(path, entry))));
39
- }
40
- else {
41
- await chmod(path, RESTRICTED_FILE_MODE);
42
- }
43
- }
44
-
45
- async function atomicWriteFile(targetPath: string, data: string): Promise<void> {
46
- const tempPath = `${targetPath}.${uuidv7()}.tmp`;
47
- await writeFile(tempPath, data, { encoding: "utf-8", mode: RESTRICTED_FILE_MODE });
48
- await rename(tempPath, targetPath);
49
- }
50
-
51
- export class IODatabase {
52
- readonly basePath: string;
53
- private readonly nameIDMap: Map<string, string>;
54
- private databaseConfig: IBDatabaseConfig;
55
-
56
- public constructor(basePath: string, databaseConfig: IBDatabaseConfig, nameIDMap?: Map<string, string>) {
57
- this.basePath = basePath;
58
- this.databaseConfig = databaseConfig;
59
- this.nameIDMap = nameIDMap ?? new Map();
60
- }
61
-
62
- private async loadCollectionMeta(id: string, database: BDatabase): Promise<BCollection> {
63
- const meta: IStoredCollection<any> = JSON.parse(await readFile(join(this.basePath, id + DB_COLLECTION_META_FILE_SUFFIX), { encoding: "utf-8" }));
64
- return new BCollection(database, id, meta.collectionName, meta.collectionConfig);
65
- }
66
-
67
- public static async createOrLoad(basePath: string, functionRegistry?: IBFunctionRegistry): Promise<BDatabase> {
68
- const metaPath = join(basePath, DB_META_FILE_NAME);
69
- let data: string;
70
- try {
71
- await access(metaPath, constants.F_OK);
72
- data = await readFile(metaPath, { encoding: "utf-8" })
73
- }
74
- catch (err: any) {
75
- if (err.code === 'ENOENT') {
76
- const iodb = new IODatabase(basePath, DEFAULT_DATABASE_CONFIG);
77
- return new BDatabase(iodb, DEFAULT_DATABASE_CONFIG, functionRegistry);
78
- }
79
- throw err;
80
- }
81
- const meta: IStoredDatabase = JSON.parse(data);
82
- const nameIDMap = new Map(Object.entries(meta.nameIDMap));
83
- const iodb = new IODatabase(basePath, meta.config, nameIDMap);
84
- const database = new BDatabase(iodb, meta.config, functionRegistry);
85
- const collections = await Promise.all(
86
- nameIDMap.values()
87
- .map(id => iodb.loadCollectionMeta(id, database))
88
- .toArray());
89
- collections.forEach(c => database.addCollection(c));
90
- return database;
91
- }
92
-
93
- public async flush(): Promise<void> {
94
- const meta: IStoredDatabase = {
95
- config: this.databaseConfig,
96
- nameIDMap: Object.fromEntries(this.nameIDMap),
97
- };
98
- const data = JSON.stringify(meta);
99
- await atomicWriteFile(join(this.basePath, DB_META_FILE_NAME), data);
100
- }
101
-
102
- public async createCollection<T = any>(collection: BCollection<T>): Promise<BCollection<T>> {
103
- const id = collection.collectionID;
104
- const collectionPath = join(this.basePath, id + DB_COLLECTION_FOLDER_SUFFIX);
105
- await mkdir(collectionPath, { recursive: true, mode: RESTRICTED_DIR_MODE });
106
- const meta: IStoredCollection<T> = {
107
- collectionConfig: collection.collectionConfig,
108
- collectionName: collection.collectionName,
109
- collectionID: id,
110
- path: collectionPath,
111
- };
112
- const data = JSON.stringify(meta);
113
- await atomicWriteFile(join(this.basePath, id + DB_COLLECTION_META_FILE_SUFFIX), data);
114
- this.nameIDMap.set(collection.collectionName, id);
115
- await this.flush();
116
- return collection;
117
- }
118
-
119
- public async deleteCollection(collectionName: string): Promise<void> {
120
- const id = this.nameIDMap.get(collectionName);
121
- if (!id) {
122
- throw new Error(`no such database ${collectionName}`);
123
- }
124
- const collectionPath = join(this.basePath, id + DB_COLLECTION_FOLDER_SUFFIX);
125
- await rm(collectionPath, { recursive: true, force: true });
126
- const metaPath = join(this.basePath, id + DB_COLLECTION_META_FILE_SUFFIX);
127
- await rm(metaPath, { force: true });
128
- this.nameIDMap.delete(collectionName);
129
- }
130
-
131
- private async loadData(collectionID: string): Promise<any> {
132
- try {
133
- const data = await readFile(join(this.basePath, collectionID + DB_COLLECTION_FOLDER_SUFFIX, DB_COLLECTION_DATA_FILE), { encoding: "utf-8" });
134
- return JSON.parse(data);
135
- }
136
- catch (err: any) {
137
- if (err.code === 'ENOENT') {
138
- return null;
139
- }
140
- throw err;
141
- }
142
- }
143
-
144
- public async loadArrayData<T>(collectionID: string): Promise<T[]> {
145
- const data = await this.loadData(collectionID);
146
- return data || [];
147
- }
148
-
149
- public async loadMapData<T>(collectionID: string): Promise<Map<string, T>> {
150
- const data = await this.loadData(collectionID);
151
- return data ? new Map(Object.entries(data)) : new Map();
152
- }
153
-
154
- private async writeData(collectionID: string, data: any): Promise<void> {
155
- return atomicWriteFile(join(this.basePath, collectionID + DB_COLLECTION_FOLDER_SUFFIX, DB_COLLECTION_DATA_FILE), JSON.stringify(data));
156
- }
157
-
158
- public async writeArrayData<T = any>(collectionID: string, arrayData: T[]): Promise<void> {
159
- return this.writeData(collectionID, arrayData);
160
- }
161
-
162
- public async writeMapData<T = any>(collectionID: string, mapData: Map<string, T>): Promise<void> {
163
- return this.writeData(collectionID, Object.fromEntries(mapData));
164
- }
165
-
166
- public async snapshot(targetDir: string, options?: IBSnapshotOptions): Promise<string> {
167
- if (options?.keepLast !== undefined && options.keepLast < 0) {
168
- throw new Error(`keepLast must not be negative, got ${options.keepLast}`);
169
- }
170
- if (options?.label !== undefined && !SNAPSHOT_LABEL_PATTERN.test(options.label)) {
171
- throw new Error(`snapshot label "${options.label}" must only contain letters, digits, "-" or "_"`);
172
- }
173
- await mkdir(targetDir, { recursive: true, mode: RESTRICTED_DIR_MODE });
174
- const versionPath = join(targetDir, snapshotVersionName(options?.label));
175
- try {
176
- await cp(this.basePath, versionPath, { recursive: true });
177
- await restrictPermissions(versionPath);
178
- }
179
- catch (err) {
180
- await rm(versionPath, { recursive: true, force: true });
181
- throw err;
182
- }
183
- if (options?.keepLast !== undefined) {
184
- await this.pruneSnapshots(targetDir, options.keepLast);
185
- }
186
- return versionPath;
187
- }
188
-
189
- // only ever deletes folders matching our own naming scheme, never anything else the caller keeps in targetDir
190
- private async pruneSnapshots(targetDir: string, keepLast: number): Promise<void> {
191
- const entries = await readdir(targetDir, { withFileTypes: true });
192
- const versions = entries
193
- .filter(e => e.isDirectory() && SNAPSHOT_VERSION_PATTERN.test(e.name))
194
- .map(e => e.name)
195
- .sort();
196
- const toDelete = versions.slice(0, Math.max(0, versions.length - (keepLast + 1)));
197
- await Promise.all(toDelete.map(name => rm(join(targetDir, name), { recursive: true, force: true })));
198
- }
199
- }
200
-
201
- /**
202
- * Opens the database at `basePath`, creating it if it doesn't exist yet.
203
- * `functionRegistry` resolves any {@link IBCollectionConfig.getID}/`setID` keys used by its collections.
204
- */
205
- export async function getDatabase(basePath: string, functionRegistry?: IBFunctionRegistry): Promise<IBDatabase> {
206
- return IODatabase.createOrLoad(basePath, functionRegistry);
207
- }
1
+ import { join } from "node:path";
2
+ import { chmod, cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { setTimeout } from "node:timers/promises";
4
+ import { uuidv7 } from "uuidv7";
5
+
6
+ import { IBCollectionConfig, IBDatabase, IBDatabaseConfig, IBFunctionRegistry, IBSnapshotOptions, } from "./base.js";
7
+ import { BCollection, BDatabase, DEFAULT_DATABASE_CONFIG, } from "./basics.js";
8
+
9
+ interface IStoredDatabase {
10
+ config: IBDatabaseConfig;
11
+ nameIDMap: { [k: string]: string; };
12
+ }
13
+
14
+ interface IStoredCollection {
15
+ collectionID: string;
16
+ collectionName: string;
17
+ collectionConfig: IBCollectionConfig;
18
+ }
19
+
20
+ const DB_META_FILE_NAME = "dbmeta.json";
21
+ const DB_COLLECTION_FOLDER_SUFFIX = "_data";
22
+ const DB_COLLECTION_META_FILE_SUFFIX = ".json";
23
+ const DB_COLLECTION_DATA_FILE = "data.json";
24
+ const RESTRICTED_FILE_MODE = 0o600;
25
+ const RESTRICTED_DIR_MODE = 0o700;
26
+ const SNAPSHOT_VERSION_PATTERN = /^\d{8}T\d{9}Z(_[A-Za-z0-9_-]+)?$/;
27
+ const SNAPSHOT_LABEL_PATTERN = /^[A-Za-z0-9_-]+$/;
28
+ const RENAME_RETRY_DELAYS_MS = [10, 25, 50, 100];
29
+
30
+ enum FsErrorCode {
31
+ ENOENT = "ENOENT",
32
+ EPERM = "EPERM",
33
+ EBUSY = "EBUSY",
34
+ EACCES = "EACCES",
35
+ }
36
+
37
+ const RETRYABLE_RENAME_ERRORS: ReadonlySet<string> =
38
+ new Set([FsErrorCode.EPERM, FsErrorCode.EBUSY, FsErrorCode.EACCES]);
39
+
40
+ export interface IPreparedWrite {
41
+ commit(): Promise<void>;
42
+ discard(): Promise<void>;
43
+ }
44
+
45
+ function snapshotVersionName(label?: string): string {
46
+ const timestamp = new Date().toISOString().replace(/[-:.]/g, "");
47
+ return label ? `${timestamp}_${label}` : timestamp;
48
+ }
49
+
50
+ // re-applies the restricted mode explicitly rather than relying on fs.cp() to have preserved it
51
+ async function restrictPermissions(path: string): Promise<void> {
52
+ const info = await stat(path);
53
+ if (info.isDirectory()) {
54
+ await chmod(path, RESTRICTED_DIR_MODE);
55
+ await Promise.all((await readdir(path)).map(entry => restrictPermissions(join(path, entry))));
56
+ }
57
+ else {
58
+ await chmod(path, RESTRICTED_FILE_MODE);
59
+ }
60
+ }
61
+
62
+ async function renameWithRetry(from: string, to: string): Promise<void> {
63
+ for (let attempt = 0; ; attempt++) {
64
+ try {
65
+ return await rename(from, to);
66
+ }
67
+ catch (err: any) {
68
+ if (attempt >= RENAME_RETRY_DELAYS_MS.length || !RETRYABLE_RENAME_ERRORS.has(err.code)) {
69
+ throw err;
70
+ }
71
+ await setTimeout(RENAME_RETRY_DELAYS_MS[attempt]);
72
+ }
73
+ }
74
+ }
75
+
76
+ async function prepareAtomicWrite(targetPath: string, data: string): Promise<IPreparedWrite> {
77
+ const tempPath = `${targetPath}.${uuidv7()}.tmp`;
78
+ try {
79
+ await writeFile(tempPath, data, { encoding: "utf-8", mode: RESTRICTED_FILE_MODE });
80
+ }
81
+ catch (err) {
82
+ await rm(tempPath, { force: true });
83
+ throw err;
84
+ }
85
+ return {
86
+ commit: async () => {
87
+ try {
88
+ await renameWithRetry(tempPath, targetPath);
89
+ }
90
+ catch (err) {
91
+ await rm(tempPath, { force: true });
92
+ throw err;
93
+ }
94
+ },
95
+ discard: () => rm(tempPath, { force: true }),
96
+ };
97
+ }
98
+
99
+ async function atomicWriteFile(targetPath: string, data: string): Promise<void> {
100
+ return (await prepareAtomicWrite(targetPath, data)).commit();
101
+ }
102
+
103
+ export class IODatabase {
104
+ readonly basePath: string;
105
+ private readonly nameIDMap: Map<string, string>;
106
+ private databaseConfig: IBDatabaseConfig;
107
+
108
+ public constructor(basePath: string, databaseConfig: IBDatabaseConfig, nameIDMap?: Map<string, string>) {
109
+ this.basePath = basePath;
110
+ this.databaseConfig = databaseConfig;
111
+ this.nameIDMap = nameIDMap ?? new Map();
112
+ }
113
+
114
+ private async loadCollectionMeta(id: string, database: BDatabase): Promise<BCollection> {
115
+ const meta: IStoredCollection = JSON.parse(await readFile(join(this.basePath, id + DB_COLLECTION_META_FILE_SUFFIX), { encoding: "utf-8" }));
116
+ return new BCollection(database, id, meta.collectionName, meta.collectionConfig);
117
+ }
118
+
119
+ public static async createOrLoad(basePath: string, functionRegistry?: IBFunctionRegistry): Promise<BDatabase> {
120
+ const metaPath = join(basePath, DB_META_FILE_NAME);
121
+ let data: string;
122
+ try {
123
+ data = await readFile(metaPath, { encoding: "utf-8" })
124
+ }
125
+ catch (err: any) {
126
+ if (err.code === FsErrorCode.ENOENT) {
127
+ const iodb = new IODatabase(basePath, DEFAULT_DATABASE_CONFIG);
128
+ return new BDatabase(iodb, DEFAULT_DATABASE_CONFIG, functionRegistry);
129
+ }
130
+ throw err;
131
+ }
132
+ const meta: IStoredDatabase = JSON.parse(data);
133
+ const nameIDMap = new Map(Object.entries(meta.nameIDMap));
134
+ const iodb = new IODatabase(basePath, meta.config, nameIDMap);
135
+ const database = new BDatabase(iodb, meta.config, functionRegistry);
136
+ const collections = await Promise.all(
137
+ nameIDMap.values()
138
+ .map(id => iodb.loadCollectionMeta(id, database))
139
+ .toArray());
140
+ collections.forEach(c => database.addCollection(c));
141
+ return database;
142
+ }
143
+
144
+ public async flush(): Promise<void> {
145
+ const meta: IStoredDatabase = {
146
+ config: this.databaseConfig,
147
+ nameIDMap: Object.fromEntries(this.nameIDMap),
148
+ };
149
+ const data = JSON.stringify(meta);
150
+ await atomicWriteFile(join(this.basePath, DB_META_FILE_NAME), data);
151
+ }
152
+
153
+ public async createCollection<T = any>(collection: BCollection<T>): Promise<BCollection<T>> {
154
+ const id = collection.collectionID;
155
+ const collectionPath = join(this.basePath, id + DB_COLLECTION_FOLDER_SUFFIX);
156
+ await mkdir(collectionPath, { recursive: true, mode: RESTRICTED_DIR_MODE });
157
+ const meta: IStoredCollection = {
158
+ collectionConfig: collection.collectionConfig,
159
+ collectionName: collection.collectionName,
160
+ collectionID: id,
161
+ };
162
+ const data = JSON.stringify(meta);
163
+ await atomicWriteFile(join(this.basePath, id + DB_COLLECTION_META_FILE_SUFFIX), data);
164
+ this.nameIDMap.set(collection.collectionName, id);
165
+ await this.flush();
166
+ return collection;
167
+ }
168
+
169
+ public async deleteCollection(collectionName: string): Promise<void> {
170
+ const id = this.nameIDMap.get(collectionName);
171
+ if (!id) {
172
+ throw new Error(`no such collection ${collectionName}`);
173
+ }
174
+ const collectionPath = join(this.basePath, id + DB_COLLECTION_FOLDER_SUFFIX);
175
+ const metaPath = join(this.basePath, id + DB_COLLECTION_META_FILE_SUFFIX);
176
+ this.nameIDMap.delete(collectionName);
177
+ await this.flush();
178
+ await rm(collectionPath, { recursive: true, force: true });
179
+ await rm(metaPath, { force: true });
180
+ }
181
+
182
+ private async loadData(collectionID: string): Promise<any> {
183
+ try {
184
+ const data = await readFile(join(this.basePath, collectionID + DB_COLLECTION_FOLDER_SUFFIX, DB_COLLECTION_DATA_FILE), { encoding: "utf-8" });
185
+ return JSON.parse(data);
186
+ }
187
+ catch (err: any) {
188
+ if (err.code === FsErrorCode.ENOENT) {
189
+ return null;
190
+ }
191
+ throw err;
192
+ }
193
+ }
194
+
195
+ public async loadArrayData<T>(collectionID: string): Promise<T[]> {
196
+ const data = await this.loadData(collectionID);
197
+ return data || [];
198
+ }
199
+
200
+ public async loadMapData<T>(collectionID: string): Promise<Map<string, T>> {
201
+ const data = await this.loadData(collectionID);
202
+ return data ? new Map(Object.entries(data)) : new Map();
203
+ }
204
+
205
+ private async prepareData(collectionID: string, data: any): Promise<IPreparedWrite> {
206
+ return prepareAtomicWrite(join(this.basePath, collectionID + DB_COLLECTION_FOLDER_SUFFIX, DB_COLLECTION_DATA_FILE), JSON.stringify(data));
207
+ }
208
+
209
+ private async writeData(collectionID: string, data: any): Promise<void> {
210
+ return (await this.prepareData(collectionID, data)).commit();
211
+ }
212
+
213
+ public async prepareArrayData<T = any>(collectionID: string, arrayData: T[]): Promise<IPreparedWrite> {
214
+ return this.prepareData(collectionID, arrayData);
215
+ }
216
+
217
+ public async prepareMapData<T = any>(collectionID: string, mapData: Map<string, T>): Promise<IPreparedWrite> {
218
+ return this.prepareData(collectionID, Object.fromEntries(mapData));
219
+ }
220
+
221
+ public async writeArrayData<T = any>(collectionID: string, arrayData: T[]): Promise<void> {
222
+ return this.writeData(collectionID, arrayData);
223
+ }
224
+
225
+ public async writeMapData<T = any>(collectionID: string, mapData: Map<string, T>): Promise<void> {
226
+ return this.writeData(collectionID, Object.fromEntries(mapData));
227
+ }
228
+
229
+ public async snapshot(targetDir: string, options?: IBSnapshotOptions): Promise<string> {
230
+ if (options?.keepLast !== undefined && options.keepLast < 0) {
231
+ throw new Error(`keepLast must not be negative, got ${options.keepLast}`);
232
+ }
233
+ if (options?.label !== undefined && !SNAPSHOT_LABEL_PATTERN.test(options.label)) {
234
+ throw new Error(`snapshot label "${options.label}" must only contain letters, digits, "-" or "_"`);
235
+ }
236
+ await mkdir(targetDir, { recursive: true, mode: RESTRICTED_DIR_MODE });
237
+ const versionPath = join(targetDir, snapshotVersionName(options?.label));
238
+ try {
239
+ await cp(this.basePath, versionPath, { recursive: true });
240
+ await restrictPermissions(versionPath);
241
+ }
242
+ catch (err) {
243
+ await rm(versionPath, { recursive: true, force: true });
244
+ throw err;
245
+ }
246
+ if (options?.keepLast !== undefined) {
247
+ await this.pruneSnapshots(targetDir, options.keepLast);
248
+ }
249
+ return versionPath;
250
+ }
251
+
252
+ // only ever deletes folders matching our own naming scheme, never anything else the caller keeps in targetDir
253
+ private async pruneSnapshots(targetDir: string, keepLast: number): Promise<void> {
254
+ const entries = await readdir(targetDir, { withFileTypes: true });
255
+ const versions = entries
256
+ .filter(e => e.isDirectory() && SNAPSHOT_VERSION_PATTERN.test(e.name))
257
+ .map(e => e.name)
258
+ .sort();
259
+ const toDelete = versions.slice(0, Math.max(0, versions.length - (keepLast + 1)));
260
+ await Promise.all(toDelete.map(name => rm(join(targetDir, name), { recursive: true, force: true })));
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Opens the database at `basePath`, creating it if it doesn't exist yet.
266
+ * `functionRegistry` resolves any {@link IBCollectionConfig.getID}/`setID` keys used by its collections.
267
+ */
268
+ export async function getDatabase(basePath: string, functionRegistry?: IBFunctionRegistry): Promise<IBDatabase> {
269
+ return IODatabase.createOrLoad(basePath, functionRegistry);
270
+ }