@saltcorn/copilot 0.7.5 → 0.8.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.
@@ -5,7 +5,7 @@ const Table = require("@saltcorn/data/models/table");
5
5
  const Field = require("@saltcorn/data/models/field");
6
6
  const { apply, removeAllWhiteSpace } = require("@saltcorn/data/utils");
7
7
  const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
8
- const { a, pre, script, div, code } = require("@saltcorn/markup/tags");
8
+ const { a, script, div, domReady } = require("@saltcorn/markup/tags");
9
9
  const { fieldProperties, getPromptFromTemplate } = require("../common");
10
10
 
11
11
  class GenerateJsAction {
@@ -59,12 +59,49 @@ class GenerateJsAction {
59
59
  when_trigger,
60
60
  trigger_table,
61
61
  }) {
62
+ const lineCount = (action_javascript_code.match(/\n/g) || []).length + 1;
63
+ const height = Math.min(Math.max(lineCount * 20, 80), 300);
64
+ const editorId = `monaco-js-${Math.random().toString(36).slice(2)}`;
62
65
  return (
63
- div({class: "mb-3"},
64
- `${action_name}${when_trigger ? `: ${when_trigger}` : ""}${
66
+ div(
67
+ { class: "mb-3" },
68
+ `<strong>${action_name}</strong>${when_trigger ? `: ${when_trigger}` : ""}${
65
69
  trigger_table ? ` on ${trigger_table}` : ""
66
- }`
67
- ) + pre(code(action_javascript_code))
70
+ }`,
71
+ ) +
72
+ div({
73
+ id: editorId,
74
+ style: `height: ${height}px; border: 1px solid #ccc;`,
75
+ }) +
76
+ // div(
77
+ // { class: `mt-3 ${action_description ? "" : "d-none"}` },
78
+ // action_description || "",
79
+ // ) +
80
+ script(
81
+ domReady(`
82
+ (function() {
83
+ var container = document.getElementById('${editorId}');
84
+ if (!container) return;
85
+ var code = ${JSON.stringify(action_javascript_code)};
86
+ function createEditor() {
87
+ monaco.editor.create(container, {
88
+ value: code,
89
+ language: 'javascript',
90
+ theme: typeof _sc_lightmode !== 'undefined' && _sc_lightmode === 'dark' ? 'vs-dark' : 'vs',
91
+ readOnly: true,
92
+ minimap: { enabled: false },
93
+ scrollBeyondLastLine: false,
94
+ automaticLayout: true,
95
+ });
96
+ }
97
+ if (typeof enable_monaco === 'function') {
98
+ enable_monaco([container], createEditor);
99
+ } else if (typeof monaco !== 'undefined') {
100
+ createEditor();
101
+ }
102
+ })();
103
+ `),
104
+ )
68
105
  );
69
106
  }
70
107
  static async execute(
@@ -85,6 +122,7 @@ class GenerateJsAction {
85
122
  }
86
123
  const trigger = await Trigger.create({
87
124
  name: action_name,
125
+ description: action_description,
88
126
  when_trigger: when_trigger || "Never",
89
127
  table_id,
90
128
  action: "run_js_code",
@@ -1,4 +1,5 @@
1
1
  const { getState } = require("@saltcorn/data/db/state");
2
+ const db = require("@saltcorn/data/db");
2
3
  const WorkflowStep = require("@saltcorn/data/models/workflow_step");
3
4
  const Trigger = require("@saltcorn/data/models/trigger");
4
5
  const Table = require("@saltcorn/data/models/table");
@@ -11,9 +12,9 @@ const { fieldProperties } = require("../common");
11
12
  class GenerateTables {
12
13
  static title = "Generate Tables";
13
14
  static function_name = "generate_tables";
14
- static description = "Generate database tables";
15
+ static description = "Generate or update database tables";
15
16
 
16
- static json_schema() {
17
+ static field_type_config_schema() {
17
18
  const types = Object.values(getState().types);
18
19
  const fieldTypeCfg = types.map((ty) => {
19
20
  const properties = {
@@ -55,6 +56,92 @@ class GenerateTables {
55
56
  data_type: { type: "string", enum: ["File"] },
56
57
  },
57
58
  });
59
+ return fieldTypeCfg;
60
+ }
61
+
62
+ static field_item_schema() {
63
+ const fieldTypeCfg = this.field_type_config_schema();
64
+ return {
65
+ type: "object",
66
+ required: ["name", "label", "type_and_configuration", "importance"],
67
+ properties: {
68
+ name: {
69
+ type: "string",
70
+ description:
71
+ "The field name. Must be a valid identifier in both SQL and JavaScript, all lower case, snake_case (underscore instead of spaces)",
72
+ },
73
+ label: {
74
+ type: "string",
75
+ description:
76
+ "A human-readable label for the field. Should be short, 1-4 words, can have spaces and mixed case.",
77
+ },
78
+ not_null: {
79
+ type: "boolean",
80
+ description:
81
+ "A value is required and the field will be NOT NULL in the database",
82
+ },
83
+ unique: {
84
+ type: "boolean",
85
+ description:
86
+ "The value is unique - different rows must have different values for this field",
87
+ },
88
+ type_and_configuration: { anyOf: fieldTypeCfg },
89
+ importance: {
90
+ type: "number",
91
+ description:
92
+ "How important is this field if only some fields can be displayed to the user. From 1 (least important) to 10 (most important).",
93
+ },
94
+ calculated: {
95
+ type: "boolean",
96
+ description:
97
+ "Whether this is a calculated field. Calculated fields derive their value from a JavaScript expression rather than being entered directly. Set to true to make this a calculated field, then provide expression. Use stored=false for virtual fields computed on-the-fly, or stored=true for materialized fields persisted in the database. Do NOT use calculated=true for child-table aggregations (counts, sums) — use aggregation=true instead.",
98
+ },
99
+ stored: {
100
+ type: "boolean",
101
+ description:
102
+ "For calculated fields only: true means the value is stored/materialized in the database; false (default) means computed on-the-fly. Stored calculated fields support joinfield syntax in expressions. Only set when calculated=true.",
103
+ },
104
+ expression: {
105
+ type: "string",
106
+ description:
107
+ "For calculated fields: a JavaScript expression returning the field value. References other fields in the same row by name (e.g. 'price * quantity'). For stored calculated fields, use joinfield syntax to access related-table fields: 'foreignKeyField.targetField' (e.g. 'author_id.full_name') or two-level: 'fkField.throughFkField.deepField'. Only set when calculated=true. Do NOT use this for child-table aggregations — use aggregation=true instead.",
108
+ },
109
+ aggregation: {
110
+ type: "boolean",
111
+ description:
112
+ "Set to true to create a stored aggregation field that counts or sums child-table records. Use this for any field that counts or aggregates rows from a child table (e.g. counting packing items, summing quantities). When aggregation=true, also set aggregate_function, child_table, child_fk_field, aggregate_field, and optionally aggregate_where. Do not set calculated or expression when using aggregation.",
113
+ },
114
+ aggregate_function: {
115
+ type: "string",
116
+ enum: ["Count", "Sum", "Avg", "CountUnique", "Max", "Min"],
117
+ description:
118
+ "Aggregation function to apply. Use 'Count' to count rows, 'Sum' to sum a numeric field. Only set when aggregation=true.",
119
+ },
120
+ child_table: {
121
+ type: "string",
122
+ description:
123
+ "Name of the child table whose rows are aggregated. Only set when aggregation=true.",
124
+ },
125
+ child_fk_field: {
126
+ type: "string",
127
+ description:
128
+ "Name of the foreign-key field on the child table that references this (parent) table. Only set when aggregation=true.",
129
+ },
130
+ aggregate_field: {
131
+ type: "string",
132
+ description:
133
+ "Field on the child table to aggregate. For Count use 'id'. For Sum/Avg/Max/Min use the numeric field name. Only set when aggregation=true.",
134
+ },
135
+ aggregate_where: {
136
+ type: "string",
137
+ description:
138
+ "Optional JavaScript expression to filter which child rows are included, evaluated in the context of a child row. Examples: 'completed' (boolean field is true), 'status === \"done\"'. Only set when aggregation=true and a subset of child rows is needed.",
139
+ },
140
+ },
141
+ };
142
+ }
143
+
144
+ static json_schema() {
58
145
  return {
59
146
  type: "object",
60
147
  required: ["tables"],
@@ -71,43 +158,7 @@ class GenerateTables {
71
158
  },
72
159
  fields: {
73
160
  type: "array",
74
- items: {
75
- type: "object",
76
- required: [
77
- "name",
78
- "label",
79
- "type_and_configuration",
80
- "importance",
81
- ],
82
- properties: {
83
- name: {
84
- type: "string",
85
- description:
86
- "The field name. Must be a valid identifier in both SQL and JavaScript, all lower case, snake_case (underscore instead of spaces)",
87
- },
88
- label: {
89
- type: "string",
90
- description:
91
- "A human-readable label for the field. Should be short, 1-4 words, can have spaces and mixed case.",
92
- },
93
- not_null: {
94
- type: "boolean",
95
- description:
96
- "A value is required and the field will be NOT NULL in the database",
97
- },
98
- unique: {
99
- type: "boolean",
100
- description:
101
- "The value is unique - different rows must have different values for this field",
102
- },
103
- type_and_configuration: { anyOf: fieldTypeCfg },
104
- importance: {
105
- type: "number",
106
- description:
107
- "How important is this field if only some fields can be displayed to the user. From 1 (least important) to 10 (most important).",
108
- },
109
- },
110
- },
161
+ items: this.field_item_schema(),
111
162
  },
112
163
  },
113
164
  },
@@ -120,42 +171,100 @@ class GenerateTables {
120
171
  const tableLines = [];
121
172
  const tables = await Table.find({});
122
173
  tables.forEach((table) => {
123
- const fieldLines = table.fields.map(
124
- (f) =>
125
- ` * ${f.name} with type: ${f.pretty_type.replace(
126
- "Key to",
127
- "ForeignKey referencing"
128
- )}.${f.description ? ` ${f.description}` : ""}`
129
- );
174
+ const fieldLines = table.fields.map((f) => {
175
+ const virtualTag =
176
+ f.calculated && !f.stored ? " (virtual, read-only)" : "";
177
+ return ` * ${f.name} with type: ${f.pretty_type.replace(
178
+ "Key to",
179
+ "ForeignKey referencing"
180
+ )}${virtualTag}.${f.description ? ` ${f.description}` : ""}`;
181
+ });
130
182
  tableLines.push(
131
183
  `${table.name}${
132
184
  table.description ? `: ${table.description}.` : "."
133
185
  } Contains the following fields:\n${fieldLines.join("\n")}`
134
186
  );
135
187
  });
136
- return `Use the generate_tables tool to construct one or more database tables.
188
+ return `Use the generate_tables tool to create new database tables or to add/update fields on existing ones.
137
189
 
138
- Do not call this tool more than once. It should only be called once. If you are
139
- building more than one table, use one call to the generate_tables tool to build all the
140
- tables.
190
+ Do not call generate_tables more than once. Use a single call even when working with multiple
191
+ tables. Include all tables new and existing in that one call.
141
192
 
142
193
  The argument to generate_tables is an array of tables, each with an array of fields. You do not
143
- need to specify a primary key, a primary key called id with autoincrementing integers is
144
- autmatically generated.
194
+ need to specify a primary key; a primary key called id with auto-incrementing integers is
195
+ automatically generated.
196
+
197
+ ## New vs existing tables
198
+
199
+ If a table does not yet exist it will be created with all the specified fields.
200
+
201
+ If a table already exists, include it in the generate_tables call anyway with the fields you
202
+ want to add or update. The system will automatically add new fields and update the settings of
203
+ existing fields — it will not recreate or drop the table.
204
+
205
+ If a user requests creating a table with certain fields and the table already exists, automatically add any missing fields to that table. Do not ask the user for confirmation or prompt them again—just proceed with the table update.
206
+
207
+ If a table has a ForeignKey field that references another table which does not yet exist in the
208
+ database, include that referenced table in the same generate_tables call. Infer reasonable
209
+ fields for it from context.
145
210
 
146
- Only include brand-new tables in the generate_tables arguments. If the user references a table
147
- that already exists, explain that generate_tables can only add new tables (not modify existing
148
- ones) and omit those tables from the tool call.
211
+ ## Calculated fields
149
212
 
150
- The database already contains the following tables:
213
+ Use calculated fields when the value should be derived from an expression rather than entered
214
+ directly. Set calculated=true and provide an expression (a JavaScript expression evaluated in
215
+ the context of the row — field names are available as variables).
216
+
217
+ Examples: 'price * quantity', 'first_name + " " + last_name', 'year - birth_year'
218
+
219
+ Choose between stored and non-stored:
220
+ - stored=false (default): value computed on-the-fly; no database column created.
221
+ Good for simple derivations from fields in the same table.
222
+ - stored=true: value persisted in the database and updated on writes. Required if you need to
223
+ sort/filter by the calculated value or if the expression references joined (related) tables.
224
+
225
+ For stored calculated fields, joinfield syntax lets expressions reference related-table fields:
226
+ - Single join: 'foreignKeyField.targetField' (e.g. 'author_id.full_name')
227
+ - Two-level join: 'fkField.throughFkField.deepField' (e.g. 'order_id.customer_id.country')
228
+
229
+ ## Aggregation fields
230
+
231
+ For fields that count or aggregate rows from a child table (e.g. counting related records),
232
+ use aggregation=true instead of calculated=true. Aggregation fields are always stored.
233
+
234
+ Required properties when aggregation=true:
235
+ - aggregate_function: 'Count', 'Sum', 'Avg', 'CountUnique', 'Max', or 'Min'
236
+ - child_table: name of the child table
237
+ - child_fk_field: name of the FK field on the child table pointing back to this table
238
+ - aggregate_field: field on the child table to aggregate ('id' for Count)
239
+ - aggregate_where: (optional) JS expression to filter child rows, e.g. 'completed' to count
240
+ only rows where completed=true
241
+
242
+ Example — count completed packing items for a trip:
243
+ { name: 'packed_count', label: 'Packed Count', type_and_configuration: {data_type: 'Integer'},
244
+ aggregation: true, aggregate_function: 'Count', child_table: 'packing_items',
245
+ child_fk_field: 'trip_id', aggregate_field: 'id', aggregate_where: 'completed' }
246
+
247
+ Do NOT use calculated=true with a hand-written expression for child-table counts — the
248
+ expression has no access to child rows and will always return 0.
249
+
250
+ The type_and_configuration.data_type for a calculated field should reflect the return type of
251
+ the expression (e.g. Integer, Float, String, Bool).
252
+
253
+ ## Existing tables
254
+
255
+ The database already contains the following tables:
151
256
 
152
257
  ${tableLines.join("\n\n")}
153
258
 
154
259
  `;
155
260
  }
156
- static render_html({ tables }) {
261
+
262
+ static render_html({ tables }, delay) {
157
263
  const sctables = this.process_tables(tables);
158
264
  const mmdia = buildMermaidMarkup(sctables);
265
+ if (delay === true) {
266
+ return pre({ class: "mermaid", "mm-src": mmdia });
267
+ }
159
268
  return (
160
269
  pre({ class: "mermaid" }, mmdia) +
161
270
  script(
@@ -164,8 +273,8 @@ class GenerateTables {
164
273
  mermaid.initialize({ startOnLoad: false });
165
274
  mermaid.run({ querySelector: ".mermaid" });
166
275
  });
167
- `),
168
- )
276
+ `)
277
+ )
169
278
  );
170
279
  }
171
280
 
@@ -184,49 +293,125 @@ class GenerateTables {
184
293
  });
185
294
  }
186
295
 
296
+ static async execute_add_or_update_fields({ table_name, fields }, req) {
297
+ const table = Table.findOne({ name: table_name });
298
+ if (!table) throw new Error(`Table "${table_name}" not found`);
299
+
300
+ const existingFieldMap = new Map();
301
+ (table.fields || []).forEach((f) => {
302
+ if (f?.name) existingFieldMap.set(f.name.toLowerCase(), f);
303
+ });
304
+
305
+ const sanitized = Array.isArray(fields)
306
+ ? fields.filter((f) => (f?.name || "").toLowerCase() !== "id")
307
+ : [];
308
+
309
+ const added = [];
310
+ const updated = [];
311
+
312
+ for (const f of sanitized) {
313
+ const fname = (f?.name || "").toLowerCase();
314
+ if (!fname) continue;
315
+ const processed = this.process_field(f, []);
316
+ const existing = existingFieldMap.get(fname);
317
+ if (!existing) {
318
+ processed.table = table;
319
+ await Field.create(processed);
320
+ added.push(f.name);
321
+ } else {
322
+ // Only update non-structural properties; skip if type would change
323
+ const existingType = existing.type?.name ?? existing.type;
324
+ if (existingType === processed.type) {
325
+ const fieldUpdates = {
326
+ label: f.label,
327
+ attributes: processed.attributes,
328
+ };
329
+ if (f.calculated !== undefined)
330
+ fieldUpdates.calculated = !!f.calculated;
331
+ if (f.stored !== undefined) fieldUpdates.stored = !!f.stored;
332
+ if (f.expression !== undefined)
333
+ fieldUpdates.expression = f.expression;
334
+ await db.update("_sc_fields", fieldUpdates, existing.id);
335
+ updated.push(f.name);
336
+ }
337
+ }
338
+ }
339
+
340
+ Trigger.emitEvent(
341
+ "AppChange",
342
+ `Fields updated on ${table_name}`,
343
+ req?.user,
344
+ { entity_type: "Table", entity_names: [table_name] }
345
+ );
346
+ return { added, updated };
347
+ }
348
+
349
+ static process_field(f, allTablesList = []) {
350
+ if (f.aggregation) {
351
+ const { data_type } = f.type_and_configuration || {
352
+ data_type: "Integer",
353
+ };
354
+ return {
355
+ name: f.name,
356
+ label: f.label,
357
+ type: data_type,
358
+ calculated: true,
359
+ stored: true,
360
+ expression: "__aggregation",
361
+ attributes: {
362
+ aggregate: f.aggregate_function || "Count",
363
+ agg_field: `${f.aggregate_field || "id"}@${data_type}`,
364
+ agg_relation: `${f.child_table}.${f.child_fk_field}`,
365
+ table: f.child_table,
366
+ ref: f.child_fk_field,
367
+ aggwhere: f.aggregate_where || "",
368
+ importance: f.importance,
369
+ },
370
+ };
371
+ }
372
+
373
+ const { data_type, reference_table, ...attributes } =
374
+ f.type_and_configuration || { data_type: "String" };
375
+ let type = data_type;
376
+ const scattributes = { ...attributes, importance: f.importance };
377
+ if (data_type === "ForeignKey") {
378
+ type = `Key to ${reference_table}`;
379
+ const refTableHere = allTablesList.find(
380
+ (t) => t.table_name === reference_table
381
+ );
382
+ if (refTableHere) {
383
+ const strFields = (refTableHere.fields || []).filter(
384
+ (rf) => rf.type_and_configuration?.data_type === "String"
385
+ );
386
+ if (strFields.length) {
387
+ const maxImp = strFields.reduce((prev, current) =>
388
+ prev && prev.importance > current.importance ? prev : current
389
+ );
390
+ if (maxImp) scattributes.summary_field = maxImp.name;
391
+ }
392
+ } else if (reference_table === "users") {
393
+ scattributes.summary_field = "email";
394
+ }
395
+ }
396
+ return {
397
+ ...f,
398
+ type,
399
+ required: f.not_null,
400
+ calculated: f.calculated || false,
401
+ stored: f.stored || false,
402
+ expression: f.expression,
403
+ attributes: scattributes,
404
+ };
405
+ }
406
+
187
407
  static process_tables(tables) {
188
408
  return tables.map((table) => {
189
409
  const sanitizedFields = Array.isArray(table.fields)
190
- ? table.fields.filter(
191
- (f) => (f?.name || "").toLowerCase() !== "id"
192
- )
410
+ ? table.fields.filter((f) => (f?.name || "").toLowerCase() !== "id")
193
411
  : [];
194
412
  return new Table({
195
413
  name: table.table_name,
196
- fields: sanitizedFields.map((f) => {
197
- const { data_type, reference_table, ...attributes } =
198
- f.type_and_configuration;
199
- let type = data_type;
200
- const scattributes = { ...attributes, importance: f.importance };
201
- if (data_type === "ForeignKey") {
202
- type = `Key to ${reference_table}`;
203
- let refTableHere = tables.find(
204
- (t) => t.table_name === reference_table
205
- );
206
- if (refTableHere) {
207
- const strFields = refTableHere.fields.filter(
208
- (f) => f.type_and_configuration.data_type === "String"
209
- );
210
- if (strFields.length) {
211
- const maxImp = strFields.reduce(function (prev, current) {
212
- return prev && prev.importance > current.importance
213
- ? prev
214
- : current;
215
- });
216
- if (maxImp) scattributes.summary_field = maxImp.name;
217
- }
218
- } else if (reference_table === "users") {
219
- scattributes.summary_field = "email";
220
- }
221
- }
222
-
223
- return {
224
- ...f,
225
- type,
226
- required: f.not_null,
227
- attributes: scattributes,
228
- };
229
- }),
414
+ fields: sanitizedFields.map((f) => this.process_field(f, tables)),
230
415
  });
231
416
  });
232
417
  }