tsondb 0.22.0 → 0.23.1
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/dist/src/node/config.d.ts +4 -7
- package/dist/src/node/index.d.ts +14 -132
- package/dist/src/node/index.js +66 -520
- package/dist/src/node/readonly.d.ts +158 -0
- package/dist/src/node/readonly.js +321 -0
- package/dist/src/node/server/index.d.ts +6 -5
- package/dist/src/node/server/index.js +5 -6
- package/dist/src/node/shared.d.ts +46 -0
- package/dist/src/node/shared.js +267 -0
- package/dist/src/node/utils/databaseInMemory.d.ts +2 -0
- package/dist/src/node/utils/databaseInMemory.js +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { exists } from "@elyukai/utils/async/node";
|
|
2
|
+
import { nullableToArray } from "@elyukai/utils/nullable";
|
|
3
|
+
import { isError } from "@elyukai/utils/result";
|
|
4
|
+
import { assertExhaustive } from "@elyukai/utils/typeSafety";
|
|
5
|
+
import Debug from "debug";
|
|
6
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { findPackageJSON } from "node:module";
|
|
8
|
+
import { join, sep } from "node:path";
|
|
9
|
+
import { stderr } from "node:process";
|
|
10
|
+
import { styleText } from "node:util";
|
|
11
|
+
import { NodeKind } from "../shared/schema/Node.js";
|
|
12
|
+
import { countError, countErrors, getErrorMessageForDisplay, wrapErrorsIfAny, } from "../shared/utils/error.js";
|
|
13
|
+
import { parallelizeErrors } from "../shared/utils/validation.js";
|
|
14
|
+
import { getDisplayName, getDisplayNameWithId } from "./schema/detached.js";
|
|
15
|
+
import { isEntityDecl } from "./schema/guards.js";
|
|
16
|
+
import { serializeNode } from "./schema/treeOperations/serialization.js";
|
|
17
|
+
import { createEntityNameValidator, createReferenceValidator, validateDeclReferentialIntegrity, validateDeclStructuralIntegrity, } from "./schema/treeOperations/validation.js";
|
|
18
|
+
import { checkCustomConstraintsForAllEntities } from "./utils/customConstraints.js";
|
|
19
|
+
import { DatabaseInMemory } from "./utils/databaseInMemory.js";
|
|
20
|
+
import { getAllInstanceOverviewsByEntityName, getInstanceOverview } from "./utils/displayName.js";
|
|
21
|
+
import { getFileNameForId, getSingletonFileName } from "./utils/files.js";
|
|
22
|
+
import { attachGitStatusToDatabaseInMemory } from "./utils/git.js";
|
|
23
|
+
import { getReferencesToInstances } from "./utils/references.js";
|
|
24
|
+
import { checkUniqueConstraintsForAllEntities } from "./utils/unique.js";
|
|
25
|
+
const debug = Debug("tsondb:class:shared");
|
|
26
|
+
const getMetaFilePath = (dataRootPath) => join(dataRootPath, ".meta.json");
|
|
27
|
+
export const getTSONDBVersion = async () => {
|
|
28
|
+
const packageJsonPath = findPackageJSON(".", import.meta.url);
|
|
29
|
+
if (!packageJsonPath) {
|
|
30
|
+
throw new Error("Could not find package.json for TSONDB");
|
|
31
|
+
}
|
|
32
|
+
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf-8"));
|
|
33
|
+
return packageJson.version;
|
|
34
|
+
};
|
|
35
|
+
export const readMetaFile = async (dataRootPath) => JSON.parse(await readFile(getMetaFilePath(dataRootPath), "utf-8"));
|
|
36
|
+
// try {
|
|
37
|
+
// } catch (err) {
|
|
38
|
+
// if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
39
|
+
// return undefined
|
|
40
|
+
// }
|
|
41
|
+
// throw err
|
|
42
|
+
// }
|
|
43
|
+
export const writeMetaFile = async (dataRootPath, meta) => writeFile(getMetaFilePath(dataRootPath), JSON.stringify(meta, null, 2), "utf-8");
|
|
44
|
+
export const prepareFolders = async (schemaVersion, dataRootPath, entities) => {
|
|
45
|
+
await mkdir(dataRootPath, { recursive: true });
|
|
46
|
+
await Promise.all(entities.map(entity => mkdir(join(dataRootPath, entity.name), { recursive: true })));
|
|
47
|
+
if (!(await exists(getMetaFilePath(dataRootPath)))) {
|
|
48
|
+
const newMetaFile = {
|
|
49
|
+
version: await getTSONDBVersion(),
|
|
50
|
+
schemaVersion,
|
|
51
|
+
};
|
|
52
|
+
await writeMetaFile(dataRootPath, newMetaFile);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
export const generateOutputs = async (schema, outputs) => {
|
|
56
|
+
await Promise.all(outputs.map(output => output.run(schema)));
|
|
57
|
+
};
|
|
58
|
+
export const checkLocales = (schema, data, locales) => {
|
|
59
|
+
const { localeEntity } = schema;
|
|
60
|
+
if (localeEntity &&
|
|
61
|
+
!locales.every(locale => data.hasInstanceOfEntityById(localeEntity.name, locale))) {
|
|
62
|
+
throw new Error("All provided locales must exist in the database.");
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const loadDataAndMeta = async (dataRootPath, schema, allowCompressedFile) => {
|
|
66
|
+
const rootStat = await stat(dataRootPath).catch(() => undefined);
|
|
67
|
+
if (rootStat === undefined) {
|
|
68
|
+
throw new Error(`Data root path "${dataRootPath}" does not exist.`);
|
|
69
|
+
}
|
|
70
|
+
if (rootStat.isFile()) {
|
|
71
|
+
if (!allowCompressedFile) {
|
|
72
|
+
throw new Error(`A writable database does not allow writing to a compressed database, but "${dataRootPath}" is a file.`);
|
|
73
|
+
}
|
|
74
|
+
debug("loading compressed database into memory ...");
|
|
75
|
+
const compressedData = JSON.parse(await readFile(dataRootPath, "utf-8"));
|
|
76
|
+
const data = await DatabaseInMemory.loadFromCompressedFile(compressedData, schema.isEntityName.bind(schema));
|
|
77
|
+
debug("done");
|
|
78
|
+
return { data, meta: compressedData.meta };
|
|
79
|
+
}
|
|
80
|
+
else if (rootStat.isDirectory()) {
|
|
81
|
+
debug("loading database into memory ...");
|
|
82
|
+
const data = await DatabaseInMemory.load(dataRootPath, schema.entities, schema.singletonEntities);
|
|
83
|
+
const meta = await readMetaFile(dataRootPath);
|
|
84
|
+
debug("done");
|
|
85
|
+
return { data, meta };
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
throw new Error(`Data root path "${dataRootPath}" is not a directory.`);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
export const initData = async (dataRootPath, schema, locales, git, gitStatus, skipReferenceCache, allowCompressedFile) => {
|
|
92
|
+
const { data, meta } = await loadDataAndMeta(dataRootPath, schema, allowCompressedFile);
|
|
93
|
+
checkLocales(schema, data, locales);
|
|
94
|
+
const serializedDeclarationsByName = Object.fromEntries(schema.resolvedDeclarations.map(decl => [decl.name, serializeNode(decl)]));
|
|
95
|
+
let referencesToInstances = {};
|
|
96
|
+
if (!skipReferenceCache) {
|
|
97
|
+
debug("creating references cache ...");
|
|
98
|
+
referencesToInstances = await getReferencesToInstances(data, serializedDeclarationsByName);
|
|
99
|
+
debug("done");
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
debug("skipping references cache creation");
|
|
103
|
+
}
|
|
104
|
+
if (git) {
|
|
105
|
+
const status = gitStatus ?? (await git.client.status());
|
|
106
|
+
const dataWithGit = attachGitStatusToDatabaseInMemory(data, dataRootPath, git.root, status);
|
|
107
|
+
debug("attached git status to instances");
|
|
108
|
+
return { data: dataWithGit, referencesToInstances, meta };
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
return { data, referencesToInstances, meta };
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
export const validate = (properties, options = {}) => {
|
|
115
|
+
const { data, schema, validationOptions, dataRootPath, locales } = properties;
|
|
116
|
+
const { checkReferentialIntegrity, checkOnlyEntities } = validationOptions;
|
|
117
|
+
const { skipStructuralValidation = false, logToConsole = true, changedEntities: changedEntityNames, } = options;
|
|
118
|
+
const entities = [...schema.entities, ...schema.singletonEntities];
|
|
119
|
+
const getEntity = schema.getEntity.bind(schema);
|
|
120
|
+
for (const onlyEntity of checkOnlyEntities) {
|
|
121
|
+
if (!entities.find(entity => entity.name === onlyEntity)) {
|
|
122
|
+
throw new Error(`Entity "${onlyEntity}" not found in schema`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const onlyEntities = checkOnlyEntities.length > 0
|
|
126
|
+
? entities.filter(entity => checkOnlyEntities.includes(entity.name))
|
|
127
|
+
: entities;
|
|
128
|
+
const changedEntities = changedEntityNames
|
|
129
|
+
? entities.filter(entity => changedEntityNames.includes(entity.name))
|
|
130
|
+
: undefined;
|
|
131
|
+
const validationContext = { validationOptions, useStyling: true };
|
|
132
|
+
const errors = [];
|
|
133
|
+
const summary = [];
|
|
134
|
+
const debugAndAddToSummary = (message) => {
|
|
135
|
+
debug(message);
|
|
136
|
+
summary.push(message);
|
|
137
|
+
};
|
|
138
|
+
if (!skipStructuralValidation) {
|
|
139
|
+
debug("Checking structural integrity ...");
|
|
140
|
+
const structureErrors = onlyEntities
|
|
141
|
+
.flatMap(entity => {
|
|
142
|
+
switch (entity.kind) {
|
|
143
|
+
case NodeKind.EntityDecl:
|
|
144
|
+
return parallelizeErrors(data
|
|
145
|
+
.getAllInstanceContainersOfEntity(entity.name)
|
|
146
|
+
.map(instance => wrapErrorsIfAny(`in file ${styleText("white", `"${dataRootPath}${sep}${styleText("bold", join(entity.name, getFileNameForId(instance.id)))}"`)}`, validateDeclStructuralIntegrity(validationContext, [], entity, [], instance.content))));
|
|
147
|
+
case NodeKind.SingletonEntityDecl: {
|
|
148
|
+
const instance = data.getSingletonInstanceContainerOfEntity(entity.name);
|
|
149
|
+
if (instance) {
|
|
150
|
+
return nullableToArray(wrapErrorsIfAny(`in file ${styleText("white", `"${dataRootPath}${sep}${styleText("bold", getSingletonFileName(entity.name))}"`)}`, validateDeclStructuralIntegrity(validationContext, [], entity, [], instance.content)));
|
|
151
|
+
}
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
default:
|
|
155
|
+
return assertExhaustive(entity);
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
.toSorted((a, b) => a.message.localeCompare(b.message));
|
|
159
|
+
if (structureErrors.length > 0) {
|
|
160
|
+
const errorCount = countErrors(structureErrors);
|
|
161
|
+
debugAndAddToSummary(`${errorCount.toString()} structural integrity violation${errorCount === 1 ? "" : "s"} found`);
|
|
162
|
+
errors.push(...structureErrors);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
debug("No structural integrity violations found");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (errors.length === 0) {
|
|
169
|
+
if (!skipStructuralValidation) {
|
|
170
|
+
if (checkReferentialIntegrity) {
|
|
171
|
+
debug("Checking referential integrity ...");
|
|
172
|
+
const entityNameValidator = createEntityNameValidator(schema.isEntityName.bind(schema), true);
|
|
173
|
+
const referenceValidator = createReferenceValidator(schema.isEntityName.bind(schema), data, true);
|
|
174
|
+
const referenceErrors = onlyEntities
|
|
175
|
+
.flatMap(entity => {
|
|
176
|
+
switch (entity.kind) {
|
|
177
|
+
case NodeKind.EntityDecl:
|
|
178
|
+
return parallelizeErrors(data
|
|
179
|
+
.getAllInstanceContainersOfEntity(entity.name)
|
|
180
|
+
.map(instance => wrapErrorsIfAny(`in file ${styleText("white", `"${dataRootPath}${sep}${styleText("bold", join(entity.name, getFileNameForId(instance.id)))}"`)}`, validateDeclReferentialIntegrity(validationContext, entityNameValidator, referenceValidator, [], entity, [], instance.content))));
|
|
181
|
+
case NodeKind.SingletonEntityDecl: {
|
|
182
|
+
const instance = data.getSingletonInstanceContainerOfEntity(entity.name);
|
|
183
|
+
if (instance) {
|
|
184
|
+
return nullableToArray(wrapErrorsIfAny(`in file ${styleText("white", `"${dataRootPath}${sep}${styleText("bold", getSingletonFileName(entity.name))}"`)}`, validateDeclReferentialIntegrity(validationContext, entityNameValidator, referenceValidator, [], entity, [], instance.content)));
|
|
185
|
+
}
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
default:
|
|
189
|
+
return assertExhaustive(entity);
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
.toSorted((a, b) => a.message.localeCompare(b.message));
|
|
193
|
+
if (referenceErrors.length > 0) {
|
|
194
|
+
const errorCount = countErrors(referenceErrors);
|
|
195
|
+
debugAndAddToSummary(`${errorCount.toString()} referential integrity violation${errorCount === 1 ? "" : "s"} found`);
|
|
196
|
+
errors.push(...referenceErrors);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
debug("No referential integrity violations found");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
debug("Disabled referential integrity checks, skipping them");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
debug("Checking unique constraints ...");
|
|
207
|
+
const instanceOverviewsByEntityName = getAllInstanceOverviewsByEntityName(getEntity, data, locales);
|
|
208
|
+
const uniqueConstraintResult = checkUniqueConstraintsForAllEntities(data, (changedEntities ?? onlyEntities).filter(e => isEntityDecl(e)), instanceOverviewsByEntityName);
|
|
209
|
+
if (isError(uniqueConstraintResult)) {
|
|
210
|
+
const errorCount = countError(uniqueConstraintResult.error);
|
|
211
|
+
debugAndAddToSummary(`${errorCount.toString()} unique constraint violation${errorCount === 1 ? "" : "s"} found`);
|
|
212
|
+
errors.push(uniqueConstraintResult.error);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
debug("No unique constraint violations found");
|
|
216
|
+
}
|
|
217
|
+
debug("Checking custom constraints ...");
|
|
218
|
+
const customConstraintResult = checkCustomConstraintsForAllEntities((...args) => getDisplayName(getEntity, locales, data, ...args), (...args) => getDisplayNameWithId(getEntity, locales, data, ...args), (...args) => getInstanceOverview(data.getInstanceContainerOfEntityById.bind(data), data.getAllChildInstanceContainersForParent.bind(data, getEntity), getEntity, locales, args), getEntity, data, entities);
|
|
219
|
+
if (isError(customConstraintResult)) {
|
|
220
|
+
const errorCount = countError(customConstraintResult.error);
|
|
221
|
+
debugAndAddToSummary(`${errorCount.toString()} custom constraint violation${errorCount === 1 ? "" : "s"} found`);
|
|
222
|
+
errors.push(customConstraintResult.error);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
debug("No custom constraint violations found");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
debugAndAddToSummary("Skipping further checks due to previous structural integrity errors");
|
|
230
|
+
}
|
|
231
|
+
if (logToConsole) {
|
|
232
|
+
console.log(`${data.totalSize.toString()} instance${data.totalSize === 1 ? "" : "s"} checked`);
|
|
233
|
+
if (errors.length === 0) {
|
|
234
|
+
console.log(styleText("green", "All instances are valid"));
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
const errorCount = countErrors(errors);
|
|
238
|
+
console.error(styleText("red", `${errors.map(err => getErrorMessageForDisplay(err)).join("\n\n")}\n`, {
|
|
239
|
+
stream: stderr,
|
|
240
|
+
}));
|
|
241
|
+
console.error(styleText(["red", "bold"], "Summary", { stream: stderr }));
|
|
242
|
+
console.error(styleText("red", [
|
|
243
|
+
`${errorCount.toString()} validation error${errorCount === 1 ? "" : "s"} found`,
|
|
244
|
+
...summary,
|
|
245
|
+
].join("\n"), { stream: stderr }));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return errors;
|
|
249
|
+
};
|
|
250
|
+
export const createCompressedDatabaseFile = (data, schemaVersion) => {
|
|
251
|
+
const compressedData = {
|
|
252
|
+
meta: {
|
|
253
|
+
version: "1.0.0",
|
|
254
|
+
schemaVersion,
|
|
255
|
+
},
|
|
256
|
+
data: Object.assign({}, Object.fromEntries(data
|
|
257
|
+
.getAllInstances()
|
|
258
|
+
.map(([entityName, instances]) => [
|
|
259
|
+
entityName,
|
|
260
|
+
Object.fromEntries(instances.map(instance => [instance.id, instance.content])),
|
|
261
|
+
])), Object.fromEntries(data
|
|
262
|
+
.getAllSingletonInstances()
|
|
263
|
+
.map(([entityName, container]) => [entityName, container.content]))),
|
|
264
|
+
};
|
|
265
|
+
return compressedData;
|
|
266
|
+
};
|
|
267
|
+
export const isEntityPair = (pair, isEntityName) => isEntityName(pair[0]);
|
|
@@ -3,10 +3,12 @@ import { type KeyPath } from "../../shared/schema/utils/keyPath.ts";
|
|
|
3
3
|
import type { InstanceContainer, InstanceContent, SingletonInstanceContainer } from "../../shared/utils/instances.ts";
|
|
4
4
|
import type { EntityDecl, SingletonEntityDecl } from "../schema/dsl/index.ts";
|
|
5
5
|
import { type AnyChildEntityMap, type AnyEntityMap, type AnySingletonEntityMap, type GetEntityByName, type IdArgsVariant, type RegisteredEntityMap, type RegisteredSingletonEntityMap } from "../schema/generatedTypeHelpers.ts";
|
|
6
|
+
import { type CompressedDatabaseFile } from "../shared.ts";
|
|
6
7
|
export declare class DatabaseInMemory<EM extends AnyEntityMap = RegisteredEntityMap, SEM extends AnySingletonEntityMap = RegisteredSingletonEntityMap> {
|
|
7
8
|
#private;
|
|
8
9
|
private constructor();
|
|
9
10
|
static load<EM extends AnyEntityMap = RegisteredEntityMap, SEM extends AnySingletonEntityMap = RegisteredSingletonEntityMap>(dataRoot: string, entities: readonly EntityDecl<Extract<keyof EM, string>>[], singletonEntities: readonly SingletonEntityDecl<Extract<keyof SEM, string>>[]): Promise<DatabaseInMemory<EM, SEM>>;
|
|
11
|
+
static loadFromCompressedFile<EM extends AnyEntityMap = RegisteredEntityMap, SEM extends AnySingletonEntityMap = RegisteredSingletonEntityMap>(file: CompressedDatabaseFile<EM, SEM>, isEntityName: (entityName: Extract<keyof EM | keyof SEM, string>) => entityName is Extract<keyof EM, string>): Promise<DatabaseInMemory<EM, SEM>>;
|
|
10
12
|
getAllInstanceContainersOfEntity<E extends Extract<keyof EM, string>>(entityName: E): InstanceContainer<EM[E]>[];
|
|
11
13
|
getAllInstancesOfEntity<E extends Extract<keyof EM, string>>(entityName: E): EM[E][];
|
|
12
14
|
getAllInstances(): [entityName: Extract<keyof EM, string>, instances: InstanceContainer[]][];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { partition } from "@elyukai/utils/array/groups";
|
|
1
2
|
import { reorder } from "@elyukai/utils/array/modify";
|
|
2
3
|
import { mapAsync } from "@elyukai/utils/async";
|
|
3
4
|
import { Dictionary } from "@elyukai/utils/dictionary";
|
|
@@ -13,6 +14,7 @@ import { promisify } from "node:util";
|
|
|
13
14
|
import { getValueAtKeyPathIfDefined, setValueAtKeyPath, } from "../../shared/schema/utils/keyPath.js";
|
|
14
15
|
import { HTTPError } from "../../shared/utils/error.js";
|
|
15
16
|
import { normalizedIdArgs, } from "../schema/generatedTypeHelpers.js";
|
|
17
|
+
import { isEntityPair } from "../shared.js";
|
|
16
18
|
import { getPathToSingletonInstance, readSingletonInstance } from "./files.js";
|
|
17
19
|
const exec = promisify(child_process.exec);
|
|
18
20
|
const ulimit = platform === "win32" ? 2048 : Number.parseInt((await exec("ulimit -n")).stdout, 10);
|
|
@@ -62,6 +64,14 @@ export class DatabaseInMemory {
|
|
|
62
64
|
}
|
|
63
65
|
}, 1)).filter((pair) => pair[1] !== undefined)));
|
|
64
66
|
}
|
|
67
|
+
static async loadFromCompressedFile(file, isEntityName) {
|
|
68
|
+
const dataEntries = Object.entries(file.data);
|
|
69
|
+
const [entityEntries, singletonEntityEntries] = partition(dataEntries, pair => isEntityPair(pair, isEntityName));
|
|
70
|
+
return new DatabaseInMemory(Dictionary.fromEntries(entityEntries.map(([entityName, instances]) => [
|
|
71
|
+
entityName,
|
|
72
|
+
Dictionary.fromEntries(Object.entries(instances).map(([id, content]) => [id, { id, content }])),
|
|
73
|
+
])), Dictionary.fromEntries(singletonEntityEntries.map(([entityName, content]) => [entityName, { content }])));
|
|
74
|
+
}
|
|
65
75
|
getAllInstanceContainersOfEntity(entityName) {
|
|
66
76
|
return (this.#data.getMap(entityName, instances => instances.values()) ?? []);
|
|
67
77
|
}
|