tsondb 0.21.8 → 0.21.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.
@@ -50,6 +50,10 @@ export interface TSONDBOptions<T extends DefaultTSONDBTypes = DefaultTSONDBTypes
50
50
  * Options for formatting the data in the database.
51
51
  */
52
52
  formatterOptions?: Partial<FormatterOptions>;
53
+ /**
54
+ * If set, the database will be migrated through the specified migration stages before being used.
55
+ */
56
+ migrationStages?: MigrationStage<T>[];
53
57
  }
54
58
  /**
55
59
  * Options for validating the data in the database.
@@ -103,6 +107,11 @@ export interface TranslationValidationOptions {
103
107
  */
104
108
  matchParametersInKeys?: boolean;
105
109
  }
110
+ export interface MigrationStage<T extends DefaultTSONDBTypes = DefaultTSONDBTypes> {
111
+ fromVersion: string;
112
+ toVersion: string;
113
+ willMigrate?(context: TSONDB<T>): Promise<void>;
114
+ }
106
115
  export type HookTrigger = "transaction:before" | "transaction:after";
107
116
  /**
108
117
  * The main class for managing a typed JSON database.
@@ -1,3 +1,4 @@
1
+ import { exists } from "@elyukai/utils/async/node";
1
2
  import { on } from "@elyukai/utils/function";
2
3
  import { Lazy } from "@elyukai/utils/lazy";
3
4
  import { nullableToArray } from "@elyukai/utils/nullable";
@@ -5,10 +6,12 @@ import { compareNumber, reduceCompare } from "@elyukai/utils/ordering";
5
6
  import { isError } from "@elyukai/utils/result";
6
7
  import { assertExhaustive } from "@elyukai/utils/typeSafety";
7
8
  import Debug from "debug";
8
- import { mkdir, writeFile } from "node:fs/promises";
9
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import { findPackageJSON } from "node:module";
9
11
  import { join, sep } from "node:path";
10
12
  import { stderr } from "node:process";
11
13
  import { styleText } from "node:util";
14
+ import semver from "semver";
12
15
  import { simpleGit } from "simple-git";
13
16
  import { NodeKind } from "../shared/schema/Node.js";
14
17
  import { countError, countErrors, getErrorMessageForDisplay, HTTPError, wrapErrorsIfAny, } from "../shared/utils/error.js";
@@ -28,9 +31,27 @@ import { attachGitStatusToDatabaseInMemory } from "./utils/git.js";
28
31
  import { getReferencesToInstances, isReferencedByOtherInstances, } from "./utils/references.js";
29
32
  import { checkUniqueConstraintsForAllEntities } from "./utils/unique.js";
30
33
  const debug = Debug("tsondb:class");
31
- const prepareFolders = async (dataRootPath, entities) => {
34
+ const getMetaFilePath = (dataRootPath) => join(dataRootPath, ".meta.json");
35
+ const getTSONDBVersion = async () => {
36
+ const packageJsonPath = findPackageJSON(".", import.meta.url);
37
+ if (!packageJsonPath) {
38
+ throw new Error("Could not find package.json for TSONDB");
39
+ }
40
+ const packageJson = JSON.parse(await readFile(packageJsonPath, "utf-8"));
41
+ return packageJson.version;
42
+ };
43
+ const readMetaFile = async (dataRootPath) => JSON.parse(await readFile(getMetaFilePath(dataRootPath), "utf-8"));
44
+ const writeMetaFile = async (dataRootPath, meta) => writeFile(getMetaFilePath(dataRootPath), JSON.stringify(meta, null, 2), "utf-8");
45
+ const prepareFolders = async (schemaVersion, dataRootPath, entities) => {
32
46
  await mkdir(dataRootPath, { recursive: true });
33
47
  await Promise.all(entities.map(entity => mkdir(join(dataRootPath, entity.name), { recursive: true })));
48
+ if (!(await exists(getMetaFilePath(dataRootPath)))) {
49
+ const newMetaFile = {
50
+ version: await getTSONDBVersion(),
51
+ schemaVersion,
52
+ };
53
+ await writeMetaFile(dataRootPath, newMetaFile);
54
+ }
34
55
  };
35
56
  const generateOutputs = async (schema, outputs) => {
36
57
  await Promise.all(outputs.map(output => output.run(schema)));
@@ -119,7 +140,7 @@ export class TSONDB {
119
140
  static async create(options, skipReferenceCache = false) {
120
141
  const { entities } = options.schema;
121
142
  debug("using data root path: %s", options.dataRootPath);
122
- await prepareFolders(options.dataRootPath, entities);
143
+ await prepareFolders(options.schema.version, options.dataRootPath, entities);
123
144
  debug("prepared folders");
124
145
  const { git: gitClient, root: gitRoot, status: gitStatus } = await getGit(options.dataRootPath);
125
146
  if (gitRoot) {
@@ -134,7 +155,7 @@ export class TSONDB {
134
155
  }
135
156
  const git = gitRoot ? { client: gitClient, root: gitRoot } : undefined;
136
157
  const { data, referencesToInstances } = await initData(options.dataRootPath, options.schema, options.locales ?? [], git, gitStatus, skipReferenceCache);
137
- return new TSONDB({
158
+ const db = new TSONDB({
138
159
  ...options,
139
160
  data,
140
161
  referencesToInstances,
@@ -145,6 +166,34 @@ export class TSONDB {
145
166
  ...options.validationOptions,
146
167
  },
147
168
  });
169
+ const metaFile = await readMetaFile(options.dataRootPath);
170
+ if (metaFile.schemaVersion !== options.schema.version && options.migrationStages) {
171
+ debug("migrating database from schema version %s to %s", metaFile.schemaVersion, options.schema.version);
172
+ for (const stage of options.migrationStages) {
173
+ if (semver.valid(stage.toVersion) === null ||
174
+ semver.validRange(stage.fromVersion) === null) {
175
+ throw new Error(`Invalid semver version in migration stage from ${stage.fromVersion} to ${stage.toVersion}`);
176
+ }
177
+ }
178
+ for (const stage of options.migrationStages) {
179
+ if (semver.satisfies(metaFile.schemaVersion, stage.fromVersion)) {
180
+ debug("applying migration stage from %s to %s", stage.fromVersion, stage.toVersion);
181
+ // oxlint-disable-next-line no-await-in-loop
182
+ await stage.willMigrate?.(db);
183
+ metaFile.schemaVersion = stage.toVersion;
184
+ }
185
+ else {
186
+ debug("skipping migration stage from %s to %s", stage.fromVersion, stage.toVersion);
187
+ }
188
+ }
189
+ }
190
+ else {
191
+ debug("no migration needed, schema version is up to date");
192
+ }
193
+ metaFile.version = await getTSONDBVersion();
194
+ metaFile.schemaVersion = options.schema.version;
195
+ await writeMetaFile(options.dataRootPath, metaFile);
196
+ return db;
148
197
  }
149
198
  /**
150
199
  * Generates outputs based on the provided schema and output configurations.
@@ -186,6 +235,11 @@ export class TSONDB {
186
235
  useStyling: true,
187
236
  };
188
237
  const errors = [];
238
+ const summary = [];
239
+ const debugAndAddToSummary = (message) => {
240
+ debug(message);
241
+ summary.push(message);
242
+ };
189
243
  if (!skipStructuralValidation) {
190
244
  debug("Checking structural integrity ...");
191
245
  const structureErrors = onlyEntities
@@ -209,7 +263,7 @@ export class TSONDB {
209
263
  .toSorted((a, b) => a.message.localeCompare(b.message));
210
264
  if (structureErrors.length > 0) {
211
265
  const errorCount = countErrors(structureErrors);
212
- debug(`${errorCount.toString()} structural integrity violation${errorCount === 1 ? "" : "s"} found`);
266
+ debugAndAddToSummary(`${errorCount.toString()} structural integrity violation${errorCount === 1 ? "" : "s"} found`);
213
267
  errors.push(...structureErrors);
214
268
  }
215
269
  else {
@@ -243,7 +297,7 @@ export class TSONDB {
243
297
  .toSorted((a, b) => a.message.localeCompare(b.message));
244
298
  if (referenceErrors.length > 0) {
245
299
  const errorCount = countErrors(referenceErrors);
246
- debug(`${errorCount.toString()} referential integrity violation${errorCount === 1 ? "" : "s"} found`);
300
+ debugAndAddToSummary(`${errorCount.toString()} referential integrity violation${errorCount === 1 ? "" : "s"} found`);
247
301
  errors.push(...referenceErrors);
248
302
  }
249
303
  else {
@@ -259,7 +313,7 @@ export class TSONDB {
259
313
  const uniqueConstraintResult = checkUniqueConstraintsForAllEntities(data, (changedEntities ?? onlyEntities).filter(e => isEntityDecl(e)), instanceOverviewsByEntityName);
260
314
  if (isError(uniqueConstraintResult)) {
261
315
  const errorCount = countError(uniqueConstraintResult.error);
262
- debug(`${errorCount.toString()} unique constraint violation${errorCount === 1 ? "" : "s"} found`);
316
+ debugAndAddToSummary(`${errorCount.toString()} unique constraint violation${errorCount === 1 ? "" : "s"} found`);
263
317
  errors.push(uniqueConstraintResult.error);
264
318
  }
265
319
  else {
@@ -269,7 +323,7 @@ export class TSONDB {
269
323
  const customConstraintResult = checkCustomConstraintsForAllEntities((...args) => getDisplayName(getEntity, this.#locales, data, ...args), (...args) => getDisplayNameWithId(getEntity, this.#locales, data, ...args), (...args) => getInstanceOverview(data.getInstanceContainerOfEntityById.bind(data), data.getAllChildInstanceContainersForParent.bind(data, getEntity), getEntity, this.#locales, args), getEntity, data, entities);
270
324
  if (isError(customConstraintResult)) {
271
325
  const errorCount = countError(customConstraintResult.error);
272
- debug(`${errorCount.toString()} custom constraint violation${errorCount === 1 ? "" : "s"} found`);
326
+ debugAndAddToSummary(`${errorCount.toString()} custom constraint violation${errorCount === 1 ? "" : "s"} found`);
273
327
  errors.push(customConstraintResult.error);
274
328
  }
275
329
  else {
@@ -277,7 +331,7 @@ export class TSONDB {
277
331
  }
278
332
  }
279
333
  else {
280
- debug("Skipping further checks due to previous structural integrity errors");
334
+ debugAndAddToSummary("Skipping further checks due to previous structural integrity errors");
281
335
  }
282
336
  if (logToConsole) {
283
337
  console.log(`${data.totalSize.toString()} instance${data.totalSize === 1 ? "" : "s"} checked`);
@@ -286,7 +340,14 @@ export class TSONDB {
286
340
  }
287
341
  else {
288
342
  const errorCount = countErrors(errors);
289
- console.error(styleText("red", `${errorCount.toString()} validation error${errorCount === 1 ? "" : "s"} found\n\n${errors.map(err => getErrorMessageForDisplay(err)).join("\n\n")}`, { stream: stderr }));
343
+ console.error(styleText("red", `${errors.map(err => getErrorMessageForDisplay(err)).join("\n\n")}\n`, {
344
+ stream: stderr,
345
+ }));
346
+ console.error(styleText(["red", "bold"], "Summary", { stream: stderr }));
347
+ console.error(styleText("red", [
348
+ `${errorCount.toString()} validation error${errorCount === 1 ? "" : "s"} found`,
349
+ ...summary,
350
+ ].join("\n"), { stream: stderr }));
290
351
  }
291
352
  }
292
353
  return errors;
@@ -35,6 +35,8 @@ export const reduceNodes = (reducer, nodes, options = {}) => {
35
35
  else {
36
36
  return { results: reducer(parentNodes, node, collectedResults), reducedDecls };
37
37
  }
38
+ case NodeKind.NestedEntityMapType:
39
+ return reduceNodeTree([...parentNodes, node], node.type.value, reducer(parentNodes, node, collectedResults), reducedDecls);
38
40
  case NodeKind.BooleanType:
39
41
  case NodeKind.DateType:
40
42
  case NodeKind.FloatType:
@@ -42,7 +44,6 @@ export const reduceNodes = (reducer, nodes, options = {}) => {
42
44
  case NodeKind.StringType:
43
45
  case NodeKind.TypeArgumentType:
44
46
  case NodeKind.ReferenceIdentifierType:
45
- case NodeKind.NestedEntityMapType:
46
47
  case NodeKind.TypeParameter:
47
48
  case NodeKind.TranslationObjectType:
48
49
  return { results: reducer(parentNodes, node, collectedResults), reducedDecls };
@@ -4,7 +4,8 @@ import { type EntityDecl } from "./dsl/declarations/EntityDecl.ts";
4
4
  import type { SingletonEntityDecl } from "./dsl/index.ts";
5
5
  export declare class Schema<T extends DefaultTSONDBTypes = DefaultTSONDBTypes> {
6
6
  #private;
7
- constructor(declarations: Decl[], localeEntity?: EntityDecl);
7
+ constructor(version: string, declarations: Decl[], localeEntity?: EntityDecl);
8
+ get version(): string;
8
9
  get entities(): EntityDecl<EntityName<T>>[];
9
10
  get resolvedEntities(): EntityDecl<EntityName<T>>[];
10
11
  getEntity<E extends EntityName<T>>(name: E): EntityDecl<E> | undefined;
@@ -3,6 +3,7 @@ import { Dictionary } from "@elyukai/utils/dictionary";
3
3
  import { deepEqual } from "@elyukai/utils/equality";
4
4
  import { assertExhaustive, trySafe } from "@elyukai/utils/typeSafety";
5
5
  import Debug from "debug";
6
+ import semver from "semver";
6
7
  import { NodeKind } from "../../shared/schema/Node.js";
7
8
  import { renderKeyPath } from "../../shared/schema/utils/keyPath.js";
8
9
  import { getParameterNames, resolveTypeArgumentsInDecls, walkNodeTree, } from "./dsl/declarations/Decl.js";
@@ -22,6 +23,7 @@ import { getNestedDeclarations } from "./treeOperations/implicitSchema.js";
22
23
  const debug = Debug("tsondb:schema");
23
24
  // const RESERVED_DECLARATION_IDENTIFIER = ["EntityMap", "StringableTranslationParameter"]
24
25
  export class Schema {
26
+ #version;
25
27
  #declarations;
26
28
  #entities;
27
29
  #singletonEntities;
@@ -31,7 +33,10 @@ export class Schema {
31
33
  #localeEntity;
32
34
  #declarationMap;
33
35
  #resolvedDeclarationMap;
34
- constructor(declarations, localeEntity) {
36
+ constructor(version, declarations, localeEntity) {
37
+ if (semver.valid(version) === null) {
38
+ throw new TypeError(`Invalid schema version "${version}". Must be a valid semver string.`);
39
+ }
35
40
  debug("creating schema from %d declarations", declarations.length);
36
41
  debug("collecting nested declarations ...");
37
42
  const allDecls = addDeclarations([], localeEntity ? declarations.concat(localeEntity) : declarations);
@@ -56,6 +61,7 @@ export class Schema {
56
61
  checkUniqueConstraints(allDeclsWithoutNestedEntities);
57
62
  debug("checking sort orders ...");
58
63
  checkSortOrders(allDeclsWithoutNestedEntities);
64
+ this.#version = version;
59
65
  this.#localeEntity = localeEntity;
60
66
  this.#declarations = allDeclsWithoutNestedEntities;
61
67
  this.#resolvedDeclarations = resolveTypeArgumentsInDecls(allDeclsWithoutNestedEntities);
@@ -67,6 +73,9 @@ export class Schema {
67
73
  this.#resolvedDeclarationMap = Dictionary.fromEntries(this.#resolvedDeclarations.map(decl => [decl.name, decl]));
68
74
  debug("created schema, no integrity violations found");
69
75
  }
76
+ get version() {
77
+ return this.#version;
78
+ }
70
79
  get entities() {
71
80
  return this.#entities;
72
81
  }
@@ -2468,10 +2468,10 @@ const useMappedAPIResource = (apiFn, mapFn, ...args)=>{
2468
2468
  setNewData
2469
2469
  ];
2470
2470
  };
2471
- const letterOrDigit = /[a-zA-Z0-9]/;
2472
- const uppercase = /[A-Z]/;
2473
- const lowerCaseOrDigit = /[a-z0-9]/;
2474
- const string_separator = /[^a-z0-9]/;
2471
+ const letterOrDigit = /[a-zA-Z0-9]/u;
2472
+ const uppercase = /[A-Z]/u;
2473
+ const lowerCaseOrDigit = /[a-z0-9]/u;
2474
+ const string_separator = /[^a-z0-9]/u;
2475
2475
  const lastChar = (str)=>str[str.length - 1];
2476
2476
  const lastElement = (arr)=>arr[arr.length - 1];
2477
2477
  const isAllUppercase = (str)=>str === str.toUpperCase();
@@ -3035,8 +3035,8 @@ class Parser {
3035
3035
  ];
3036
3036
  });
3037
3037
  }
3038
- static space = Parser.regex(/^\s*/);
3039
- static hspace = Parser.regex(/^[^\S\r\n]*/);
3038
+ static space = Parser.regex(/^\s*/u);
3039
+ static hspace = Parser.regex(/^[^\S\r\n]*/u);
3040
3040
  token() {
3041
3041
  return this.then((result)=>Parser.space.then(()=>Parser.of(result)));
3042
3042
  }
@@ -3054,7 +3054,7 @@ class Parser {
3054
3054
  }
3055
3055
  static lookahead(parser) {
3056
3056
  return new Parser((syntax)=>{
3057
- const result = parser.parse(syntax)[0];
3057
+ const [result] = parser.parse(syntax);
3058
3058
  return void 0 !== result ? [
3059
3059
  [
3060
3060
  result[0],
@@ -3065,7 +3065,7 @@ class Parser {
3065
3065
  }
3066
3066
  static negativeLookahead(parser) {
3067
3067
  return new Parser((syntax)=>{
3068
- const result = parser.parse(syntax)[0];
3068
+ const [result] = parser.parse(syntax);
3069
3069
  return void 0 === result ? [
3070
3070
  [
3071
3071
  void 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsondb",
3
- "version": "0.21.8",
3
+ "version": "0.21.11",
4
4
  "description": "",
5
5
  "license": "ISC",
6
6
  "author": "Lukas Obermann",
@@ -40,6 +40,7 @@
40
40
  "@types/debug": "^4.1.13",
41
41
  "@types/express": "^5.0.6",
42
42
  "@types/node": "^26.6.4",
43
+ "@types/semver": "^7.8.0",
43
44
  "@typescript/native": "npm:typescript@^7.0.2",
44
45
  "commit-and-tag-version": "^13.2.1",
45
46
  "tsx": "^4.23.15",
@@ -48,7 +49,7 @@
48
49
  "dependencies": {
49
50
  "@elyukai/markdown": "^0.4.0",
50
51
  "@elyukai/oxc-config": "^0.1.2",
51
- "@elyukai/utils": "^0.3.8",
52
+ "@elyukai/utils": "^0.3.11",
52
53
  "@preact/signals": "^2.11.3",
53
54
  "debug": "^4.4.3",
54
55
  "express": "^5.2.1",
@@ -58,6 +59,7 @@
58
59
  "oxlint-tsgolint": "^7.0.2003",
59
60
  "preact": "^11.0.0",
60
61
  "preact-iso": "^2.12.2",
62
+ "semver": "^7.8.5",
61
63
  "simple-cli-args": "^0.1.3",
62
64
  "simple-git": "^4.0.2"
63
65
  },