@cascateer/database 0.0.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.
@@ -0,0 +1,11 @@
1
+ name: Publish to NPM
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ call-workflow:
10
+ uses: cascateer/lib/.github/workflows/publish.yml@main
11
+ secrets: inherit
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@cascateer/database",
3
+ "version": "0.0.2",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/cascateer/database.git"
7
+ },
8
+ "scripts": {
9
+ "semver-patch": "npm version patch && git push origin main --tags",
10
+ "update": "npm cache clean --force && npx npm-check-updates -u && npm i",
11
+ "test": "vitest"
12
+ },
13
+ "exports": {
14
+ ".": "./src/index.ts"
15
+ },
16
+ "dependencies": {
17
+ "@cascateer/lib": "^1.0.50",
18
+ "lodash": "^4.18.1",
19
+ "object-hash": "^3.0.0",
20
+ "ora": "^9.4.1",
21
+ "rxjs": "^7.8.2",
22
+ "uuid": "^14.0.1"
23
+ },
24
+ "devDependencies": {
25
+ "@types/lodash": "^4.17.24",
26
+ "@types/node": "^26.0.1",
27
+ "@types/object-hash": "^3.0.6",
28
+ "vitest": "^4.1.9"
29
+ }
30
+ }
@@ -0,0 +1,4 @@
1
+ export class defaults {
2
+ static TABLE_BASE_URL = "tables";
3
+ static FILE_BASE_URL = "files";
4
+ }
package/src/file.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { createHash } from "crypto";
2
+ import { createReadStream } from "fs";
3
+ import { Ora } from "ora";
4
+ import { extname, relative, resolve } from "path";
5
+ import { defaults } from "./defaults";
6
+ import { FileTable } from "./types";
7
+
8
+ export class File {
9
+ static BASE_URL = defaults.FILE_BASE_URL;
10
+
11
+ static fromPath = (path: string) => new File(relative(this.BASE_URL, path));
12
+
13
+ static fromUrl = (table: FileTable, url: string, spinner?: Ora) =>
14
+ table
15
+ .accessSome([url], spinner)
16
+ .then(([{ name, checksum }]) => new File(name).verified(checksum));
17
+
18
+ constructor(public name: string) {}
19
+
20
+ get path() {
21
+ return resolve(File.BASE_URL, this.name);
22
+ }
23
+
24
+ get extname() {
25
+ return extname(this.path);
26
+ }
27
+
28
+ async hash() {
29
+ return new Promise<string>((resolve, reject) => {
30
+ const hash = createHash("md5");
31
+
32
+ createReadStream(this.path)
33
+ .on("error", (error) => reject(error.message))
34
+ .on("data", (data) => hash.update(data))
35
+ .on("end", () => resolve(hash.digest("hex")));
36
+ });
37
+ }
38
+
39
+ async verify(checksum?: string): Promise<boolean> {
40
+ return (await this.hash()) === checksum;
41
+ }
42
+
43
+ async verified(checksum: string): Promise<File> {
44
+ if (await this.verify(checksum)) {
45
+ return this;
46
+ }
47
+
48
+ throw new Error();
49
+ }
50
+
51
+ toString(): string {
52
+ return this.path;
53
+ }
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { defaults } from "./defaults";
2
+ export { File } from "./file";
3
+ export { createTable } from "./table";
@@ -0,0 +1,43 @@
1
+ import { property } from "@cascateer/lib";
2
+ import { flatMap } from "@cascateer/lib/observables";
3
+ import { LazyPromise } from "@cascateer/lib/promises";
4
+ import { Function1, last, noop, tap } from "lodash";
5
+ import { mergeAll, OperatorFunction, scan, startWith } from "rxjs";
6
+ import { TableAction, TableActionCreator } from "../types";
7
+
8
+ export const reduceActions =
9
+ <R, K extends keyof R>(
10
+ transform: (records: R[], ...actions: TableAction<R, K>[]) => R[],
11
+ seed: LazyPromise<
12
+ R[],
13
+ {
14
+ actions: TableAction<R, K>[];
15
+ callback?: Function1<R[], void>;
16
+ }
17
+ >,
18
+ ): OperatorFunction<TableActionCreator<R, K>, TableAction<R, K>> =>
19
+ (source) =>
20
+ source.pipe(
21
+ startWith(seed),
22
+ scan(
23
+ (result, actions) =>
24
+ result.then(({ records, actions: previousActions }) =>
25
+ actions.run(records).then(({ actions, callback }) => ({
26
+ records: tap(transform(records, ...actions), callback ?? noop),
27
+ actions: actions.map((action, actionIndex, actions) => ({
28
+ ...action,
29
+ previousId: (actionIndex >= 1
30
+ ? actions[actionIndex - 1]
31
+ : last(previousActions)
32
+ )?.id,
33
+ })),
34
+ })),
35
+ ),
36
+ Promise.resolve({
37
+ records: new Array<R>(),
38
+ actions: new Array<TableAction<R, K>>(),
39
+ }),
40
+ ),
41
+ mergeAll(),
42
+ flatMap(property("actions")),
43
+ );
package/src/table.ts ADDED
@@ -0,0 +1,284 @@
1
+ import { findDupeBy, nonNullable, nthArg } from "@cascateer/lib";
2
+ import { LazyPromise } from "@cascateer/lib/promises";
3
+ import assert from "assert";
4
+ import { existsSync } from "fs";
5
+ import { mkdir, readdir, readFile, writeFile } from "fs/promises";
6
+ import {
7
+ difference,
8
+ fromPairs,
9
+ intersectionBy,
10
+ memoize,
11
+ thru,
12
+ uniq,
13
+ without,
14
+ } from "lodash";
15
+ import objectHash from "object-hash";
16
+ import { Ora } from "ora";
17
+ import { resolve } from "path";
18
+ import { mergeMap, NextObserver, Subject, Subscription } from "rxjs";
19
+ import { v4 } from "uuid";
20
+ import { defaults } from "./defaults";
21
+ import { reduceActions } from "./observables/reduceActions";
22
+ import {
23
+ TableAction,
24
+ TableActionCreator,
25
+ TableActionCreatorResult,
26
+ TableRecordCreator,
27
+ } from "./types";
28
+
29
+ class Table<R, K extends keyof R> {
30
+ private static readonly BASE_URL = defaults.TABLE_BASE_URL;
31
+
32
+ constructor(
33
+ public id: string,
34
+ public key: K,
35
+ public records: TableRecordCreator<R, K>,
36
+ private observer: NextObserver<TableActionCreator<R, K>>,
37
+ ) {}
38
+
39
+ get path() {
40
+ return resolve(Table.BASE_URL, this.id);
41
+ }
42
+
43
+ protected readonly readActions = new LazyPromise<
44
+ R[],
45
+ TableActionCreatorResult<R, K>
46
+ >(async () => {
47
+ if (!existsSync(this.path)) {
48
+ await mkdir(this.path, { recursive: true });
49
+ }
50
+
51
+ const files = await readdir(this.path);
52
+ const actions = new Array<TableAction<R, K>>();
53
+
54
+ for (const file of files) {
55
+ actions.push(
56
+ await readFile(resolve(this.path, file), "utf-8").then<
57
+ TableAction<R, K>
58
+ >(JSON.parse),
59
+ );
60
+ }
61
+
62
+ const actionsMap = fromPairs(
63
+ actions.map((action) => [action.previousId ?? "", [action]]),
64
+ );
65
+
66
+ return {
67
+ actions: actions.reduce(
68
+ (actions, action) => actions.concat(actionsMap[action.id] ?? []),
69
+ actionsMap[""] ?? [],
70
+ ),
71
+ };
72
+ });
73
+
74
+ applyActions = (records: R[], ...actions: TableAction<R, K>[]) =>
75
+ actions.reduce((records, action) => {
76
+ switch (action.type) {
77
+ case "insert":
78
+ return records.concat(action.payload.records);
79
+ case "update": {
80
+ const targetRecord = action.payload.record;
81
+
82
+ return records.map((record) =>
83
+ this.selectId(record) === this.selectId(targetRecord)
84
+ ? targetRecord
85
+ : record,
86
+ );
87
+ }
88
+ case "delete":
89
+ return without(records, this.selectById(records)(action.payload.id));
90
+ }
91
+
92
+ return records;
93
+ }, records);
94
+
95
+ selectId = (record: R): R[K] => record[this.key];
96
+ selectById =
97
+ (records: R[]) =>
98
+ (id: R[K]): R => (
99
+ assert(findDupeBy(records, this.selectId) == null),
100
+ nonNullable(records.find((record) => this.selectId(record) === id))
101
+ );
102
+
103
+ public async dispatch(
104
+ ...args: NonNullable<TableAction<R, K>["args"]>
105
+ ): Promise<R[]> {
106
+ return new Promise<R[]>((callback) => {
107
+ switch (args[0]) {
108
+ case "one":
109
+ this.observer.next(
110
+ new LazyPromise((records) =>
111
+ thru(
112
+ args,
113
+ ([, id, predicate]) => (
114
+ predicate(this.selectById(records)(id)),
115
+ {
116
+ actions: [],
117
+ callback,
118
+ }
119
+ ),
120
+ ),
121
+ ),
122
+ );
123
+
124
+ break;
125
+ case "all":
126
+ this.observer.next(
127
+ new LazyPromise((records) =>
128
+ thru(
129
+ args,
130
+ ([, predicate]) => (
131
+ predicate(records),
132
+ {
133
+ actions: [],
134
+ callback,
135
+ }
136
+ ),
137
+ ),
138
+ ),
139
+ );
140
+
141
+ break;
142
+ case "insert":
143
+ this.observer.next(
144
+ new LazyPromise(async (records) => {
145
+ const [, predicate] = args;
146
+
147
+ const newRecords = await predicate(records.map(this.selectId));
148
+ const conflictingIds = intersectionBy(
149
+ newRecords,
150
+ records,
151
+ this.selectId,
152
+ );
153
+
154
+ if (conflictingIds.length > 0) {
155
+ throw new Error(`conflicting ids ${conflictingIds.join(", ")}`);
156
+ }
157
+
158
+ return {
159
+ actions:
160
+ newRecords.length > 0
161
+ ? [
162
+ {
163
+ id: v4(),
164
+ type: "insert",
165
+ payload: {
166
+ records: newRecords,
167
+ },
168
+ },
169
+ ]
170
+ : [],
171
+ callback,
172
+ };
173
+ }),
174
+ );
175
+
176
+ break;
177
+ case "update":
178
+ this.observer.next(
179
+ new LazyPromise((records) =>
180
+ thru(args, async ([, id, predicate]) => {
181
+ const targetRecord = this.selectById(records)(id);
182
+ const updatedTargetRecord = await predicate(targetRecord);
183
+
184
+ return {
185
+ actions:
186
+ objectHash(targetRecord ?? null) !==
187
+ objectHash(updatedTargetRecord ?? null)
188
+ ? [
189
+ {
190
+ id: v4(),
191
+ type: "update",
192
+ payload: {
193
+ record: updatedTargetRecord,
194
+ },
195
+ },
196
+ ]
197
+ : [],
198
+ callback,
199
+ };
200
+ }),
201
+ ),
202
+ );
203
+
204
+ break;
205
+ case "delete":
206
+ this.observer.next(
207
+ new LazyPromise((records) =>
208
+ thru(args, ([, id]) => ({
209
+ actions: records.some((record) => this.selectId(record) === id)
210
+ ? [
211
+ {
212
+ id: v4(),
213
+ type: "delete",
214
+ payload: {
215
+ id,
216
+ },
217
+ },
218
+ ]
219
+ : [],
220
+ callback,
221
+ })),
222
+ ),
223
+ );
224
+ }
225
+ });
226
+ }
227
+
228
+ public async accessSome<A extends R[K][]>(
229
+ [...ids]: [...A],
230
+ spinner?: Ora,
231
+ ): Promise<[...{ [K in keyof A]: R }]> {
232
+ if (ids.length !== uniq(ids).length) {
233
+ console.warn(`IDs ${ids.join(", ")} are not unique.`);
234
+ }
235
+
236
+ // @ts-expect-error
237
+ return this.dispatch(
238
+ "insert",
239
+ (currentIds: R[K][]) =>
240
+ // @ts-expect-error
241
+
242
+ this.records(difference(uniq(ids), currentIds), spinner),
243
+ // @ts-expect-error
244
+ ).then((records) => ids.map(this.selectById(records)));
245
+ }
246
+
247
+ public async accessAll(): Promise<R[]> {
248
+ return new Promise<R[]>((resolve) => this.dispatch("all", resolve));
249
+ }
250
+ }
251
+
252
+ export { type Table };
253
+
254
+ export const createTable = memoize(
255
+ <R, K extends keyof R>(
256
+ id: string,
257
+ key: K,
258
+ records: TableRecordCreator<R, K>,
259
+ ) =>
260
+ class TableInstance extends Table<R, K> {
261
+ private static readonly actions = new Subject<TableActionCreator<R, K>>();
262
+ private static actionsSubscription?: Subscription;
263
+
264
+ constructor() {
265
+ super(id, key, records, TableInstance.actions);
266
+
267
+ TableInstance.actionsSubscription ??= TableInstance.actions
268
+ .pipe(
269
+ reduceActions(this.applyActions, this.readActions),
270
+ mergeMap(async (action) => {
271
+ const path = resolve(this.path, `${action.id}.json`);
272
+
273
+ if (!existsSync(path)) {
274
+ await writeFile(path, JSON.stringify(action));
275
+ }
276
+
277
+ return action;
278
+ }),
279
+ )
280
+ .subscribe();
281
+ }
282
+ },
283
+ nthArg(0),
284
+ );
package/src/types.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { MaybePromise } from "@cascateer/lib";
2
+ import { LazyPromise } from "@cascateer/lib/promises";
3
+ import { Function1 } from "lodash";
4
+ import { Ora } from "ora";
5
+ import { Table } from "./table";
6
+
7
+ interface BaseTableAction<Type> {
8
+ id: string;
9
+ previousId?: string;
10
+ type: Type;
11
+ payload: unknown;
12
+ args?: [Type, ...unknown[]];
13
+ }
14
+
15
+ interface TableActions<R, K extends keyof R> {
16
+ one: {
17
+ payload: never;
18
+ dispatch: [id: R[K], predicate: Function1<R, void>];
19
+ };
20
+ all: {
21
+ payload: never;
22
+ dispatch: [predicate: Function1<R[], void>];
23
+ };
24
+ insert: {
25
+ payload: {
26
+ records: R[];
27
+ };
28
+ dispatch: [predicate: Function1<R[K][], MaybePromise<R[]>>];
29
+ };
30
+ update: {
31
+ payload: {
32
+ record: R;
33
+ };
34
+ dispatch: [id: R[K], predicate: Function1<R, MaybePromise<R>>];
35
+ };
36
+ delete: {
37
+ payload: {
38
+ id: R[K];
39
+ };
40
+ dispatch: [id: R[K]];
41
+ };
42
+ }
43
+
44
+ export type TableAction<
45
+ R,
46
+ K extends keyof R = keyof R,
47
+ Type extends keyof TableActions<R, K> = keyof TableActions<R, K>,
48
+ > = BaseTableAction<Type> &
49
+ {
50
+ [Type in keyof TableActions<R, K>]: {
51
+ type: Type;
52
+ payload: TableActions<R, K>[Type]["payload"];
53
+ args?: [Type, ...TableActions<R, K>[Type]["dispatch"]];
54
+ };
55
+ }[Type];
56
+
57
+ export interface TableActionCreator<R, K extends keyof R> extends LazyPromise<
58
+ R[],
59
+ TableActionCreatorResult<R, K>
60
+ > {}
61
+
62
+ export interface TableActionCreatorResult<R, K extends keyof R> {
63
+ actions: TableAction<R, K>[];
64
+ callback?: Function1<R[], void>;
65
+ }
66
+
67
+ export interface TableRecordCreator<R, K extends keyof R> {
68
+ (ids: R[K][], spinner?: Ora): MaybePromise<R[]>;
69
+ }
70
+
71
+ export interface FileTableRecord {
72
+ originalUrl: string;
73
+ name: string;
74
+ checksum: string;
75
+ }
76
+
77
+ export interface FileTable extends Table<FileTableRecord, "originalUrl"> {}
@@ -0,0 +1 @@
1
+ {"id":"2fabb707-99c5-483a-bfaa-e24763c1db25","type":"update","payload":{"record":{"id":"foo","name":"xxx"}},"previousId":"d09834ff-25cd-44fa-a4c2-522547c35866"}
@@ -0,0 +1 @@
1
+ {"id":"d09834ff-25cd-44fa-a4c2-522547c35866","type":"insert","payload":{"records":[{"id":"foo"}]}}
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2016",
4
+ "module": "commonjs",
5
+ "types": ["node"],
6
+ "esModuleInterop": true,
7
+ "forceConsistentCasingInFileNames": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "noUncheckedIndexedAccess": true
11
+ }
12
+ }