@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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lobb-js/core",
3
3
  "license": "UNLICENSED",
4
- "version": "0.19.0",
4
+ "version": "0.21.0",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -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: `Field (${collectionName}.${fieldName}) references collection (${fieldValue.references.collection}) which does not exist`,
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
- dbSchemaDiff: Array<{ op: string; path: (string | number)[]; value: unknown }>,
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
- // just-diff may produce deep paths (length > 3) for sub-field changes within
55
- // an existing index (e.g. renaming a column the index covers). Convert those
56
- // into explicit drop+recreate (replace) ops, deduplicating by index name.
57
- const syntheticReplaces = new Map<string, (string | number)[]>();
58
- const normalizedDiff = dbSchemaDiff.filter((change) => {
59
- if (change.path[1] === "indexes" && change.path.length > 3) {
60
- const key = `${change.path[0]}:${change.path[2]}`;
61
- if (!syntheticReplaces.has(key)) {
62
- syntheticReplaces.set(key, [change.path[0], "indexes", change.path[2]]);
63
- }
64
- return false;
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
- // Apply changes in safe dependency order:
73
- // 1. Drop collections, 2. Drop indexes, 3. Remove fields,
74
- // 4. Add collections, 5. Add fields, 6. Add/replace indexes
75
- const opOrder = (change: { op: string; path: (string | number)[] }) => {
76
- if (change.path.length === 1 && change.op === "remove") return 0; // drop collection
77
- if (change.path[1] === "indexes" && change.op === "remove") return 1; // drop index
78
- if (change.path[1] === "indexes" && change.op === "replace") return 2; // replace index (drop+create)
79
- if (change.path[1] === "fields" && change.op === "remove") return 3; // remove field
80
- if (change.path.length === 1 && change.op === "add") return 4; // add collection
81
- if (change.path[1] === "fields" && change.op === "add") return 5; // add field
82
- if (change.path[1] === "indexes" && change.op === "add") return 6; // add index
83
- return 7;
84
- };
85
- normalizedDiff.sort((a, b) => opOrder(a) - opOrder(b));
86
-
87
- for (let index = 0; index < normalizedDiff.length; index++) {
88
- const change = normalizedDiff[index];
89
- if (change.path.length === 1 && change.op === "add") {
90
- const addedCollectionName = change.path[0] as string;
91
- await this.dbDriver.createCollection(
92
- addedCollectionName,
93
- change.value as CollectionConfig,
94
- );
95
- } else if (change.path.length === 1 && change.op === "remove") {
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(specificColleciton?: string) {
132
+ private async getDbDifferences(specificCollection?: string): Promise<SchemaDiffOp[]> {
156
133
  let dbSchema = await this.dbDriver.getDbSchema();
157
- let configDbSchema = Lobb.instance.configManager.getDbSchema();
158
-
159
- if (specificColleciton) {
160
- if (dbSchema[specificColleciton]) {
161
- dbSchema = {
162
- [specificColleciton]: dbSchema[specificColleciton],
163
- };
164
- } else {
165
- dbSchema = {};
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
- if (configDbSchema[specificColleciton]) {
168
- configDbSchema = {
169
- [specificColleciton]: configDbSchema[specificColleciton],
170
- };
171
- } else {
172
- configDbSchema = {};
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 diff(dbSchema, configDbSchema);
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
- EnumLevel,
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 EnumLevel = "success" | "warning" | "danger" | "info" | "neutral" | "muted";
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
- level: z.enum(["success", "warning", "danger", "info", "neutral", "muted"]),
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") }),