@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.
- package/.github/workflows/publish.yml +11 -0
- package/package.json +30 -0
- package/src/defaults.ts +4 -0
- package/src/file.ts +54 -0
- package/src/index.ts +3 -0
- package/src/observables/reduceActions.ts +43 -0
- package/src/table.ts +284 -0
- package/src/types.ts +77 -0
- package/tables/test/2fabb707-99c5-483a-bfaa-e24763c1db25.json +1 -0
- package/tables/test/d09834ff-25cd-44fa-a4c2-522547c35866.json +1 -0
- package/tsconfig.json +12 -0
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
|
+
}
|
package/src/defaults.ts
ADDED
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,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