@lobb-js/core 0.19.0 → 0.21.0
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/package.json
CHANGED
|
@@ -58,7 +58,7 @@ export class ConfigManager {
|
|
|
58
58
|
if (!this.config.collections[fieldValue.references.collection]) {
|
|
59
59
|
throw new LobbError({
|
|
60
60
|
code: "INTERNAL_SERVER_ERROR",
|
|
61
|
-
message: `
|
|
61
|
+
message: `"${collectionName}.${fieldName}" references "${fieldValue.references.collection}" which is not defined in your collections.`,
|
|
62
62
|
});
|
|
63
63
|
}
|
|
64
64
|
if (!this.config.relations) {
|
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
import * as _ from "lodash";
|
|
2
|
-
|
|
3
|
-
import { diff } from "just-diff";
|
|
4
1
|
import { runCoreDbSetup } from "../coreDbSetup/index.ts";
|
|
5
2
|
import { MigrationsManager } from "./MigrationsManager.ts";
|
|
6
3
|
import { LobbError } from "../LobbError.ts";
|
|
7
4
|
import { Lobb } from "../Lobb.ts";
|
|
8
|
-
import type { CollectionConfig, CollectionField, CollectionIndex } from "../types/index.ts";
|
|
5
|
+
import type { CollectionConfig, CollectionField, CollectionIndex, CollectionsConfig } from "../types/index.ts";
|
|
6
|
+
import type { NormalCollectionConfig } from "../types/config/collectionsConfig.ts";
|
|
7
|
+
|
|
8
|
+
// ── Semantic diff types ────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
type SchemaDiffOp =
|
|
11
|
+
| { op: "add-collection"; collection: string; config: CollectionConfig }
|
|
12
|
+
| { op: "remove-collection"; collection: string }
|
|
13
|
+
| { op: "add-field"; collection: string; field: string; config: CollectionField }
|
|
14
|
+
| { op: "remove-field"; collection: string; field: string }
|
|
15
|
+
| { op: "alter-field"; collection: string; field: string; config: CollectionField }
|
|
16
|
+
| { op: "add-index"; collection: string; index: string; config: CollectionIndex }
|
|
17
|
+
| { op: "remove-index"; collection: string; index: string }
|
|
18
|
+
| { op: "replace-index"; collection: string; index: string; config: CollectionIndex };
|
|
19
|
+
|
|
20
|
+
// ── DatabaseSyncManager ────────────────────────────────────────────────────
|
|
9
21
|
|
|
10
22
|
export class DatabaseSyncManager {
|
|
11
23
|
private dbDriver = Lobb.instance.databaseService.getDriver();
|
|
@@ -30,149 +42,169 @@ export class DatabaseSyncManager {
|
|
|
30
42
|
await this.applyDbSchemaDiff(dbSchemaDiff, forceSync);
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
private async applyDbSchemaDiff(
|
|
34
|
-
|
|
35
|
-
forceSync: boolean,
|
|
36
|
-
) {
|
|
37
|
-
if (!dbSchemaDiff.length) return;
|
|
38
|
-
|
|
39
|
-
if (!forceSync) {
|
|
40
|
-
console.error(
|
|
41
|
-
"These are the differences between the config schema and the database schema",
|
|
42
|
-
);
|
|
43
|
-
console.error(
|
|
44
|
-
"You should add migrations to match the collection schema with the database schema",
|
|
45
|
-
);
|
|
46
|
-
console.error(dbSchemaDiff);
|
|
47
|
-
throw new LobbError({
|
|
48
|
-
code: "INTERNAL_SERVER_ERROR",
|
|
49
|
-
message:
|
|
50
|
-
"The schema of the configuration collection does not align with the actual schema of the connected database.",
|
|
51
|
-
});
|
|
52
|
-
}
|
|
45
|
+
private async applyDbSchemaDiff(ops: SchemaDiffOp[], forceSync: boolean) {
|
|
46
|
+
if (!ops.length) return;
|
|
53
47
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
48
|
+
await this.assertNoDataLoss(ops, forceSync);
|
|
49
|
+
|
|
50
|
+
// Apply in safe dependency order:
|
|
51
|
+
// 1. remove collections 2. remove indexes 3. remove fields
|
|
52
|
+
// 4. add collections 5. add/alter fields 6. add/replace indexes
|
|
53
|
+
const order: Record<SchemaDiffOp["op"], number> = {
|
|
54
|
+
"remove-collection": 0,
|
|
55
|
+
"remove-index": 1,
|
|
56
|
+
"remove-field": 2,
|
|
57
|
+
"add-collection": 3,
|
|
58
|
+
"add-field": 4,
|
|
59
|
+
"alter-field": 4,
|
|
60
|
+
"add-index": 5,
|
|
61
|
+
"replace-index": 5,
|
|
62
|
+
};
|
|
63
|
+
ops.sort((a, b) => order[a.op] - order[b.op]);
|
|
64
|
+
|
|
65
|
+
for (const op of ops) {
|
|
66
|
+
if (op.op === "add-collection") {
|
|
67
|
+
await this.dbDriver.createCollection(op.collection, op.config);
|
|
68
|
+
} else if (op.op === "remove-collection") {
|
|
69
|
+
await this.dbDriver.dropCollection(op.collection);
|
|
70
|
+
} else if (op.op === "add-field") {
|
|
71
|
+
await this.dbDriver.addField(op.collection, op.field, op.config);
|
|
72
|
+
} else if (op.op === "remove-field") {
|
|
73
|
+
await this.dbDriver.removeField(op.collection, op.field);
|
|
74
|
+
} else if (op.op === "alter-field") {
|
|
75
|
+
await this.dbDriver.alterField(op.collection, op.field, op.config);
|
|
76
|
+
} else if (op.op === "add-index") {
|
|
77
|
+
await this.dbDriver.createIndex(op.collection, op.index, op.config);
|
|
78
|
+
} else if (op.op === "remove-index") {
|
|
79
|
+
await this.dbDriver.dropIndex(op.collection, op.index);
|
|
80
|
+
} else if (op.op === "replace-index") {
|
|
81
|
+
await this.dbDriver.dropIndex(op.collection, op.index);
|
|
82
|
+
await this.dbDriver.createIndex(op.collection, op.index, op.config);
|
|
65
83
|
}
|
|
66
|
-
return true;
|
|
67
|
-
});
|
|
68
|
-
for (const path of syntheticReplaces.values()) {
|
|
69
|
-
normalizedDiff.push({ op: "replace", path, value: undefined });
|
|
70
84
|
}
|
|
85
|
+
}
|
|
71
86
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
await this.dbDriver.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const removedCollectionName = change.path[0] as string;
|
|
97
|
-
await this.dbDriver.dropCollection(removedCollectionName);
|
|
98
|
-
} else if (
|
|
99
|
-
change.path.length === 3 &&
|
|
100
|
-
change.path[1] === "fields" &&
|
|
101
|
-
change.op === "add"
|
|
102
|
-
) {
|
|
103
|
-
const collectionName = change.path[0] as string;
|
|
104
|
-
const fieldName = change.path[2] as string;
|
|
105
|
-
await this.dbDriver.addField(
|
|
106
|
-
collectionName,
|
|
107
|
-
fieldName,
|
|
108
|
-
change.value as CollectionField,
|
|
109
|
-
);
|
|
110
|
-
} else if (change.path[1] === "fields" && change.op === "remove") {
|
|
111
|
-
const collectionName = change.path[0] as string;
|
|
112
|
-
const fieldName = change.path[2] as string;
|
|
113
|
-
await this.dbDriver.removeField(collectionName, fieldName);
|
|
114
|
-
} else if (change.path[1] === "fields" && change.op === "replace") {
|
|
115
|
-
const collectionName = change.path[0] as string;
|
|
116
|
-
const fieldName = change.path[2] as string;
|
|
117
|
-
const fieldConfig = Lobb.instance.configManager.getNormalCollection(collectionName).fields[fieldName];
|
|
118
|
-
await this.dbDriver.alterField(collectionName, fieldName, fieldConfig);
|
|
119
|
-
} else if (change.path[1] === "indexes" && change.op === "add") {
|
|
120
|
-
const collectionName = change.path[0] as string;
|
|
121
|
-
const indexName = change.path[2] as string;
|
|
122
|
-
await this.dbDriver.createIndex(
|
|
123
|
-
collectionName,
|
|
124
|
-
indexName,
|
|
125
|
-
change.value as CollectionIndex,
|
|
126
|
-
);
|
|
127
|
-
} else if (change.path[1] === "indexes" && change.op === "remove") {
|
|
128
|
-
const collectionName = change.path[0] as string;
|
|
129
|
-
const indexName = change.path[2] as string;
|
|
130
|
-
await this.dbDriver.dropIndex(collectionName, indexName);
|
|
131
|
-
} else if (change.path[1] === "indexes" && change.op === "replace") {
|
|
132
|
-
const collectionName = change.path[0] as string;
|
|
133
|
-
const indexName = change.path[2] as string;
|
|
134
|
-
const collectionConfig = Lobb.instance.configManager.getNormalCollection(
|
|
135
|
-
collectionName,
|
|
136
|
-
);
|
|
137
|
-
const indexes = collectionConfig.indexes ?? {};
|
|
138
|
-
const index = indexes[indexName];
|
|
139
|
-
await this.dbDriver.dropIndex(collectionName, indexName);
|
|
140
|
-
await this.dbDriver.createIndex(
|
|
141
|
-
collectionName,
|
|
142
|
-
indexName,
|
|
143
|
-
index,
|
|
144
|
-
);
|
|
145
|
-
} else {
|
|
146
|
-
console.error(change);
|
|
147
|
-
throw new LobbError({
|
|
148
|
-
code: "INTERNAL_SERVER_ERROR",
|
|
149
|
-
message: "the database change above is not handled",
|
|
87
|
+
private async assertNoDataLoss(ops: SchemaDiffOp[], forceSync: boolean) {
|
|
88
|
+
if (forceSync) return;
|
|
89
|
+
|
|
90
|
+
const destructiveOps = ops.filter(
|
|
91
|
+
(op) => op.op === "remove-collection" || op.op === "remove-field",
|
|
92
|
+
);
|
|
93
|
+
if (destructiveOps.length === 0) return;
|
|
94
|
+
|
|
95
|
+
const blocked: string[] = [];
|
|
96
|
+
|
|
97
|
+
for (const op of destructiveOps) {
|
|
98
|
+
if (op.op === "remove-collection") {
|
|
99
|
+
const { meta } = await this.dbDriver.findAll(op.collection, { limit: 1, offset: 0, fields: "id" });
|
|
100
|
+
if (meta.totalCount > 0) {
|
|
101
|
+
blocked.push(
|
|
102
|
+
`Table "${op.collection}" has ${meta.totalCount} row(s) and cannot be dropped automatically. Write a migration to handle the data first.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
} else if (op.op === "remove-field") {
|
|
106
|
+
const { meta } = await this.dbDriver.findAll(op.collection, {
|
|
107
|
+
limit: 1,
|
|
108
|
+
offset: 0,
|
|
109
|
+
fields: "id",
|
|
110
|
+
filter: { [op.field]: { $ne: null } },
|
|
150
111
|
});
|
|
112
|
+
if (meta.totalCount > 0) {
|
|
113
|
+
blocked.push(
|
|
114
|
+
`Column "${op.collection}.${op.field}" has ${meta.totalCount} non-null row(s) and cannot be dropped automatically. Write a migration to handle the data first.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
151
117
|
}
|
|
152
118
|
}
|
|
119
|
+
|
|
120
|
+
if (blocked.length > 0) {
|
|
121
|
+
console.error("Blocked destructive schema changes detected:");
|
|
122
|
+
for (const msg of blocked) console.error(` • ${msg}`);
|
|
123
|
+
throw new LobbError({
|
|
124
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
125
|
+
message:
|
|
126
|
+
"Refusing to apply destructive schema changes on tables/columns that contain data. " +
|
|
127
|
+
"Write explicit migrations to handle the data, then re-run.",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
153
130
|
}
|
|
154
131
|
|
|
155
|
-
private async getDbDifferences(
|
|
132
|
+
private async getDbDifferences(specificCollection?: string): Promise<SchemaDiffOp[]> {
|
|
156
133
|
let dbSchema = await this.dbDriver.getDbSchema();
|
|
157
|
-
let
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
134
|
+
let configSchema = Lobb.instance.configManager.getDbSchema();
|
|
135
|
+
|
|
136
|
+
if (specificCollection) {
|
|
137
|
+
dbSchema = dbSchema[specificCollection] ? { [specificCollection]: dbSchema[specificCollection] } : {};
|
|
138
|
+
configSchema = configSchema[specificCollection] ? { [specificCollection]: configSchema[specificCollection] } : {};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return this.computeSemanticDiff(dbSchema, configSchema);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private computeSemanticDiff(dbSchema: CollectionsConfig, configSchema: CollectionsConfig): SchemaDiffOp[] {
|
|
145
|
+
const ops: SchemaDiffOp[] = [];
|
|
146
|
+
const allCollections = new Set([...Object.keys(dbSchema), ...Object.keys(configSchema)]);
|
|
147
|
+
|
|
148
|
+
for (const collection of allCollections) {
|
|
149
|
+
const inDb = dbSchema[collection];
|
|
150
|
+
const inConfig = configSchema[collection];
|
|
151
|
+
|
|
152
|
+
if (!inDb && inConfig) {
|
|
153
|
+
ops.push({ op: "add-collection", collection, config: inConfig });
|
|
154
|
+
continue;
|
|
166
155
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
156
|
+
|
|
157
|
+
if (inDb && !inConfig) {
|
|
158
|
+
ops.push({ op: "remove-collection", collection });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Collection exists in both — diff fields and indexes
|
|
163
|
+
// Skip virtual collections — they have no DB columns
|
|
164
|
+
if ("virtual" in inConfig && inConfig.virtual) continue;
|
|
165
|
+
|
|
166
|
+
const normalInDb = inDb as NormalCollectionConfig;
|
|
167
|
+
const normalInConfig = inConfig as NormalCollectionConfig;
|
|
168
|
+
const allFields = new Set([...Object.keys(normalInDb.fields), ...Object.keys(normalInConfig.fields)]);
|
|
169
|
+
for (const field of allFields) {
|
|
170
|
+
const dbField = normalInDb.fields[field];
|
|
171
|
+
const configField = normalInConfig.fields[field];
|
|
172
|
+
|
|
173
|
+
if (!dbField && configField) {
|
|
174
|
+
ops.push({ op: "add-field", collection, field, config: configField as CollectionField });
|
|
175
|
+
} else if (dbField && !configField) {
|
|
176
|
+
ops.push({ op: "remove-field", collection, field });
|
|
177
|
+
} else if (dbField && configField && !this.fieldsEqual(dbField, configField)) {
|
|
178
|
+
ops.push({ op: "alter-field", collection, field, config: configField as CollectionField });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const dbIndexes = normalInDb.indexes ?? {};
|
|
183
|
+
const configIndexes = normalInConfig.indexes ?? {};
|
|
184
|
+
const allIndexes = new Set([...Object.keys(dbIndexes), ...Object.keys(configIndexes)]);
|
|
185
|
+
for (const index of allIndexes) {
|
|
186
|
+
const dbIndex = dbIndexes[index];
|
|
187
|
+
const configIndex = configIndexes[index];
|
|
188
|
+
|
|
189
|
+
if (!dbIndex && configIndex) {
|
|
190
|
+
ops.push({ op: "add-index", collection, index, config: configIndex });
|
|
191
|
+
} else if (dbIndex && !configIndex) {
|
|
192
|
+
ops.push({ op: "remove-index", collection, index });
|
|
193
|
+
} else if (dbIndex && configIndex && !this.indexesEqual(dbIndex, configIndex)) {
|
|
194
|
+
ops.push({ op: "replace-index", collection, index, config: configIndex });
|
|
195
|
+
}
|
|
173
196
|
}
|
|
174
197
|
}
|
|
175
198
|
|
|
176
|
-
return
|
|
199
|
+
return ops;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private fieldsEqual(a: any, b: any): boolean {
|
|
203
|
+
// Compare only DB-relevant properties: type and length
|
|
204
|
+
return a.type === b.type && (a.length ?? null) === (b.length ?? null);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private indexesEqual(a: CollectionIndex, b: CollectionIndex): boolean {
|
|
208
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
177
209
|
}
|
|
178
210
|
}
|
package/src/index.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type {
|
|
|
29
29
|
CollectionField,
|
|
30
30
|
CollectionFieldBase,
|
|
31
31
|
EnumOption,
|
|
32
|
-
|
|
32
|
+
EnumColor,
|
|
33
33
|
} from "./types/config/collectionFields.ts";
|
|
34
34
|
export type { Dashboard, Extension } from "./types/Extension.ts";
|
|
35
35
|
export { Field } from "./types/Field.ts";
|
|
@@ -2,11 +2,17 @@ import { RelationCollectionFieldSchema } from "./relations.ts";
|
|
|
2
2
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
|
|
5
|
-
export type
|
|
5
|
+
export type EnumColor =
|
|
6
|
+
| "red" | "rose" | "pink" | "fuchsia" | "purple"
|
|
7
|
+
| "violet" | "indigo" | "blue" | "sky" | "cyan"
|
|
8
|
+
| "teal" | "emerald" | "green" | "lime" | "yellow"
|
|
9
|
+
| "amber" | "orange" | "slate" | "zinc" | "gray"
|
|
10
|
+
| "stone" | "neutral";
|
|
6
11
|
|
|
7
12
|
export const EnumOptionSchema = z.object({
|
|
8
|
-
value: z.string(),
|
|
9
|
-
|
|
13
|
+
value: z.union([z.string(), z.number()]),
|
|
14
|
+
color: z.enum(["red", "rose", "pink", "fuchsia", "purple", "violet", "indigo", "blue", "sky", "cyan", "teal", "emerald", "green", "lime", "yellow", "amber", "orange", "slate", "zinc", "gray", "stone", "neutral"]).optional(),
|
|
15
|
+
description: z.string().optional(),
|
|
10
16
|
});
|
|
11
17
|
|
|
12
18
|
export type EnumOption = z.infer<typeof EnumOptionSchema>;
|
|
@@ -114,7 +120,7 @@ export const CollectionIntegerFieldSchema = z.union([
|
|
|
114
120
|
CollectionFieldBaseSchema.extend({
|
|
115
121
|
type: z.literal("integer"),
|
|
116
122
|
default: z.number().int().optional(),
|
|
117
|
-
enum: z.array(z.number().int()).optional(),
|
|
123
|
+
enum: z.union([z.array(z.number().int()), z.array(EnumOptionSchema)]).optional(),
|
|
118
124
|
references: RelationCollectionFieldSchema.optional(),
|
|
119
125
|
}),
|
|
120
126
|
VirtualFieldSchema.extend({ type: z.literal("integer") }),
|