sbuilder-mcp 0.16.0 → 0.16.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/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@ All notable changes to this project are documented in this file.
6
6
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.16.1] - 2026-09-10
10
+
11
+ ### Fixed
12
+ - sb_add, sb_set, sb_move, sb_remove, sb_duplicate, sb_event and sb_bind now judge a write against a throwaway copy of the page before applying it, so a save the platform refuses is never applied to the draft or broadcast to a live session; previously the refused node stayed in the document and every later command was validated against a tree the caller never asked for, repeating the same complaint about an id it had never typed. A write is still refused only for problems it introduces, not for damage the page already had when the session opened it.
13
+ - sb_import_page now builds its whole run of sections on a copy and commits them in one save, so a refusal partway through no longer leaves a page half imported with no way to tell which sections landed.
14
+
9
15
  ## [0.16.0] - 2026-09-10
10
16
 
11
17
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,12 @@ Mọi thay đổi đáng chú ý của dự án được ghi lại trong file n
6
6
  Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  và dự án tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.16.1] - 2026-09-10
10
+
11
+ ### Fixed
12
+ - sb_add, sb_set, sb_move, sb_remove, sb_duplicate, sb_event và sb_bind giờ đánh giá một lần ghi trên một bản sao tạm của trang trước khi áp dụng thật, để một lần save bị nền tảng từ chối không bao giờ được áp vào draft hay phát ra cho một phiên live đang xem; trước đây node bị từ chối vẫn ở lại trong document và mọi lệnh sau đó đều bị kiểm tra dựa trên một tree mà người gọi chưa từng yêu cầu, lặp lại cùng một lời phàn nàn về một id mà họ chưa từng gõ. Một lần ghi vẫn chỉ bị từ chối vì vấn đề do chính nó gây ra, không phải vì hư hỏng trang đã có sẵn từ trước khi phiên làm việc mở nó.
13
+ - sb_import_page giờ dựng toàn bộ các section trong một lượt import trên một bản sao và commit chúng trong một lần save duy nhất, để một lần từ chối giữa chừng không còn để lại một trang import dở dang mà không cách nào biết những section nào đã vào được.
14
+
9
15
  ## [0.16.0] - 2026-09-10
10
16
 
11
17
  ### Added
@@ -130,6 +130,29 @@ export class PageDoc {
130
130
  applyPatches(this.doc, patches);
131
131
  this.revision += 1;
132
132
  }
133
+ /**
134
+ * What this document WOULD be with these patches applied — a throwaway copy,
135
+ * so the real one is untouched.
136
+ *
137
+ * It exists so a write can be judged before it is made. Applying first and
138
+ * validating after leaves a refused edit sitting in the draft: the platform
139
+ * declines the save, the node stays, and every later command is then judged
140
+ * against a tree the caller never asked for and cannot see. Driving the real
141
+ * server, that arrived as three `sb_add` calls in a row all answering with the
142
+ * same complaint about a node id the caller had never typed.
143
+ *
144
+ * A JSON round-trip rather than anything cleverer. A page is hundreds of KB,
145
+ * which is a millisecond, and it is paid only on a write that is actually
146
+ * being committed — never on a dry run, never on a read.
147
+ */
148
+ preview(patches) {
149
+ const copy = new PageDoc(JSON.parse(JSON.stringify(this.doc)), this.revision);
150
+ // Carried, not re-derived: a validator that saw a different root key on the
151
+ // copy than on the original would be judging a different page.
152
+ copy.adoptedRootKey = this.adoptedRootKey;
153
+ copy.apply(patches);
154
+ return copy;
155
+ }
133
156
  /**
134
157
  * A compressed tree — id, type, name, child count, and the flags that change
135
158
  * what a caller may safely do with a node.
@@ -121,13 +121,24 @@ export function registerImportTools(server, ctx, session) {
121
121
  `Skipped: ${JSON.stringify(shot.skipped)}. A page that builds itself with scripts ` +
122
122
  'after load, or one behind a login, reads as empty here.');
123
123
  }
124
+ // STAGED ON A COPY, committed once.
125
+ //
126
+ // Each section's patch set is computed from the tree the previous one
127
+ // left, so they have to be applied in order — but applying them to the
128
+ // REAL document means a refusal at the end leaves the page half imported,
129
+ // with no way for the caller to tell which half. Building on a throwaway
130
+ // and committing the whole run through `applyAndSave` keeps the import
131
+ // all-or-nothing, and keeps it to one save and one live frame.
124
132
  const added = [];
133
+ const all = [];
134
+ const staged = doc.preview([]);
125
135
  for (const spec of specs) {
126
- const { patches, ids } = addSubtree(doc, doc.doc.root_node_id, spec);
127
- session.applyAndPublish(patches);
136
+ const { patches, ids } = addSubtree(staged, staged.doc.root_node_id, spec);
137
+ staged.apply(patches);
138
+ all.push(...patches);
128
139
  added.push(ids[0]);
129
140
  }
130
- await session.save();
141
+ await session.applyAndSave(all);
131
142
  return text({
132
143
  read: shot.url,
133
144
  added_sections: added,
@@ -452,8 +452,7 @@ export function registerLiveTools(server, ctx, session) {
452
452
  const patches = setEvent(d, id, trigger ?? 'click', action, payload);
453
453
  if (dry_run !== false)
454
454
  return text({ dry_run: true, patches });
455
- session.applyAndPublish(patches);
456
- await session.save();
455
+ await session.applyAndSave(patches);
457
456
  return text({ node: id, trigger: trigger ?? 'click', action, rev: d.rev });
458
457
  });
459
458
  server.registerTool('sb_bind', {
@@ -481,8 +480,7 @@ export function registerLiveTools(server, ctx, session) {
481
480
  const patches = bindNode(d, id, source, field, action);
482
481
  if (dry_run !== false)
483
482
  return text({ dry_run: true, patches });
484
- session.applyAndPublish(patches);
485
- await session.save();
483
+ await session.applyAndSave(patches);
486
484
  return text({ bound: id, source, field, ...(action ? { action } : {}), rev: d.rev });
487
485
  });
488
486
  }
@@ -101,6 +101,36 @@ export class PageSession {
101
101
  this.live.cursor(box.x + box.w / 2, box.y + box.h / 2);
102
102
  }
103
103
  }
104
+ /**
105
+ * The only sanctioned way to write: judge, then apply, publish and save.
106
+ *
107
+ * SPLITTING THESE WAS THE BUG. Every tool used to call `applyAndPublish` and
108
+ * then `save()`, and a save the platform would refuse threw with the patches
109
+ * already in the draft — and already broadcast to anyone watching the page
110
+ * live. The refused node then sat there, so the NEXT command was validated
111
+ * against a tree the caller had never asked for and got the same complaint
112
+ * about an id they had never typed. Three `sb_add` calls in a row, three
113
+ * identical refusals, and three copies of the element quietly in the page.
114
+ *
115
+ * The check runs on a COPY (`PageDoc.preview`), so a write that cannot be
116
+ * stored is never applied at all — no rollback to get wrong, and no phantom
117
+ * frame for a peer in the room to have to un-see.
118
+ *
119
+ * IT REFUSES ONLY WHAT THIS WRITE INTRODUCES. A page that arrived broken —
120
+ * damage stored before this session opened it — must not become a page nobody
121
+ * can edit, because the edit that repairs it is also a write. Pre-existing
122
+ * problems are left to `save()`, which names them.
123
+ */
124
+ async applyAndSave(patches) {
125
+ const d = this.current();
126
+ const before = new Set(validateForSave(d));
127
+ const introduced = validateForSave(d.preview(patches)).filter((p) => !before.has(p));
128
+ if (introduced.length > 0) {
129
+ throw new Error(`sbuilder: refusing to save — ${introduced.join(' ')}`);
130
+ }
131
+ this.applyAndPublish(patches);
132
+ await this.save();
133
+ }
104
134
  applyRemote(patches) {
105
135
  this.doc?.apply(patches);
106
136
  }
@@ -287,8 +317,7 @@ export function registerPageTools(server, ctx) {
287
317
  ...(inert ? { inert } : {}),
288
318
  });
289
319
  }
290
- session.applyAndPublish(patches);
291
- await session.save();
320
+ await session.applyAndSave(patches);
292
321
  return text({ added: ids, rev: d.rev, ...(inert ? { inert } : {}) });
293
322
  });
294
323
  server.registerTool('sb_set', {
@@ -504,8 +533,7 @@ export function registerPageTools(server, ctx) {
504
533
  });
505
534
  }
506
535
  const warnings = stuck();
507
- session.applyAndPublish(patches);
508
- await session.save();
536
+ await session.applyAndSave(patches);
509
537
  for (const t of touched) {
510
538
  const w = globalWarning(d.doc, t.id);
511
539
  if (w)
@@ -551,8 +579,7 @@ export function registerPageTools(server, ctx) {
551
579
  const patches = moveNode(d, id, parent_id, index);
552
580
  if (dry_run !== false)
553
581
  return text({ dry_run: true, patches });
554
- session.applyAndPublish(patches);
555
- await session.save();
582
+ await session.applyAndSave(patches);
556
583
  return text({ moved: id, rev: d.rev });
557
584
  });
558
585
  server.registerTool('sb_remove', {
@@ -564,8 +591,7 @@ export function registerPageTools(server, ctx) {
564
591
  const patches = removeNode(d, id);
565
592
  if (dry_run !== false)
566
593
  return text({ dry_run: true, removing: patches.length });
567
- session.applyAndPublish(patches);
568
- await session.save();
594
+ await session.applyAndSave(patches);
569
595
  return text({ removed: id, rev: d.rev });
570
596
  });
571
597
  server.registerTool('sb_review', {
@@ -608,8 +634,7 @@ export function registerPageTools(server, ctx) {
608
634
  const { patches, ids } = duplicateNode(d, id);
609
635
  if (dry_run !== false)
610
636
  return text({ dry_run: true, would_copy: ids.length });
611
- session.applyAndPublish(patches);
612
- await session.save();
637
+ await session.applyAndSave(patches);
613
638
  return text({ duplicated: id, into: ids[0], nodes: ids.length, rev: d.rev });
614
639
  });
615
640
  server.registerTool('sb_templates', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "MCP server that designs and operates a Store Builder site — pages, data, theme and publish — through the platform's own API and live-edit protocol.",
5
5
  "mcpName": "io.github.vuluu2k/sbuilder-mcp",
6
6
  "type": "module",