@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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lobb-js/core",
3
3
  "license": "UNLICENSED",
4
- "version": "0.20.0",
4
+ "version": "0.22.0",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -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
- dbSchemaDiff: Array<{ op: string; path: (string | number)[]; value: unknown }>,
35
- forceSync: boolean,
36
- ) {
37
- if (!dbSchemaDiff.length) return;
38
-
39
- // just-diff may produce deep paths (length > 3) for sub-field changes within
40
- // an existing index (e.g. renaming a column the index covers). Convert those
41
- // into explicit drop+recreate (replace) ops, deduplicating by index name.
42
- const syntheticReplaces = new Map<string, (string | number)[]>();
43
- const normalizedDiff = dbSchemaDiff.filter((change) => {
44
- if (change.path[1] === "indexes" && change.path.length > 3) {
45
- const key = `${change.path[0]}:${change.path[2]}`;
46
- if (!syntheticReplaces.has(key)) {
47
- syntheticReplaces.set(key, [change.path[0], "indexes", change.path[2]]);
48
- }
49
- return false;
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
- normalizedDiff.sort((a, b) => opOrder(a) - opOrder(b));
71
-
72
- await this.assertNoDataLoss(normalizedDiff, forceSync);
73
-
74
- for (let index = 0; index < normalizedDiff.length; index++) {
75
- const change = normalizedDiff[index];
76
- if (change.path.length === 1 && change.op === "add") {
77
- const addedCollectionName = change.path[0] as string;
78
- await this.dbDriver.createCollection(
79
- addedCollectionName,
80
- change.value as CollectionConfig,
81
- );
82
- } else if (change.path.length === 1 && change.op === "remove") {
83
- const removedCollectionName = change.path[0] as string;
84
- await this.dbDriver.dropCollection(removedCollectionName);
85
- } else if (
86
- change.path.length === 3 &&
87
- change.path[1] === "fields" &&
88
- change.op === "add"
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 change of destructiveOps) {
158
- const collectionName = change.path[0] as string;
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 "${collectionName}" has ${meta.totalCount} row(s) and cannot be dropped automatically. Write a migration to handle the data first.`,
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 (change.path[1] === "fields" && change.op === "remove") {
168
- const fieldName = change.path[2] as string;
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: { [fieldName]: { $ne: null } },
110
+ filter: { [op.field]: { $ne: null } },
174
111
  });
175
112
  if (meta.totalCount > 0) {
176
113
  blocked.push(
177
- `Column "${collectionName}.${fieldName}" has ${meta.totalCount} non-null row(s) and cannot be dropped automatically. Write a migration to handle the data first.`,
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(specificColleciton?: string) {
132
+ private async getDbDifferences(specificCollection?: string): Promise<SchemaDiffOp[]> {
196
133
  let dbSchema = await this.dbDriver.getDbSchema();
197
- let configDbSchema = Lobb.instance.configManager.getDbSchema();
198
-
199
- if (specificColleciton) {
200
- if (dbSchema[specificColleciton]) {
201
- dbSchema = {
202
- [specificColleciton]: dbSchema[specificColleciton],
203
- };
204
- } else {
205
- 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;
206
155
  }
207
- if (configDbSchema[specificColleciton]) {
208
- configDbSchema = {
209
- [specificColleciton]: configDbSchema[specificColleciton],
210
- };
211
- } else {
212
- 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
+ }
213
196
  }
214
197
  }
215
198
 
216
- 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);
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
- 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") }),