@dyrected/core 2.1.0 → 2.4.1

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/dist/index.cjs CHANGED
@@ -36,7 +36,9 @@ __export(index_exports, {
36
36
  defineGlobal: () => defineGlobal,
37
37
  generateAIPrompt: () => generateAIPrompt,
38
38
  generateFreshSetupPrompt: () => generateFreshSetupPrompt,
39
- normalizeConfig: () => normalizeConfig
39
+ normalizeConfig: () => normalizeConfig,
40
+ parseMongoWhere: () => parseMongoWhere,
41
+ parseSqlWhere: () => parseSqlWhere
40
42
  });
41
43
  module.exports = __toCommonJS(index_exports);
42
44
 
@@ -136,7 +138,10 @@ function buildConstraintsSection() {
136
138
  - RESILIENCE : If Dyrected backend is unreachable, fall back to
137
139
  initialData and show stale content \u2014 never an error page.
138
140
  All relationship fields must handle null gracefully.
139
- Every block renderer must have a default fallback case.`;
141
+ Every block renderer must have a default fallback case.
142
+ - AUTO-SEEDING : Use initialData: [...] in CollectionConfig or GlobalConfig
143
+ to automatically populate the database on first run.
144
+ This is great for demo content or default settings.`;
140
145
  }
141
146
  function buildSchemaRulesSection() {
142
147
  return `
@@ -145,8 +150,10 @@ function buildSchemaRulesSection() {
145
150
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
146
151
  - Never drop existing fields from the schema. Mark unused fields as deprecated only.
147
152
  - All new fields must have a defaultValue.
148
- - Never rename a field slug \u2014 add a new field and migrate data separately.
149
- - For Cloud deployments, run npx @dyrected/cli sync:schema after every config change. Self-hosted deployments sync automatically on startup.`;
153
+ - Use renameTo: 'oldName' to lazily migrate data when renaming fields.
154
+ - Use promoted: true for fields that need high-performance SQL indexing or unique constraints.
155
+ - For Cloud deployments, run npx @dyrected/cli sync:schema after every config change. Self-hosted deployments sync automatically on startup.
156
+ `;
150
157
  }
151
158
  function buildDoNotSection() {
152
159
  return `
@@ -193,6 +200,8 @@ FIELD OPTIONS:
193
200
  - unique \u2014 database-level uniqueness constraint
194
201
  - hasMany \u2014 allow multiple values (for relationship, select, image)
195
202
  - defaultValue \u2014 fallback value (required on all new fields added to existing schemas)
203
+ - promoted \u2014 extracts field to a real SQL column for native indexing (SQL adapters only)
204
+ - renameTo \u2014 name of the old field key to migrate data from (lazy migration)
196
205
  - admin.condition \u2014 Jexl expression string to conditionally show/hide field
197
206
  e.g. "status == \\"published\\""
198
207
  - admin.readOnly \u2014 display only, not editable
@@ -291,6 +300,7 @@ const settings = defineGlobal({
291
300
  export default defineConfig({
292
301
  collections: [media, pages],
293
302
  globals: [settings],
303
+ // Use SqliteAdapter for local, PostgresAdapter or MySqlAdapter for production
294
304
  db: new SqliteAdapter({ filename: './dyrected.db' }),
295
305
  })
296
306
  \`\`\``;
@@ -915,6 +925,162 @@ function normalizeConfig(config) {
915
925
  };
916
926
  }
917
927
 
928
+ // src/utils/parse-where.ts
929
+ function assertNever(op, context) {
930
+ throw new Error(`[dyrected/core] Unhandled where operator "${op}" in ${context}`);
931
+ }
932
+ function parseSqlWhere(where, getJsonField, placeholder = "?") {
933
+ const params = [];
934
+ let pgIndex = 1;
935
+ function next() {
936
+ return placeholder === "pg" ? `$${pgIndex++}` : "?";
937
+ }
938
+ function col(field) {
939
+ return field === "id" ? "id" : getJsonField(field);
940
+ }
941
+ function buildOperator(field, value) {
942
+ const c = col(field);
943
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
944
+ params.push(value);
945
+ return `${c} = ${next()}`;
946
+ }
947
+ const entries = Object.entries(value);
948
+ if (entries.length !== 1) {
949
+ return entries.map(([op2, operand2]) => buildSingleOp(c, op2, operand2)).join(" AND ");
950
+ }
951
+ const [op, operand] = entries[0];
952
+ return buildSingleOp(c, op, operand);
953
+ }
954
+ function buildSingleOp(c, op, operand) {
955
+ switch (op) {
956
+ case "equals":
957
+ params.push(operand);
958
+ return `${c} = ${next()}`;
959
+ case "not_equals":
960
+ params.push(operand);
961
+ return `${c} != ${next()}`;
962
+ case "in": {
963
+ const vals = Array.isArray(operand) ? operand : [operand];
964
+ if (vals.length === 0) return "1=0";
965
+ const placeholders = vals.map((v) => {
966
+ params.push(v);
967
+ return next();
968
+ });
969
+ return `${c} IN (${placeholders.join(", ")})`;
970
+ }
971
+ case "not_in": {
972
+ const vals = Array.isArray(operand) ? operand : [operand];
973
+ if (vals.length === 0) return "1=1";
974
+ const placeholders = vals.map((v) => {
975
+ params.push(v);
976
+ return next();
977
+ });
978
+ return `${c} NOT IN (${placeholders.join(", ")})`;
979
+ }
980
+ case "gt":
981
+ params.push(operand);
982
+ return `${c} > ${next()}`;
983
+ case "gte":
984
+ params.push(operand);
985
+ return `${c} >= ${next()}`;
986
+ case "lt":
987
+ params.push(operand);
988
+ return `${c} < ${next()}`;
989
+ case "lte":
990
+ params.push(operand);
991
+ return `${c} <= ${next()}`;
992
+ case "contains":
993
+ params.push(`%${operand}%`);
994
+ return `${c} LIKE ${next()}`;
995
+ case "starts_with":
996
+ params.push(`${operand}%`);
997
+ return `${c} LIKE ${next()}`;
998
+ case "exists":
999
+ return operand ? `${c} IS NOT NULL` : `${c} IS NULL`;
1000
+ default:
1001
+ return assertNever(op, "parseSqlWhere");
1002
+ }
1003
+ }
1004
+ function buildClause(w) {
1005
+ const parts = [];
1006
+ for (const [field, value] of Object.entries(w)) {
1007
+ if (field === "OR" && Array.isArray(value)) {
1008
+ const sub = value.map((v) => `(${buildClause(v)})`).join(" OR ");
1009
+ parts.push(`(${sub})`);
1010
+ } else if (field === "AND" && Array.isArray(value)) {
1011
+ const sub = value.map((v) => `(${buildClause(v)})`).join(" AND ");
1012
+ parts.push(`(${sub})`);
1013
+ } else {
1014
+ parts.push(buildOperator(field, value));
1015
+ }
1016
+ }
1017
+ return parts.length ? parts.join(" AND ") : "1=1";
1018
+ }
1019
+ const sql = buildClause(where);
1020
+ return { sql, params };
1021
+ }
1022
+ function parseMongoWhere(where) {
1023
+ function buildOperator(field, value) {
1024
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1025
+ return { [field]: { $eq: value } };
1026
+ }
1027
+ const entries = Object.entries(value);
1028
+ if (entries.length !== 1) {
1029
+ const merged = {};
1030
+ for (const [op2, operand2] of entries) {
1031
+ Object.assign(merged, buildSingleOp(field, op2, operand2)[field]);
1032
+ }
1033
+ return { [field]: merged };
1034
+ }
1035
+ const [op, operand] = entries[0];
1036
+ return buildSingleOp(field, op, operand);
1037
+ }
1038
+ function buildSingleOp(field, op, operand) {
1039
+ switch (op) {
1040
+ case "equals":
1041
+ return { [field]: { $eq: operand } };
1042
+ case "not_equals":
1043
+ return { [field]: { $ne: operand } };
1044
+ case "in":
1045
+ return { [field]: { $in: Array.isArray(operand) ? operand : [operand] } };
1046
+ case "not_in":
1047
+ return { [field]: { $nin: Array.isArray(operand) ? operand : [operand] } };
1048
+ case "gt":
1049
+ return { [field]: { $gt: operand } };
1050
+ case "gte":
1051
+ return { [field]: { $gte: operand } };
1052
+ case "lt":
1053
+ return { [field]: { $lt: operand } };
1054
+ case "lte":
1055
+ return { [field]: { $lte: operand } };
1056
+ case "contains":
1057
+ return { [field]: { $regex: operand, $options: "i" } };
1058
+ case "starts_with":
1059
+ return { [field]: { $regex: `^${operand}`, $options: "i" } };
1060
+ case "exists":
1061
+ return { [field]: { $exists: operand } };
1062
+ default:
1063
+ return assertNever(op, "parseMongoWhere");
1064
+ }
1065
+ }
1066
+ function buildClause(w) {
1067
+ const conditions = [];
1068
+ for (const [field, value] of Object.entries(w)) {
1069
+ if (field === "OR" && Array.isArray(value)) {
1070
+ conditions.push({ $or: value.map(buildClause) });
1071
+ } else if (field === "AND" && Array.isArray(value)) {
1072
+ conditions.push({ $and: value.map(buildClause) });
1073
+ } else {
1074
+ conditions.push(buildOperator(field, value));
1075
+ }
1076
+ }
1077
+ if (conditions.length === 0) return {};
1078
+ if (conditions.length === 1) return conditions[0];
1079
+ return { $and: conditions };
1080
+ }
1081
+ return buildClause(where);
1082
+ }
1083
+
918
1084
  // src/app.ts
919
1085
  var import_hono = require("hono");
920
1086
  var import_cors = require("hono/cors");
@@ -1029,7 +1195,14 @@ var DefaultsService = class {
1029
1195
  static apply(fields, data = {}) {
1030
1196
  const result = { ...data || {} };
1031
1197
  fields.forEach((field) => {
1032
- const value = result[field.name];
1198
+ let value = result[field.name];
1199
+ if ((value === void 0 || value === null) && field.renameTo) {
1200
+ const legacyValue = result[field.renameTo];
1201
+ if (legacyValue !== void 0 && legacyValue !== null) {
1202
+ value = legacyValue;
1203
+ result[field.name] = legacyValue;
1204
+ }
1205
+ }
1033
1206
  if (value === void 0 || value === null) {
1034
1207
  if (field.defaultValue !== void 0) {
1035
1208
  result[field.name] = field.defaultValue;
@@ -1108,6 +1281,19 @@ var CollectionController = class {
1108
1281
  sort,
1109
1282
  where
1110
1283
  });
1284
+ if (result.total === 0 && this.collection.initialData && !where && page === 1) {
1285
+ console.log(`[dyrected/core] Auto-seeding collection "${this.collection.slug}" from config.initialData`);
1286
+ for (const data of this.collection.initialData) {
1287
+ await db.create({ collection: this.collection.slug, data });
1288
+ }
1289
+ result = await db.find({
1290
+ collection: this.collection.slug,
1291
+ limit,
1292
+ page,
1293
+ sort,
1294
+ where
1295
+ });
1296
+ }
1111
1297
  result.docs = result.docs.map((doc) => DefaultsService.apply(this.collection.fields, doc));
1112
1298
  if (depth > 0) {
1113
1299
  const populationService = new PopulationService(db, config.collections);
@@ -1260,6 +1446,54 @@ var CollectionController = class {
1260
1446
  }
1261
1447
  return c.json({ message: "Deleted" });
1262
1448
  }
1449
+ async deleteMany(c) {
1450
+ const config = c.get("config");
1451
+ const db = config.db;
1452
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1453
+ const user = c.get("user");
1454
+ let ids = [];
1455
+ try {
1456
+ const body = await c.req.json().catch(() => null);
1457
+ if (body?.ids && Array.isArray(body.ids)) {
1458
+ ids = body.ids;
1459
+ }
1460
+ } catch {
1461
+ }
1462
+ if (!ids.length) {
1463
+ const raw = c.req.queries("ids") ?? c.req.queries("ids[]") ?? [];
1464
+ ids = raw.filter(Boolean);
1465
+ }
1466
+ if (!ids.length) return c.json({ message: "No IDs provided" }, 400);
1467
+ const deleted = [];
1468
+ const failed = [];
1469
+ for (const id of ids) {
1470
+ try {
1471
+ let before = null;
1472
+ if (this.collection.audit) {
1473
+ before = await db.findOne({ collection: this.collection.slug, id });
1474
+ }
1475
+ await db.delete({ collection: this.collection.slug, id });
1476
+ deleted.push(id);
1477
+ if (this.collection.audit) {
1478
+ AuditService.log(db, {
1479
+ operation: "delete",
1480
+ collection: this.collection.slug,
1481
+ documentId: id,
1482
+ user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
1483
+ before,
1484
+ after: null
1485
+ });
1486
+ }
1487
+ } catch (err) {
1488
+ failed.push({ id, error: err?.message ?? "Unknown error" });
1489
+ }
1490
+ }
1491
+ return c.json({
1492
+ message: `Deleted ${deleted.length} document(s)`,
1493
+ deleted,
1494
+ ...failed.length ? { failed } : {}
1495
+ });
1496
+ }
1263
1497
  async seed(c) {
1264
1498
  const config = c.get("config");
1265
1499
  const db = config.db;
@@ -1294,7 +1528,13 @@ var GlobalController = class {
1294
1528
  const db = config.db;
1295
1529
  if (!db) return c.json({ message: "Database not configured" }, 500);
1296
1530
  const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
1297
- const data = await db.getGlobal({ slug: this.global.slug });
1531
+ let data = await db.getGlobal({ slug: this.global.slug });
1532
+ const isEmpty = !data || Object.keys(data).length === 0;
1533
+ if (isEmpty && this.global.initialData) {
1534
+ console.log(`[dyrected/core] Auto-seeding global "${this.global.slug}" from config.initialData`);
1535
+ await db.updateGlobal({ slug: this.global.slug, data: this.global.initialData });
1536
+ data = this.global.initialData;
1537
+ }
1298
1538
  const dataWithDefaults = DefaultsService.apply(this.global.fields, data);
1299
1539
  if (depth > 0 && dataWithDefaults) {
1300
1540
  const populationService = new PopulationService(db, config.collections);
@@ -2509,6 +2749,7 @@ function registerRoutes(app, config) {
2509
2749
  app.get(path, accessGate(collection, "read"), (c) => controller.find(c));
2510
2750
  app.post(path, accessGate(collection, "create"), (c) => controller.create(c));
2511
2751
  app.post(`${path}/media`, accessGate(collection, "create"), (c) => controller.create(c));
2752
+ app.delete(`${path}/delete-many`, accessGate(collection, "delete"), (c) => controller.deleteMany(c));
2512
2753
  app.get(`${path}/:id`, accessGate(collection, "read"), (c) => controller.findOne(c));
2513
2754
  app.patch(`${path}/:id`, accessGate(collection, "update"), (c) => controller.update(c));
2514
2755
  app.delete(`${path}/:id`, accessGate(collection, "delete"), (c) => controller.delete(c));
@@ -2559,8 +2800,10 @@ function registerRoutes(app, config) {
2559
2800
  if (id) {
2560
2801
  if (method === "GET") return controller.findOne(c);
2561
2802
  if (method === "PATCH") return controller.update(c);
2803
+ if (method === "DELETE" && id === "delete-many") return controller.deleteMany(c);
2562
2804
  if (method === "DELETE") return controller.delete(c);
2563
2805
  if (method === "POST" && id === "media") return controller.create(c);
2806
+ if (method === "POST" && id === "seed") return controller.seed(c);
2564
2807
  } else {
2565
2808
  if (method === "GET") return controller.find(c);
2566
2809
  if (method === "POST") return controller.create(c);
@@ -2646,5 +2889,7 @@ function defineConfig(config) {
2646
2889
  defineGlobal,
2647
2890
  generateAIPrompt,
2648
2891
  generateFreshSetupPrompt,
2649
- normalizeConfig
2892
+ normalizeConfig,
2893
+ parseMongoWhere,
2894
+ parseSqlWhere
2650
2895
  });
package/dist/index.d.cts CHANGED
@@ -21,6 +21,69 @@ declare function generateAIPrompt(activeTab: "next" | "nuxt" | "react" | "vue",
21
21
  */
22
22
  declare function normalizeConfig(config: DyrectedConfig): DyrectedConfig;
23
23
 
24
+ /**
25
+ * Dyrected Where Clause DSL — shared query language across all database adapters.
26
+ *
27
+ * Adapters translate this DSL into their native format:
28
+ * - SQL adapters (SQLite, Postgres, MySQL): use parseSqlWhere()
29
+ * - MongoDB adapter: use parseMongoWhere()
30
+ * - Future adapters: implement their own translator against WhereClause
31
+ *
32
+ * Exhaustive operator handling is enforced at compile time via `assertNever`,
33
+ * so adding a new operator without handling it will produce a TypeScript error
34
+ * in every adapter translator.
35
+ */
36
+ type WhereOperatorName = 'equals' | 'not_equals' | 'in' | 'not_in' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'starts_with' | 'exists';
37
+ type WhereOperator = {
38
+ equals: any;
39
+ } | {
40
+ not_equals: any;
41
+ } | {
42
+ in: any[];
43
+ } | {
44
+ not_in: any[];
45
+ } | {
46
+ gt: any;
47
+ } | {
48
+ gte: any;
49
+ } | {
50
+ lt: any;
51
+ } | {
52
+ lte: any;
53
+ } | {
54
+ contains: string;
55
+ } | {
56
+ starts_with: string;
57
+ } | {
58
+ exists: boolean;
59
+ };
60
+ /** Top-level where clause. Fields map to operator objects or shorthand scalar values. */
61
+ type WhereClause = {
62
+ [field: string]: WhereOperator | any;
63
+ OR?: WhereClause[];
64
+ AND?: WhereClause[];
65
+ };
66
+ interface SqlWhereResult {
67
+ sql: string;
68
+ params: any[];
69
+ }
70
+ /**
71
+ * Translates a WhereClause into a parameterized SQL WHERE fragment.
72
+ *
73
+ * @param where The Dyrected where DSL object
74
+ * @param getJsonField Returns the SQL expression for a JSON-stored field (dialect-specific):
75
+ * SQLite: (f) => `json_extract(data, '$.${f}')`
76
+ * Postgres: (f) => `data->>'${f}'`
77
+ * MySQL: (f) => `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${f}'))`
78
+ * @param placeholder '?' for SQLite/MySQL, 'pg' for auto-incrementing Postgres $1/$2/…
79
+ */
80
+ declare function parseSqlWhere(where: WhereClause, getJsonField: (field: string) => string, placeholder?: '?' | 'pg'): SqlWhereResult;
81
+ /**
82
+ * Translates a WhereClause into a MongoDB filter object.
83
+ * Handles nested OR/AND via $or/$and, and all operators via $eq/$in/$gt etc.
84
+ */
85
+ declare function parseMongoWhere(where: WhereClause): Record<string, any>;
86
+
24
87
  /**
25
88
  * Define a collection configuration with full type safety.
26
89
  */
@@ -34,4 +97,4 @@ declare function defineGlobal(config: GlobalConfig): GlobalConfig;
34
97
  */
35
98
  declare function defineConfig(config: DyrectedConfig): DyrectedConfig;
36
99
 
37
- export { CollectionConfig, DyrectedConfig, GlobalConfig, type SetupPromptConfig, defineCollection, defineConfig, defineGlobal, generateAIPrompt, generateFreshSetupPrompt, normalizeConfig };
100
+ export { CollectionConfig, DyrectedConfig, GlobalConfig, type SetupPromptConfig, type SqlWhereResult, type WhereClause, type WhereOperator, type WhereOperatorName, defineCollection, defineConfig, defineGlobal, generateAIPrompt, generateFreshSetupPrompt, normalizeConfig, parseMongoWhere, parseSqlWhere };
package/dist/index.d.ts CHANGED
@@ -21,6 +21,69 @@ declare function generateAIPrompt(activeTab: "next" | "nuxt" | "react" | "vue",
21
21
  */
22
22
  declare function normalizeConfig(config: DyrectedConfig): DyrectedConfig;
23
23
 
24
+ /**
25
+ * Dyrected Where Clause DSL — shared query language across all database adapters.
26
+ *
27
+ * Adapters translate this DSL into their native format:
28
+ * - SQL adapters (SQLite, Postgres, MySQL): use parseSqlWhere()
29
+ * - MongoDB adapter: use parseMongoWhere()
30
+ * - Future adapters: implement their own translator against WhereClause
31
+ *
32
+ * Exhaustive operator handling is enforced at compile time via `assertNever`,
33
+ * so adding a new operator without handling it will produce a TypeScript error
34
+ * in every adapter translator.
35
+ */
36
+ type WhereOperatorName = 'equals' | 'not_equals' | 'in' | 'not_in' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'starts_with' | 'exists';
37
+ type WhereOperator = {
38
+ equals: any;
39
+ } | {
40
+ not_equals: any;
41
+ } | {
42
+ in: any[];
43
+ } | {
44
+ not_in: any[];
45
+ } | {
46
+ gt: any;
47
+ } | {
48
+ gte: any;
49
+ } | {
50
+ lt: any;
51
+ } | {
52
+ lte: any;
53
+ } | {
54
+ contains: string;
55
+ } | {
56
+ starts_with: string;
57
+ } | {
58
+ exists: boolean;
59
+ };
60
+ /** Top-level where clause. Fields map to operator objects or shorthand scalar values. */
61
+ type WhereClause = {
62
+ [field: string]: WhereOperator | any;
63
+ OR?: WhereClause[];
64
+ AND?: WhereClause[];
65
+ };
66
+ interface SqlWhereResult {
67
+ sql: string;
68
+ params: any[];
69
+ }
70
+ /**
71
+ * Translates a WhereClause into a parameterized SQL WHERE fragment.
72
+ *
73
+ * @param where The Dyrected where DSL object
74
+ * @param getJsonField Returns the SQL expression for a JSON-stored field (dialect-specific):
75
+ * SQLite: (f) => `json_extract(data, '$.${f}')`
76
+ * Postgres: (f) => `data->>'${f}'`
77
+ * MySQL: (f) => `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${f}'))`
78
+ * @param placeholder '?' for SQLite/MySQL, 'pg' for auto-incrementing Postgres $1/$2/…
79
+ */
80
+ declare function parseSqlWhere(where: WhereClause, getJsonField: (field: string) => string, placeholder?: '?' | 'pg'): SqlWhereResult;
81
+ /**
82
+ * Translates a WhereClause into a MongoDB filter object.
83
+ * Handles nested OR/AND via $or/$and, and all operators via $eq/$in/$gt etc.
84
+ */
85
+ declare function parseMongoWhere(where: WhereClause): Record<string, any>;
86
+
24
87
  /**
25
88
  * Define a collection configuration with full type safety.
26
89
  */
@@ -34,4 +97,4 @@ declare function defineGlobal(config: GlobalConfig): GlobalConfig;
34
97
  */
35
98
  declare function defineConfig(config: DyrectedConfig): DyrectedConfig;
36
99
 
37
- export { CollectionConfig, DyrectedConfig, GlobalConfig, type SetupPromptConfig, defineCollection, defineConfig, defineGlobal, generateAIPrompt, generateFreshSetupPrompt, normalizeConfig };
100
+ export { CollectionConfig, DyrectedConfig, GlobalConfig, type SetupPromptConfig, type SqlWhereResult, type WhereClause, type WhereOperator, type WhereOperatorName, defineCollection, defineConfig, defineGlobal, generateAIPrompt, generateFreshSetupPrompt, normalizeConfig, parseMongoWhere, parseSqlWhere };