@lobb-js/core 0.20.0 → 0.22.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
|
@@ -9,6 +9,7 @@ import { ZodError } from "zod";
|
|
|
9
9
|
import { LobbError } from "../../LobbError.ts";
|
|
10
10
|
import { Lobb } from "../../Lobb.ts";
|
|
11
11
|
import { beginTransaction } from "./transactions.ts";
|
|
12
|
+
import { validateFilterFields } from "./utils.ts";
|
|
12
13
|
|
|
13
14
|
export interface ExposedServiceOutput {
|
|
14
15
|
data: any;
|
|
@@ -70,6 +71,10 @@ export class CollectionStore {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
if (params?.filter) {
|
|
74
|
+
if (triggeredBy === "API") {
|
|
75
|
+
validateFilterFields(params.filter, collectionName);
|
|
76
|
+
}
|
|
77
|
+
|
|
73
78
|
params.filter = {
|
|
74
79
|
$and: [params.filter],
|
|
75
80
|
};
|
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import { Lobb } from "../../Lobb.ts";
|
|
2
|
+
import { LobbError } from "../../LobbError.ts";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
|
|
5
|
+
export function validateFilterFields(filter: any, collectionName: string) {
|
|
6
|
+
const collectionFields = Lobb.instance.configManager.getNormalCollection(collectionName)?.fields;
|
|
7
|
+
if (!collectionFields) return;
|
|
8
|
+
|
|
9
|
+
const checkObject = (obj: any) => {
|
|
10
|
+
for (const key of Object.keys(obj)) {
|
|
11
|
+
if (key === "$and" || key === "$or") {
|
|
12
|
+
for (const sub of obj[key]) checkObject(sub);
|
|
13
|
+
} else if (!key.startsWith("$")) {
|
|
14
|
+
if (!collectionFields[key]) {
|
|
15
|
+
throw new LobbError({
|
|
16
|
+
code: "BAD_REQUEST",
|
|
17
|
+
message: `Invalid filter: field "${key}" does not exist in collection "${collectionName}"`,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
checkObject(filter);
|
|
25
|
+
}
|
|
26
|
+
|
|
4
27
|
export function getCollectionDocumentSchema(
|
|
5
28
|
collectionName?: string,
|
|
6
29
|
) {
|
|
@@ -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,151 +42,76 @@ export class DatabaseSyncManager {
|
|
|
30
42
|
await this.applyDbSchemaDiff(dbSchemaDiff, forceSync);
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
private async applyDbSchemaDiff(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
return true;
|
|
52
|
-
});
|
|
53
|
-
for (const path of syntheticReplaces.values()) {
|
|
54
|
-
normalizedDiff.push({ op: "replace", path, value: undefined });
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Apply changes in safe dependency order:
|
|
58
|
-
// 1. Drop collections, 2. Drop indexes, 3. Remove fields,
|
|
59
|
-
// 4. Add collections, 5. Add fields, 6. Add/replace indexes
|
|
60
|
-
const opOrder = (change: { op: string; path: (string | number)[] }) => {
|
|
61
|
-
if (change.path.length === 1 && change.op === "remove") return 0; // drop collection
|
|
62
|
-
if (change.path[1] === "indexes" && change.op === "remove") return 1; // drop index
|
|
63
|
-
if (change.path[1] === "indexes" && change.op === "replace") return 2; // replace index (drop+create)
|
|
64
|
-
if (change.path[1] === "fields" && change.op === "remove") return 3; // remove field
|
|
65
|
-
if (change.path.length === 1 && change.op === "add") return 4; // add collection
|
|
66
|
-
if (change.path[1] === "fields" && change.op === "add") return 5; // add field
|
|
67
|
-
if (change.path[1] === "indexes" && change.op === "add") return 6; // add index
|
|
68
|
-
return 7;
|
|
45
|
+
private async applyDbSchemaDiff(ops: SchemaDiffOp[], forceSync: boolean) {
|
|
46
|
+
if (!ops.length) return;
|
|
47
|
+
|
|
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,
|
|
69
62
|
};
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
await this.dbDriver.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
await this.dbDriver.
|
|
85
|
-
} else if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const collectionName = change.path[0] as string;
|
|
91
|
-
const fieldName = change.path[2] as string;
|
|
92
|
-
await this.dbDriver.addField(
|
|
93
|
-
collectionName,
|
|
94
|
-
fieldName,
|
|
95
|
-
change.value as CollectionField,
|
|
96
|
-
);
|
|
97
|
-
} else if (change.path[1] === "fields" && change.op === "remove") {
|
|
98
|
-
const collectionName = change.path[0] as string;
|
|
99
|
-
const fieldName = change.path[2] as string;
|
|
100
|
-
await this.dbDriver.removeField(collectionName, fieldName);
|
|
101
|
-
} else if (change.path[1] === "fields" && change.op === "replace") {
|
|
102
|
-
const collectionName = change.path[0] as string;
|
|
103
|
-
const fieldName = change.path[2] as string;
|
|
104
|
-
const fieldConfig = Lobb.instance.configManager.getNormalCollection(collectionName).fields[fieldName];
|
|
105
|
-
await this.dbDriver.alterField(collectionName, fieldName, fieldConfig);
|
|
106
|
-
} else if (change.path[1] === "indexes" && change.op === "add") {
|
|
107
|
-
const collectionName = change.path[0] as string;
|
|
108
|
-
const indexName = change.path[2] as string;
|
|
109
|
-
await this.dbDriver.createIndex(
|
|
110
|
-
collectionName,
|
|
111
|
-
indexName,
|
|
112
|
-
change.value as CollectionIndex,
|
|
113
|
-
);
|
|
114
|
-
} else if (change.path[1] === "indexes" && change.op === "remove") {
|
|
115
|
-
const collectionName = change.path[0] as string;
|
|
116
|
-
const indexName = change.path[2] as string;
|
|
117
|
-
await this.dbDriver.dropIndex(collectionName, indexName);
|
|
118
|
-
} else if (change.path[1] === "indexes" && change.op === "replace") {
|
|
119
|
-
const collectionName = change.path[0] as string;
|
|
120
|
-
const indexName = change.path[2] as string;
|
|
121
|
-
const collectionConfig = Lobb.instance.configManager.getNormalCollection(
|
|
122
|
-
collectionName,
|
|
123
|
-
);
|
|
124
|
-
const indexes = collectionConfig.indexes ?? {};
|
|
125
|
-
const index = indexes[indexName];
|
|
126
|
-
await this.dbDriver.dropIndex(collectionName, indexName);
|
|
127
|
-
await this.dbDriver.createIndex(
|
|
128
|
-
collectionName,
|
|
129
|
-
indexName,
|
|
130
|
-
index,
|
|
131
|
-
);
|
|
132
|
-
} else {
|
|
133
|
-
console.error(change);
|
|
134
|
-
throw new LobbError({
|
|
135
|
-
code: "INTERNAL_SERVER_ERROR",
|
|
136
|
-
message: "the database change above is not handled",
|
|
137
|
-
});
|
|
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);
|
|
138
83
|
}
|
|
139
84
|
}
|
|
140
85
|
}
|
|
141
86
|
|
|
142
|
-
private async assertNoDataLoss(
|
|
143
|
-
diff: Array<{ op: string; path: (string | number)[]; value: unknown }>,
|
|
144
|
-
forceSync: boolean,
|
|
145
|
-
) {
|
|
87
|
+
private async assertNoDataLoss(ops: SchemaDiffOp[], forceSync: boolean) {
|
|
146
88
|
if (forceSync) return;
|
|
147
|
-
const destructiveOps = diff.filter(
|
|
148
|
-
(change) =>
|
|
149
|
-
(change.path.length === 1 && change.op === "remove") ||
|
|
150
|
-
(change.path[1] === "fields" && change.op === "remove"),
|
|
151
|
-
);
|
|
152
89
|
|
|
90
|
+
const destructiveOps = ops.filter(
|
|
91
|
+
(op) => op.op === "remove-collection" || op.op === "remove-field",
|
|
92
|
+
);
|
|
153
93
|
if (destructiveOps.length === 0) return;
|
|
154
94
|
|
|
155
95
|
const blocked: string[] = [];
|
|
156
96
|
|
|
157
|
-
for (const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (change.path.length === 1 && change.op === "remove") {
|
|
161
|
-
const { meta } = await this.dbDriver.findAll(collectionName, { limit: 1, offset: 0, fields: "id" });
|
|
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" });
|
|
162
100
|
if (meta.totalCount > 0) {
|
|
163
101
|
blocked.push(
|
|
164
|
-
`Table "${
|
|
102
|
+
`Table "${op.collection}" has ${meta.totalCount} row(s) and cannot be dropped automatically. Write a migration to handle the data first.`,
|
|
165
103
|
);
|
|
166
104
|
}
|
|
167
|
-
} else if (
|
|
168
|
-
const
|
|
169
|
-
const { meta } = await this.dbDriver.findAll(collectionName, {
|
|
105
|
+
} else if (op.op === "remove-field") {
|
|
106
|
+
const { meta } = await this.dbDriver.findAll(op.collection, {
|
|
170
107
|
limit: 1,
|
|
171
108
|
offset: 0,
|
|
172
109
|
fields: "id",
|
|
173
|
-
filter: { [
|
|
110
|
+
filter: { [op.field]: { $ne: null } },
|
|
174
111
|
});
|
|
175
112
|
if (meta.totalCount > 0) {
|
|
176
113
|
blocked.push(
|
|
177
|
-
`Column "${
|
|
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.`,
|
|
178
115
|
);
|
|
179
116
|
}
|
|
180
117
|
}
|
|
@@ -192,27 +129,82 @@ export class DatabaseSyncManager {
|
|
|
192
129
|
}
|
|
193
130
|
}
|
|
194
131
|
|
|
195
|
-
private async getDbDifferences(
|
|
132
|
+
private async getDbDifferences(specificCollection?: string): Promise<SchemaDiffOp[]> {
|
|
196
133
|
let dbSchema = await this.dbDriver.getDbSchema();
|
|
197
|
-
let
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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;
|
|
206
155
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
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
|
+
}
|
|
213
196
|
}
|
|
214
197
|
}
|
|
215
198
|
|
|
216
|
-
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);
|
|
217
209
|
}
|
|
218
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") }),
|