@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 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
+ }