@travetto/model-file 5.0.0-rc.11
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/README.md +19 -0
- package/__index__.ts +1 -0
- package/package.json +51 -0
- package/src/service.ts +233 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!-- This file was generated by @travetto/doc and should not be modified directly -->
|
|
2
|
+
<!-- Please modify https://github.com/travetto/travetto/tree/main/module/model-file/DOC.tsx and execute "npx trv doc" to rebuild -->
|
|
3
|
+
# File Model Support
|
|
4
|
+
|
|
5
|
+
## File system backing for the travetto model module.
|
|
6
|
+
|
|
7
|
+
**Install: @travetto/model-file**
|
|
8
|
+
```bash
|
|
9
|
+
npm install @travetto/model-file
|
|
10
|
+
|
|
11
|
+
# or
|
|
12
|
+
|
|
13
|
+
yarn add @travetto/model-file
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
This module provides an file-based implementation for the [Data Modeling Support](https://github.com/travetto/travetto/tree/main/module/model#readme "Datastore abstraction for core operations."). Supported features:
|
|
17
|
+
* [CRUD](https://github.com/travetto/travetto/tree/main/module/model/src/service/crud.ts#L11)
|
|
18
|
+
* [Expiry](https://github.com/travetto/travetto/tree/main/module/model/src/service/expiry.ts#L11)
|
|
19
|
+
* [Blob](https://github.com/travetto/travetto/tree/main/module/model/src/service/blob.ts#L8)
|
package/__index__.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './src/service';
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@travetto/model-file",
|
|
3
|
+
"version": "5.0.0-rc.11",
|
|
4
|
+
"description": "File system backing for the travetto model module.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"datastore",
|
|
7
|
+
"file",
|
|
8
|
+
"schema",
|
|
9
|
+
"typescript",
|
|
10
|
+
"travetto"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://travetto.io",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": {
|
|
15
|
+
"email": "travetto.framework@gmail.com",
|
|
16
|
+
"name": "Travetto Framework"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"__index__.ts",
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"main": "__index__.ts",
|
|
23
|
+
"repository": {
|
|
24
|
+
"url": "https://github.com/travetto/travetto.git",
|
|
25
|
+
"directory": "module/model-file"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@travetto/config": "^5.0.0-rc.11",
|
|
29
|
+
"@travetto/di": "^5.0.0-rc.10",
|
|
30
|
+
"@travetto/model": "^5.0.0-rc.11",
|
|
31
|
+
"@travetto/schema": "^5.0.0-rc.11"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@travetto/cli": "^5.0.0-rc.11",
|
|
35
|
+
"@travetto/test": "^5.0.0-rc.10"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"@travetto/cli": {
|
|
39
|
+
"optional": true
|
|
40
|
+
},
|
|
41
|
+
"@travetto/test": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"travetto": {
|
|
46
|
+
"displayName": "File Model Support"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { Class, TimeSpan, Runtime, asFull, BlobMeta, ByteRange, BinaryInput, BinaryUtil } from '@travetto/runtime';
|
|
8
|
+
import { Injectable } from '@travetto/di';
|
|
9
|
+
import { Config } from '@travetto/config';
|
|
10
|
+
import { Required } from '@travetto/schema';
|
|
11
|
+
import {
|
|
12
|
+
ModelCrudSupport, ModelExpirySupport, ModelStorageSupport, ModelType, ModelRegistry,
|
|
13
|
+
NotFoundError, OptionalId, ExistsError, ModelBlobSupport, ModelBlobUtil
|
|
14
|
+
} from '@travetto/model';
|
|
15
|
+
|
|
16
|
+
import { ModelCrudUtil } from '@travetto/model/src/internal/service/crud';
|
|
17
|
+
import { ModelExpiryUtil } from '@travetto/model/src/internal/service/expiry';
|
|
18
|
+
import { MODEL_BLOB, ModelBlobNamespace } from '@travetto/model/src/internal/service/blob';
|
|
19
|
+
|
|
20
|
+
type Suffix = '.bin' | '.meta' | '.json' | '.expires';
|
|
21
|
+
|
|
22
|
+
const BIN = '.bin';
|
|
23
|
+
const META = '.meta';
|
|
24
|
+
|
|
25
|
+
@Config('model.file')
|
|
26
|
+
export class FileModelConfig {
|
|
27
|
+
@Required(false)
|
|
28
|
+
folder: string;
|
|
29
|
+
namespace: string = '.';
|
|
30
|
+
autoCreate?: boolean;
|
|
31
|
+
cullRate?: number | TimeSpan;
|
|
32
|
+
|
|
33
|
+
async postConstruct(): Promise<void> {
|
|
34
|
+
this.folder ??= path.resolve(os.tmpdir(), `trv_file_${Runtime.main.name.replace(/[^a-z]/ig, '_')}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const exists = (f: string): Promise<boolean> => fs.stat(f).then(() => true, () => false);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Standard file support
|
|
42
|
+
*/
|
|
43
|
+
@Injectable()
|
|
44
|
+
export class FileModelService implements ModelCrudSupport, ModelBlobSupport, ModelExpirySupport, ModelStorageSupport {
|
|
45
|
+
|
|
46
|
+
private static async * scanFolder(folder: string, suffix: string): AsyncGenerator<[id: string, field: string]> {
|
|
47
|
+
for (const sub of await fs.readdir(folder)) {
|
|
48
|
+
for (const file of await fs.readdir(path.resolve(folder, sub))) {
|
|
49
|
+
if (file.endsWith(suffix)) {
|
|
50
|
+
yield [file.replace(suffix, ''), path.resolve(folder, sub, file)];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
idSource = ModelCrudUtil.uuidSource();
|
|
57
|
+
|
|
58
|
+
get client(): string {
|
|
59
|
+
return this.config.folder;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The root location for all activity
|
|
64
|
+
*/
|
|
65
|
+
constructor(public readonly config: FileModelConfig) { }
|
|
66
|
+
|
|
67
|
+
async #resolveName<T extends ModelType>(cls: Class<T> | string, suffix?: Suffix, id?: string): Promise<string> {
|
|
68
|
+
const name = typeof cls === 'string' ? cls : ModelRegistry.getStore(cls);
|
|
69
|
+
let resolved = path.resolve(this.config.folder, this.config.namespace, name);
|
|
70
|
+
if (id) {
|
|
71
|
+
resolved = path.resolve(resolved, id.replace(/^[/]/, '').substring(0, 3));
|
|
72
|
+
}
|
|
73
|
+
let dir = resolved;
|
|
74
|
+
if (id) {
|
|
75
|
+
resolved = path.resolve(resolved, `${id}${suffix}`);
|
|
76
|
+
dir = path.dirname(resolved);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await fs.mkdir(dir, { recursive: true });
|
|
80
|
+
return resolved;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async #find<T extends ModelType>(cls: Class<T> | string, suffix: Suffix, id?: string): Promise<string> {
|
|
84
|
+
const file = await this.#resolveName(cls, suffix, id);
|
|
85
|
+
if (id && !(await exists(file))) {
|
|
86
|
+
throw new NotFoundError(cls, id);
|
|
87
|
+
}
|
|
88
|
+
return file;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
postConstruct(): void {
|
|
92
|
+
ModelExpiryUtil.registerCull(this);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
checkExpiry<T extends ModelType>(cls: Class<T>, item: T): T {
|
|
96
|
+
const { expiresAt } = ModelRegistry.get(cls);
|
|
97
|
+
if (expiresAt && ModelExpiryUtil.getExpiryState(cls, item).expired) {
|
|
98
|
+
throw new NotFoundError(cls, item.id);
|
|
99
|
+
}
|
|
100
|
+
return item;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async get<T extends ModelType>(cls: Class<T>, id: string): Promise<T> {
|
|
104
|
+
await this.#find(cls, '.json', id);
|
|
105
|
+
|
|
106
|
+
const file = await this.#resolveName(cls, '.json', id);
|
|
107
|
+
|
|
108
|
+
if (await exists(file)) {
|
|
109
|
+
const content = await fs.readFile(file);
|
|
110
|
+
return this.checkExpiry(cls, await ModelCrudUtil.load(cls, content));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
throw new NotFoundError(cls, id);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async create<T extends ModelType>(cls: Class<T>, item: OptionalId<T>): Promise<T> {
|
|
117
|
+
if (!item.id) {
|
|
118
|
+
item.id = this.idSource.create();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const file = await this.#resolveName(cls, '.json', item.id);
|
|
122
|
+
|
|
123
|
+
if (await exists(file)) {
|
|
124
|
+
throw new ExistsError(cls, item.id!);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return await this.upsert(cls, item);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async update<T extends ModelType>(cls: Class<T>, item: T): Promise<T> {
|
|
131
|
+
await this.get(cls, item.id);
|
|
132
|
+
return await this.upsert(cls, item);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async upsert<T extends ModelType>(cls: Class<T>, item: OptionalId<T>): Promise<T> {
|
|
136
|
+
ModelCrudUtil.ensureNotSubType(cls);
|
|
137
|
+
const prepped = await ModelCrudUtil.preStore(cls, item, this);
|
|
138
|
+
|
|
139
|
+
const file = await this.#resolveName(cls, '.json', item.id);
|
|
140
|
+
await fs.writeFile(file, JSON.stringify(item), { encoding: 'utf8' });
|
|
141
|
+
|
|
142
|
+
return prepped;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async updatePartial<T extends ModelType>(cls: Class<T>, item: Partial<T> & { id: string }, view?: string): Promise<T> {
|
|
146
|
+
ModelCrudUtil.ensureNotSubType(cls);
|
|
147
|
+
const id = item.id;
|
|
148
|
+
item = await ModelCrudUtil.naivePartialUpdate(cls, item, view, () => this.get(cls, id));
|
|
149
|
+
const file = await this.#resolveName(cls, '.json', item.id);
|
|
150
|
+
await fs.writeFile(file, JSON.stringify(item), { encoding: 'utf8' });
|
|
151
|
+
return asFull<T>(item);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async delete<T extends ModelType>(cls: Class<T>, id: string): Promise<void> {
|
|
155
|
+
const file = await this.#find(cls, '.json', id);
|
|
156
|
+
await fs.unlink(file);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async * list<T extends ModelType>(cls: Class<T>): AsyncIterable<T> {
|
|
160
|
+
for await (const [id] of FileModelService.scanFolder(await this.#resolveName(cls, '.json'), '.json')) {
|
|
161
|
+
try {
|
|
162
|
+
yield await this.get(cls, id);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (!(err instanceof NotFoundError)) {
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Blob
|
|
172
|
+
async insertBlob(location: string, input: BinaryInput, meta?: BlobMeta, errorIfExisting = false): Promise<void> {
|
|
173
|
+
await this.describeBlob(location);
|
|
174
|
+
if (errorIfExisting) {
|
|
175
|
+
throw new ExistsError(ModelBlobNamespace, location);
|
|
176
|
+
}
|
|
177
|
+
return this.upsertBlob(location, input, meta);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async upsertBlob(location: string, input: BinaryInput, meta?: BlobMeta): Promise<void> {
|
|
181
|
+
const [stream, blobMeta] = await ModelBlobUtil.getInput(input, meta);
|
|
182
|
+
const file = await this.#resolveName(ModelBlobNamespace, BIN, location);
|
|
183
|
+
await Promise.all([
|
|
184
|
+
await pipeline(stream, createWriteStream(file)),
|
|
185
|
+
fs.writeFile(file.replace(BIN, META), JSON.stringify(blobMeta), 'utf8')
|
|
186
|
+
]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async getBlob(location: string, range?: ByteRange): Promise<Blob> {
|
|
190
|
+
const file = await this.#find(ModelBlobNamespace, BIN, location);
|
|
191
|
+
const meta = await this.describeBlob(location);
|
|
192
|
+
const final = range ? ModelBlobUtil.enforceRange(range, meta.size!) : undefined;
|
|
193
|
+
return BinaryUtil.readableBlob(() => createReadStream(file, { ...range }), { ...meta, range: final });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async describeBlob(location: string): Promise<BlobMeta> {
|
|
197
|
+
const file = await this.#find(ModelBlobNamespace, META, location);
|
|
198
|
+
const content = await fs.readFile(file);
|
|
199
|
+
const text: BlobMeta = JSON.parse(content.toString('utf8'));
|
|
200
|
+
return text;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async deleteBlob(location: string): Promise<void> {
|
|
204
|
+
const file = await this.#resolveName(ModelBlobNamespace, BIN, location);
|
|
205
|
+
if (await exists(file)) {
|
|
206
|
+
await Promise.all([
|
|
207
|
+
fs.unlink(file),
|
|
208
|
+
fs.unlink(file.replace('.bin', META))
|
|
209
|
+
]);
|
|
210
|
+
} else {
|
|
211
|
+
throw new NotFoundError(ModelBlobNamespace, location);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Expiry
|
|
216
|
+
async deleteExpired<T extends ModelType>(cls: Class<T>): Promise<number> {
|
|
217
|
+
return ModelExpiryUtil.naiveDeleteExpired(this, cls);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Storage management
|
|
221
|
+
async createStorage(): Promise<void> {
|
|
222
|
+
const dir = path.resolve(this.config.folder, this.config.namespace);
|
|
223
|
+
await fs.mkdir(dir, { recursive: true });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async deleteStorage(): Promise<void> {
|
|
227
|
+
await fs.rm(path.resolve(this.config.folder, this.config.namespace), { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async truncate(cls: Class<ModelType>): Promise<void> {
|
|
231
|
+
await fs.rm(await this.#resolveName(cls === MODEL_BLOB ? ModelBlobNamespace : cls), { recursive: true, force: true });
|
|
232
|
+
}
|
|
233
|
+
}
|