@saltcorn/copilot 0.8.0 → 0.8.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.
@@ -1,7 +1,10 @@
1
1
  const Table = require("@saltcorn/data/models/table");
2
2
  const View = require("@saltcorn/data/models/view");
3
3
  const { fieldProperties } = require("../common");
4
- const { initial_config_all_fields } = require("@saltcorn/data/plugin-helper");
4
+ const {
5
+ initial_config_all_fields,
6
+ build_schema_data,
7
+ } = require("@saltcorn/data/plugin-helper");
5
8
  const { getState } = require("@saltcorn/data/db/state");
6
9
  const {
7
10
  div,
@@ -14,6 +17,11 @@ const {
14
17
  text_attr,
15
18
  } = require("@saltcorn/markup/tags");
16
19
  const builderGen = require("../builder-gen");
20
+ const {
21
+ RELATION_PATH_DOC,
22
+ GET_RELATION_PATHS_FUNCTION,
23
+ getRelationPathsForPairs,
24
+ } = require("../relation-paths");
17
25
 
18
26
  const collectLayoutFieldNames = (segment, out = new Set()) => {
19
27
  if (!segment || typeof segment !== "object") return out;
@@ -112,14 +120,34 @@ class GenerateViewSkill {
112
120
 
113
121
  async systemPrompt() {
114
122
  return (
115
- `If the user asks to generate a view, use the generate_view tool to enter ` +
116
- `a view generation mode. The tool call only requires high-level details to start this sequence.\n` +
117
- `The Edit viewtemplate serves both create (no id in state) and edit (id in state) — one view covers both modes.`
123
+ `If the user asks to generate a view, use the generate_view tool but ONLY if the view does not already exist. ` +
124
+ `If a view with that name already exists, do NOT call generate_view doing so will create a duplicate. Instead follow the modification sequence below.\n` +
125
+ `The Edit viewtemplate serves both create (no id in state) and edit (id in state) — one view covers both.\n\n` +
126
+ `**Modifying an existing view — required sequence:**\n` +
127
+ `(1) Call get_view_config to fetch the current configuration.\n` +
128
+ `(2) Only if you are adding view_link columns or embedded view (type "view") segments: call get_relation_paths once with all the source_table/target_view pairs you need. For changes that don't involve linking or embedding views (e.g. adding a field, changing a label), skip this step.\n` +
129
+ `(3) Write out the complete updated configuration JSON in full — every key from the existing config must be present, with only your targeted changes merged in.\n` +
130
+ `(4) Call apply_view_config with that complete object. NEVER call apply_view_config before step (3) is finished. NEVER call it with only the name or a partial object — the configuration field is mandatory and must be the full merged result from step (3). Calling apply_view_config without a complete configuration is an error.\n\n` +
131
+ `**Generating a new view that contains view_links or embedded views:**\n` +
132
+ `Call get_relation_paths once with all source_table/target_view pairs you need before constructing the layout.\n\n` +
133
+ `**Embedded view segment format (for Show layouts):**\n` +
134
+ ` { "type": "view", "view": "<viewName>", "name": "<viewName>", "relation": "<from get_relation_paths>" }\n` +
135
+ `Do NOT use blank text segments as placeholders — always use a real view segment with a relation string from get_relation_paths.\n\n` +
136
+ RELATION_PATH_DOC
118
137
  );
119
138
  }
120
139
 
121
140
  get userActions() {
122
141
  return {
142
+ async build_copilot_view_update({ name, configuration }) {
143
+ const existingView = View.findOne({ name });
144
+ if (!existingView) return { error: `View "${name}" not found` };
145
+ await View.update({ configuration }, existingView.id);
146
+ setTimeout(() => getState().refresh_views(), 200);
147
+ return {
148
+ notify: `View updated: <a target="_blank" href="/view/${name}">${name}</a>`,
149
+ };
150
+ },
123
151
  async build_copilot_view_gen({
124
152
  wfctx,
125
153
  name,
@@ -127,14 +155,23 @@ class GenerateViewSkill {
127
155
  table,
128
156
  min_role,
129
157
  }) {
130
- const normalizedRole = min_role || "public";
158
+ const existing = View.findOne({ name });
159
+ if (existing)
160
+ return {
161
+ error: `View "${name}" already exists. Use get_view_config and apply_view_config to update it.`,
162
+ };
131
163
  const tableRow = table ? Table.findOne({ name: table }) : null;
164
+ const roleName = typeof min_role === "number" ? null : (min_role || "public");
165
+ const resolvedRole =
166
+ typeof min_role === "number"
167
+ ? min_role
168
+ : ((getState().roles || []).find((r) => r.role === roleName) || { id: 100 }).id;
132
169
  await View.create({
133
170
  name,
134
171
  viewtemplate: viewpattern,
135
172
  table_id: tableRow?.id,
136
173
  table: tableRow,
137
- min_role: { admin: 1, public: 100, user: 80 }[normalizedRole],
174
+ min_role: resolvedRole,
138
175
  configuration: wfctx,
139
176
  });
140
177
  const vt = getState().viewtemplates[viewpattern];
@@ -199,12 +236,12 @@ class GenerateViewSkill {
199
236
  },
200
237
  };
201
238
 
202
- return {
239
+ const generateViewTool = {
203
240
  type: "function",
204
241
  function: {
205
242
  name: "generate_view",
206
243
  description:
207
- "Generate a view by supplying high-level details. This will trigger a view generation sequence",
244
+ "Generate a NEW view by supplying high-level details. Only call this for views that do not yet exist — if the view already exists, use get_view_config + apply_view_config instead.",
208
245
  parameters,
209
246
  },
210
247
  process: async (input) => {
@@ -264,7 +301,9 @@ class GenerateViewSkill {
264
301
  chat
265
302
  );
266
303
  if (table && viewpattern !== "Filter") {
267
- const baseCfg = await initial_config_all_fields(false)({
304
+ // isEdit=true: FK fields get Field+select columns; false gives JoinField (display-only)
305
+ const isEditView = viewpattern === "Edit";
306
+ const baseCfg = await initial_config_all_fields(isEditView)({
268
307
  table_id: table.id,
269
308
  });
270
309
  if (baseCfg?.columns) wfctx.columns = baseCfg.columns;
@@ -273,14 +312,26 @@ class GenerateViewSkill {
273
312
  const layoutFieldNames = collectLayoutFieldNames(wfctx.layout);
274
313
  const fields = table.fields || [];
275
314
  const fixed = {};
315
+ const usersFkColumnsToAdd = [];
276
316
  for (const f of fields) {
277
317
  if (f.primary_key || f.calculated) continue;
278
- if (layoutFieldNames.has(f.name)) continue;
279
318
  if (f.type === "Key" && f.reftable_name === "users") {
280
- fixed[`preset_${f.name}`] = "LoggedIn";
281
- fixed[`_block_${f.name}`] = true;
319
+ if (layoutFieldNames.has(f.name)) {
320
+ // Explicitly placed in layout — add a select column so getForm renders it
321
+ usersFkColumnsToAdd.push({
322
+ field_name: f.name,
323
+ type: "Field",
324
+ fieldview: "select",
325
+ state_field: true,
326
+ });
327
+ } else {
328
+ fixed[`preset_${f.name}`] = "LoggedIn";
329
+ fixed[`_block_${f.name}`] = true;
330
+ }
282
331
  }
283
332
  }
333
+ if (usersFkColumnsToAdd.length > 0)
334
+ wfctx.columns = [...(wfctx.columns || []), ...usersFkColumnsToAdd];
284
335
  if (Object.keys(fixed).length > 0) wfctx.fixed = fixed;
285
336
  wfctx.destination_type = "Back to referer";
286
337
  }
@@ -388,13 +439,18 @@ class GenerateViewSkill {
388
439
  },
389
440
  }
390
441
  );
391
- const tc = answer.getToolCalls()[0];
392
- await getState().functions.llm_add_message.run(
393
- "tool_response",
394
- { type: "text", value: "Details provided" },
395
- { chat, tool_call: tc }
396
- );
397
- Object.assign(wfctx, tc.input);
442
+ const tc =
443
+ typeof answer?.getToolCalls === "function"
444
+ ? answer.getToolCalls()[0]
445
+ : null;
446
+ if (tc) {
447
+ await getState().functions.llm_add_message.run(
448
+ "tool_response",
449
+ { type: "text", value: "Details provided" },
450
+ { chat, tool_call: tc }
451
+ );
452
+ Object.assign(wfctx, tc.input);
453
+ }
398
454
  }
399
455
  }
400
456
  const roleName = tool_call.input.min_role || "public";
@@ -402,6 +458,13 @@ class GenerateViewSkill {
402
458
  const min_role = rolesState
403
459
  ? (rolesState.find((r) => r.role === roleName) || { id: 100 }).id
404
460
  : { admin: 1, public: 100, user: 80 }[roleName] ?? 100;
461
+ const existingView = View.findOne({ name: tool_call.input.name });
462
+ if (existingView) {
463
+ return {
464
+ stop: true,
465
+ add_response: `Error: view "${tool_call.input.name}" already exists. Do NOT call generate_view again — use get_view_config to inspect the current configuration and apply_view_config to update it.`,
466
+ };
467
+ }
405
468
  const view = new View({
406
469
  name: tool_call.input.name,
407
470
  viewtemplate: tool_call.input.viewpattern,
@@ -441,6 +504,127 @@ class GenerateViewSkill {
441
504
  };
442
505
  },
443
506
  };
507
+
508
+ const getViewConfigTool = {
509
+ type: "function",
510
+ function: {
511
+ name: "get_view_config",
512
+ description:
513
+ "Retrieve the current configuration of an existing view. " +
514
+ "Call this first to inspect the layout before calling apply_view_config to save changes. " +
515
+ "Returns the full configuration JSON and the viewtemplate name.",
516
+ parameters: {
517
+ type: "object",
518
+ required: ["name"],
519
+ properties: {
520
+ name: {
521
+ description: "The name of the existing view to inspect.",
522
+ type: "string",
523
+ },
524
+ },
525
+ },
526
+ },
527
+ process: async ({ name }) => {
528
+ const existingView = View.findOne({ name });
529
+ if (!existingView)
530
+ return `View "${name}" not found. Use generate_view to create a new view instead.`;
531
+ return (
532
+ `Current configuration of view "${name}" (viewtemplate: ${existingView.viewtemplate}):\n` +
533
+ JSON.stringify(existingView.configuration, null, 2)
534
+ );
535
+ },
536
+ };
537
+
538
+ const applyViewConfigTool = {
539
+ type: "function",
540
+ function: {
541
+ name: "apply_view_config",
542
+ description:
543
+ "Save an updated configuration to an existing view. " +
544
+ "STRICT PRECONDITION: you must have already called get_view_config AND written out the complete merged configuration JSON before calling this tool. " +
545
+ "Do NOT call this tool as a placeholder or before the configuration is fully constructed. " +
546
+ "Calling this tool without a complete configuration object is always wrong and will fail.",
547
+ parameters: {
548
+ type: "object",
549
+ required: ["name", "configuration"],
550
+ properties: {
551
+ name: {
552
+ description: "The name of the existing view to update.",
553
+ type: "string",
554
+ },
555
+ configuration: {
556
+ type: "object",
557
+ description:
558
+ "REQUIRED. The complete updated configuration object — every key from the existing config preserved, with only your changes merged in. " +
559
+ "You MUST have the full object written out before calling this tool. " +
560
+ "Passing null, an empty object, or a partial object (e.g. only the name) is always wrong and will return an error.",
561
+ },
562
+ },
563
+ },
564
+ },
565
+ process: async ({ name, configuration }) => {
566
+ const existingView = View.findOne({ name });
567
+ if (!existingView) return `View "${name}" not found.`;
568
+ if (!configuration || typeof configuration !== "object")
569
+ return (
570
+ `ERROR: configuration is missing. ` +
571
+ `You must call get_view_config first, merge your changes into the full existing configuration, then call apply_view_config again with the complete configuration object.`
572
+ );
573
+ return { name, configuration, view_id: existingView.id };
574
+ },
575
+ postProcess: async ({ tool_call, req }) => {
576
+ const { name, configuration } = tool_call.input;
577
+ const existingView = View.findOne({ name });
578
+ if (!existingView)
579
+ return { stop: true, add_response: `View "${name}" not found.` };
580
+ if (!configuration || typeof configuration !== "object")
581
+ return {
582
+ stop: true,
583
+ add_response:
584
+ `apply_view_config called for "${name}" without a configuration object. ` +
585
+ `Call get_view_config first, merge your changes into the full existing configuration, then call apply_view_config again with the complete configuration.`,
586
+ };
587
+ const cfg = configuration;
588
+
589
+ if (this.yoloMode) {
590
+ await View.update({ configuration: cfg }, existingView.id);
591
+ setTimeout(() => getState().refresh_views(), 200);
592
+ return { stop: true, add_response: `View ${name} updated.` };
593
+ }
594
+ return {
595
+ stop: true,
596
+ add_response: pre(JSON.stringify(cfg, null, 2)),
597
+ add_user_action: {
598
+ name: "build_copilot_view_update",
599
+ type: "button",
600
+ label: "Save updated view " + name,
601
+ input: { name, configuration: cfg },
602
+ },
603
+ };
604
+ },
605
+ };
606
+
607
+ const getRelationPathsTool = {
608
+ type: "function",
609
+ function: GET_RELATION_PATHS_FUNCTION,
610
+ process: async ({ pairs }) => {
611
+ const schemaData = await build_schema_data();
612
+ const sections = getRelationPathsForPairs(pairs || [], schemaData);
613
+ return (
614
+ sections.join("\n\n") +
615
+ `\n\nFor each pair, set the "relation" property to one of the strings listed above.\n` +
616
+ `Pick by type: ChildList = multiple child rows, ParentShow = single parent, OneToOneShow = unique child. ` +
617
+ `If multiple paths of the same type exist, choose the one whose FK field name best matches the task. Prefer shorter paths.`
618
+ );
619
+ },
620
+ };
621
+
622
+ return [
623
+ generateViewTool,
624
+ getViewConfigTool,
625
+ applyViewConfigTool,
626
+ getRelationPathsTool,
627
+ ];
444
628
  };
445
629
  }
446
630
 
@@ -42,12 +42,14 @@ const existing_tables_list = (tables) => {
42
42
  tables.forEach((table) => {
43
43
  const fieldLines = table.fields.map(
44
44
  (f) =>
45
- ` * ${f.name} with type: ${f.pretty_type}.${f.description ? ` ${f.description}` : ""}`,
45
+ ` * ${f.name} with type: ${f.pretty_type}.${
46
+ f.description ? ` ${f.description}` : ""
47
+ }`
46
48
  );
47
49
  tableLines.push(
48
50
  `${table.name}${
49
51
  table.description ? `: ${table.description}.` : "."
50
- } Contains the following fields:\n${fieldLines.join("\n")}`,
52
+ } Contains the following fields:\n${fieldLines.join("\n")}`
51
53
  );
52
54
  });
53
55
  return `The database already contains the following tables:
@@ -55,4 +57,64 @@ const existing_tables_list = (tables) => {
55
57
  ${tableLines.join("\n\n")}`;
56
58
  };
57
59
 
58
- module.exports = { saltcorn_description, existing_tables_list };
60
+ const existing_entities_list = ({ views, triggers, pages, tableById = {} }) => {
61
+ const sections = [];
62
+ if (views.length)
63
+ sections.push(
64
+ `The following views are already implemented — do NOT plan tasks to create them:\n` +
65
+ views
66
+ .map((v) => {
67
+ const tablePart =
68
+ v.table?.name ||
69
+ (v.table_id && tableById[v.table_id]) ||
70
+ v.exttable_name;
71
+ return `- ${v.name} (${v.viewtemplate}${
72
+ tablePart ? ` on ${tablePart}` : ""
73
+ })`;
74
+ })
75
+ .join("\n")
76
+ );
77
+ if (triggers.length)
78
+ sections.push(
79
+ `The following triggers are already implemented — do NOT plan tasks to create them:\n` +
80
+ triggers
81
+ .map(
82
+ (t) =>
83
+ `- ${t.name} (${t.action}${
84
+ t.when_trigger ? `, ${t.when_trigger}` : ""
85
+ })`
86
+ )
87
+ .join("\n")
88
+ );
89
+ if (pages.length)
90
+ sections.push(
91
+ `The following pages are already implemented — do NOT plan tasks to create them:\n` +
92
+ pages.map((p) => `- ${p.name}`).join("\n")
93
+ );
94
+ return sections.join("\n\n");
95
+ };
96
+
97
+ const available_plugins_list = (storePlugins, installedNames) => {
98
+ const uninstalled = storePlugins.filter((p) => !installedNames.has(p.name));
99
+ if (!uninstalled.length) return "";
100
+ const lines = uninstalled.map((p) => {
101
+ let line = `### ${p.name}`;
102
+ if (p.description) line += `\n${p.description}`;
103
+ if (p.contents) line += `\n${p.contents}`;
104
+ return line;
105
+ });
106
+ return (
107
+ `The following plugins are available in the Saltcorn store but not yet installed. ` +
108
+ `If a task requires functionality provided by one of these plugins (e.g. a specific view template, field type, or action), ` +
109
+ `include an explicit "Install plugin <name>" task before it with the exact plugin name as listed here. ` +
110
+ `The executor will use that name directly without needing to look it up.\n\n` +
111
+ lines.join("\n\n")
112
+ );
113
+ };
114
+
115
+ module.exports = {
116
+ saltcorn_description,
117
+ existing_tables_list,
118
+ existing_entities_list,
119
+ available_plugins_list,
120
+ };
@@ -8,41 +8,13 @@ const { findType } = require("@saltcorn/data/models/discovery");
8
8
  const { save_menu_items } = require("@saltcorn/data/models/config");
9
9
  const db = require("@saltcorn/data/db");
10
10
  const WorkflowRun = require("@saltcorn/data/models/workflow_run");
11
+ const User = require("@saltcorn/data/models/user");
11
12
  const { viewname } = require("./common");
12
13
 
13
- const runNextTask = async (alwaysRun) => {
14
- if (!alwaysRun) {
15
- const settings = await MetaData.findOne({
16
- type: "CopilotConstructMgr",
17
- name: "settings",
18
- });
19
- if (!settings?.body?.running) return;
20
- }
21
- const tasks = await MetaData.find(
22
- {
23
- type: "CopilotConstructMgr",
24
- name: "task",
25
- },
26
- { orderBy: "id" }
27
- );
28
- const todos = tasks.filter(
29
- (t) => !t.body.status || t.body.status === "To do"
30
- );
31
- const done = tasks.filter((t) => t.body.status === "Done");
32
- const done_names = new Set(done.map((t) => t.body.name));
33
-
34
- const startable = todos.filter((t) =>
35
- t.body.depends_on.every((nm) => done_names.has(nm))
36
- );
37
-
38
- if (startable[0]) {
39
- console.log("running task", startable[0]);
40
-
41
- return await runTask(startable[0].id, {});
42
- }
43
- //not done
44
- };
45
-
14
+ /**
15
+ * @param {number} md_id - MetaData id of the task to run
16
+ * @param {object} req - Express request (may be empty `{}` from scheduler)
17
+ */
46
18
  const runTask = async (md_id, req) => {
47
19
  const md = await MetaData.findOne({
48
20
  id: md_id,
@@ -59,15 +31,18 @@ const runTask = async (md_id, req) => {
59
31
  when_trigger: "Never",
60
32
  configuration: {
61
33
  viewname: viewname,
62
- sys_prompt: "",
34
+ sys_prompt:
35
+ "Each task creates exactly one view or one page. " +
36
+ "Never create more than one view or page per task, even if the description mentions multiple. " +
37
+ "Call the view or page tool exactly once and then stop.",
63
38
  prompt: "{{prompt}}",
64
39
  skills: [
65
40
  { skill_type: "Generate Page", yoloMode: true },
66
41
  { skill_type: "Database design", yoloMode: true },
67
42
  { skill_type: "Generate Workflow", yoloMode: true },
43
+ { skill_type: "Generate trigger", yoloMode: true },
68
44
  { skill_type: "Generate View", yoloMode: true },
69
45
  { skill_type: "Install Plugin", yoloMode: true },
70
- { skill_type: "AppConstructor Context" },
71
46
  ],
72
47
  },
73
48
  });
@@ -83,16 +58,20 @@ Important: The database schema is already fully implemented. Do NOT use generate
83
58
 
84
59
  Important: Some fields are non-stored (virtual) calculated fields — they have no database column and are computed on-the-fly by Saltcorn. Never include such fields in modify_row, SQL UPDATE statements, or recalculate_stored_fields calls. Only fields that exist as actual database columns (regular fields and stored calculated fields) can be written. If a calculated field needs updating, it will refresh automatically when the fields it depends on change.
85
60
 
61
+ Important: The "users" table is built-in. Passwords are platform-managed — never add a password field to a view. Signup uses a built-in form, not an Edit view.
62
+
86
63
  Your task now is:
87
64
  ${md.body.description}`;
65
+ const safeReq = req?.__
66
+ ? req
67
+ : { ...req, __: (s) => s, user: req?.user };
88
68
 
89
69
  await md.update({ body: { ...md.body, status: "Running" } });
90
70
  const actionres = await agent_action.runWithoutRow({
91
71
  row: { prompt },
92
- req,
93
- user: req?.user,
72
+ req: safeReq,
73
+ user: safeReq.user,
94
74
  });
95
- //console.log("actionres", actionres);
96
75
  const run_id = actionres.json.run_id;
97
76
  const run = await WorkflowRun.findOne({ id: run_id });
98
77
  await agent_action.runWithoutRow({
@@ -100,18 +79,18 @@ ${md.body.description}`;
100
79
  prompt:
101
80
  "Write a description of what you did, for the purposes of a progress report. Write 1-4 sentences. Do not use any tools or write any code",
102
81
  },
103
- req,
82
+ req: safeReq,
104
83
  run,
105
- user: req?.user,
84
+ user: safeReq.user,
106
85
  });
107
86
  const lastInteraction =
108
87
  run.context.interactions[run.context.interactions.length - 1];
109
88
  const lastText =
110
89
  typeof lastInteraction.content === "string"
111
90
  ? lastInteraction.content
112
- : lastInteraction.content.text
91
+ : lastInteraction.content.text
113
92
  ? lastInteraction.content.text
114
- : Array.isArray(lastInteraction.content)
93
+ : Array.isArray(lastInteraction.content)
115
94
  ? lastInteraction.content[0].text
116
95
  : lastInteraction.content;
117
96
  await MetaData.create({
@@ -121,8 +100,47 @@ ${md.body.description}`;
121
100
  user_id: req?.user?.id,
122
101
  });
123
102
 
124
- //console.log("run", run);
125
103
  await md.update({ body: { ...md.body, status: "Done", run_id } });
126
104
  };
127
105
 
106
+ /**
107
+ * Run the next startable task
108
+ * @param {boolean} [once=false] - true: run one task and stop, false: iterate all tasks
109
+ */
110
+ const runNextTask = async (once = false) => {
111
+ if (!once) {
112
+ const settings = await MetaData.findOne({
113
+ type: "CopilotConstructMgr",
114
+ name: "settings",
115
+ });
116
+ if (!settings?.body?.running) return;
117
+ }
118
+ const tasks = await MetaData.find(
119
+ {
120
+ type: "CopilotConstructMgr",
121
+ name: "task",
122
+ },
123
+ { orderBy: "id" }
124
+ );
125
+ if (tasks.some((t) => t.body.status === "Running")) return;
126
+ const todos = tasks.filter(
127
+ (t) => !t.body.status || t.body.status === "To do"
128
+ );
129
+ const done = tasks.filter((t) => t.body.status === "Done");
130
+ const done_names = new Set(done.map((t) => t.body.name));
131
+
132
+ const startable = todos.filter((t) =>
133
+ t.body.depends_on.every((nm) => done_names.has(nm))
134
+ );
135
+
136
+ if (startable[0]) {
137
+ console.log("running task", startable[0]);
138
+ const taskUser = startable[0].user_id
139
+ ? await User.findOne({ id: startable[0].user_id })
140
+ : null;
141
+ await runTask(startable[0].id, { user: taskUser, __: (s) => s });
142
+ if (!once) await runNextTask();
143
+ }
144
+ };
145
+
128
146
  module.exports = { runTask, runNextTask };
@@ -123,6 +123,8 @@ ${existing_tables_list(existing_tables)}
123
123
 
124
124
  Design a complete database schema that covers ALL requirements listed above. Every distinct entity in the application must have its own table. Do not produce a minimal or partial schema — all tables needed to implement every requirement must be included in this single call. Do not leave any tables for a later step.
125
125
 
126
+ The tables listed above are already implemented in the database — include them in the schema as-is so the full data model is visible, but do not change their fields. Only add new tables for entities not yet covered. The implementation step will skip any table whose name already exists.
127
+
126
128
  For every field that must be unique (e.g. unique email, unique slug, unique combination keys expressed as individual unique fields), set unique=true on that field.
127
129
  For every field that must not be empty, set not_null=true.
128
130
  Do NOT leave uniqueness or required constraints for a later step — express them fully in this schema.
@@ -174,7 +176,18 @@ const implement_schema = async (
174
176
  });
175
177
 
176
178
  const { apply_copilot_tables } = new GenerateTablesSkill({}).userActions;
177
- await apply_copilot_tables({ tables: md.body.tables, user: req.user });
179
+ const existingNames = new Set((await Table.find({})).map((t) => t.name));
180
+ const newTables = md.body.tables.filter((t) => {
181
+ if (existingNames.has(t.name)) {
182
+ getState().log(
183
+ 2,
184
+ `AppConstructor: skipping table "${t.name}" — already exists in database`
185
+ );
186
+ return false;
187
+ }
188
+ return true;
189
+ });
190
+ await apply_copilot_tables({ tables: newTables, user: req.user });
178
191
  md.body.implemented = true;
179
192
  await md.update({ body: md.body });
180
193