unoverse 0.1.163 → 0.1.164

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/lib/publish.mjs CHANGED
@@ -140,7 +140,7 @@ export async function publish(args) {
140
140
  // ── 3. the plan: what would this publish actually do ────────────────────────
141
141
  let plan;
142
142
  try {
143
- plan = await planPublish(items, universe, token);
143
+ plan = await planPublish(items, universe, token, project);
144
144
  } catch (e) {
145
145
  die(e.message);
146
146
  }
@@ -152,8 +152,10 @@ export async function publish(args) {
152
152
  for (const i of plan.update) console.log(` ${c.yellow}~${c.off} ${label(i)} ${c.dim}(changed)${c.off}${pending(i)}`);
153
153
  if (plan.unchanged.length) console.log(` ${c.dim}= ${plan.unchanged.length} unchanged${c.off}`);
154
154
  for (const r of plan.refused) console.log(` ${c.red}✗ ${label(r)}${c.off} ${c.dim}${r.why}${c.off}`);
155
+ // Deploy is a SYNC: what left the workspace leaves the universe, said before it happens.
156
+ for (const r of plan.remove ?? []) console.log(` ${c.red}-${c.off} ${label(r)} ${c.dim}(no longer in this workspace — will be removed)${c.off}`);
155
157
 
156
- const toSend = plan.create.length + plan.update.length;
158
+ const toSend = plan.create.length + plan.update.length + (plan.remove?.length ?? 0);
157
159
  if (!toSend) {
158
160
  console.log(`\n ${c.green}✓${c.off} nothing to do, ${project} is up to date\n`);
159
161
  process.exit(plan.refused.length ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.163",
3
+ "version": "0.1.164",
4
4
  "description": "The Unoverse front door — create a Studio project, a universe, or a client app, and launch Studio.",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "type": "module",
@@ -35,8 +35,41 @@ export async function lintForPublish(designRoot, project) {
35
35
  * owned by another publisher is refused, and finding that out before a publish beats
36
36
  * finding it out halfway through one.
37
37
  */
38
- export async function planPublish(items, universe, token, fetchImpl = fetch) {
39
- const plan = { create: [], update: [], unchanged: [], refused: [] };
38
+ export async function planPublish(items, universe, token,
39
+ /** The project being deployed; names the org whose universe rows the sync reconciles. */
40
+ project = "", fetchImpl = fetch) {
41
+ const plan = { create: [], update: [], unchanged: [], refused: [], remove: [] };
42
+ /**
43
+ * DEPLOY IS A SYNC (ruled 2026-08-21): what the workspace no longer holds, the
44
+ * universe should not keep serving under this project's name. The universe lists its
45
+ * rows for the project; anything absent from the collected items joins the plan as a
46
+ * removal, shown and confirmed like everything else.
47
+ *
48
+ * ONE GUARD, against the catastrophic misread: a workspace that holds NO items for
49
+ * the project (a fresh clone missing its files, the wrong folder entirely) does not
50
+ * get to empty the universe. Removals are proposed only when the workspace holds at
51
+ * least one item — deleting the last one is done by name, deliberately.
52
+ */
53
+ if (project && items.length > 0) {
54
+ try {
55
+ const res = await fetchImpl(`${universe}/publish`, {
56
+ method: "POST",
57
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
58
+ body: JSON.stringify({ op: "list", project }),
59
+ });
60
+ if (res.ok) {
61
+ const doc = (await res.json().catch(() => null));
62
+ const have = new Set(items.map((i) => `${i.kind}/${i.name}`));
63
+ for (const r of doc?.items ?? []) {
64
+ if (!have.has(`${r.kind}/${r.name}`))
65
+ plan.remove.push(r);
66
+ }
67
+ }
68
+ // An older universe answers this op with an error: no removals proposed, the
69
+ // additive behavior every deploy had before. Never a failure.
70
+ }
71
+ catch { /* unreachable universe fails later, on the real sends */ }
72
+ }
40
73
  for (const item of items) {
41
74
  // ASK THE GATE, not the item store. `/api/items` is deliberately shut (§9.10), so a
42
75
  // publisher cannot read rows directly; and asking the publish route means the plan is
@@ -88,6 +121,20 @@ export async function sendPublish(plan, universe, token, fetchImpl = fetch) {
88
121
  else
89
122
  failed.push({ ...item, why: body.error ?? `HTTP ${res.status}` });
90
123
  }
124
+ // Removals LAST, after the writes landed: a sync that dies mid-run has then added
125
+ // before it has taken away, which is the recoverable order.
126
+ for (const r of plan.remove ?? []) {
127
+ const res = await fetchImpl(`${universe}/publish`, {
128
+ method: "POST",
129
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
130
+ body: JSON.stringify({ op: "remove", kind: r.kind, name: r.name }),
131
+ });
132
+ const body = (await res.json().catch(() => ({})));
133
+ if (res.ok)
134
+ sent.push({ ...r, mode: "removed" });
135
+ else
136
+ failed.push({ ...r, why: body.error ?? `HTTP ${res.status}` });
137
+ }
91
138
  return { sent, failed };
92
139
  }
93
140
  export { collectProject };