@synap-core/workspace-templates 0.5.0 → 0.6.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/src/catalog.ts ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * mergeCatalog — the ONE merge door for the workspace-template catalog.
3
+ * ====================================================================
4
+ *
5
+ * Both marketplace entry points (browser + CLI) and the pod need to show ONE
6
+ * list of installable workspace templates drawn from TWO sources:
7
+ *
8
+ * 1. BUNDLED — the canonical `@synap-core/workspace-templates` package that
9
+ * ships inside every client (this package). Always present, zero network.
10
+ * 2. REMOTE — rows in the control-plane package registry (`GET /api/packages`),
11
+ * which includes both the official templates re-published by
12
+ * `seed-templates.ts` AND private / third-party packages that never shipped
13
+ * in the bundle.
14
+ *
15
+ * The two overlap: every official template exists in BOTH sources (once bundled,
16
+ * once seeded to the CP). Naively concatenating them shows 21 duplicate cards.
17
+ * `mergeCatalog` de-dupes them into ONE catalog with a deterministic winner.
18
+ *
19
+ * WHY THIS LIVES HERE (and is PURE):
20
+ * This is the ONLY module the root `pnpm-workspace.yaml` lets `synap-cli`,
21
+ * `browser`, `synap-control-plane-api`, AND `synap-backend/packages/api` all
22
+ * resolve. `@synap-core/marketplace` pulls React and is `private: true`, so the
23
+ * Node CLI cannot import it. Keep this file pure data — no fetch, no auth, no
24
+ * React, no Node-only APIs. Each entry point has a DIFFERENT transport (browser
25
+ * cookie/`cpFetch`, CLI Bearer, pod anonymous); a door that fetched would force
26
+ * ONE auth model on all three and become the next lossy seam. Transport lives
27
+ * in the consumers; this door only merges arrays they already fetched.
28
+ */
29
+
30
+ import type { WorkspaceYaml } from "./types.js";
31
+
32
+ // ============================================================================
33
+ // Inputs
34
+ // ============================================================================
35
+
36
+ /**
37
+ * A bundled template — the canonical `WorkspaceYaml` this package ships. Callers
38
+ * pass `listPublicTemplates()` (18) or `listWorkspaceTemplates()` (21); the door
39
+ * merges whatever it is given and does NOT filter (see the publicity note below).
40
+ */
41
+ export type BundledEntry = WorkspaceYaml;
42
+
43
+ /**
44
+ * A control-plane registry row, mapped into the minimal shape the door needs.
45
+ * Consumers build this from a `MarketplacePackage` (browser) / the CP
46
+ * `GET /api/packages` JSON (CLI) — a thin, transport-specific projection.
47
+ *
48
+ * `definition` is carried OPAQUE: it is both (a) the install payload the consumer
49
+ * feeds back to the pod, and (b) the home of the ONE comparable version signal,
50
+ * `definition.sourcePackage.version` — the real npm semver of the published
51
+ * `@synap-core/workspace-templates` build that generated an official row
52
+ * (`seed-templates.ts` stamps it). The door only reads that one nested field; the
53
+ * rest of `definition` is passed through untouched.
54
+ */
55
+ export interface RemoteEntry {
56
+ slug: string;
57
+ name: string;
58
+ description?: string;
59
+ /** CP package category / type (e.g. `"workspace"`, `"view"`). */
60
+ category?: string;
61
+ tags?: string[];
62
+ icon?: string | null;
63
+ /**
64
+ * `true` when the CP row is not publicly listed (`isPublic === false`). Browse
65
+ * rows leave this `undefined` BY CONSTRUCTION — the browse route hard-filters
66
+ * `WHERE is_public = true`, so every browse row is public; `undefined` ⇒ public,
67
+ * never "unknown-so-hide".
68
+ */
69
+ isPrivate?: boolean;
70
+ /**
71
+ * The CP `definition` jsonb — opaque install payload. The door reads only
72
+ * `definition.sourcePackage.version` (the winner signal). Absent on browse rows
73
+ * (the list route strips `definition`), which is fine: absence ⇒ bundle wins.
74
+ */
75
+ definition?: {
76
+ sourcePackage?: { name?: string; version?: string };
77
+ [k: string]: unknown;
78
+ };
79
+ }
80
+
81
+ /** Whether the caller could reach / authenticate against the remote source. */
82
+ export type RemoteSourceStatus = "ok" | "unauthenticated" | "unreachable";
83
+
84
+ export interface MergeCatalogInput {
85
+ /** Bundled templates (this package). Always "ok" — it is in-process data. */
86
+ bundled: BundledEntry[];
87
+ /** Already-fetched CP rows. Empty/omitted when the fetch failed. */
88
+ remote?: RemoteEntry[];
89
+ /**
90
+ * This client's bundled-package version (`package.json` `version`, live `0.6.0`).
91
+ * Compared against each remote row's `sourcePackage.version` to decide whether
92
+ * THIS client's bundle is stale. Callers pass the compiled-in package version.
93
+ */
94
+ bundleVersion: string;
95
+ /**
96
+ * The transport's verdict on the remote fetch. `"ok"` (default) ⇒ merge the
97
+ * `remote` array. `"unauthenticated"` / `"unreachable"` ⇒ ignore `remote` and
98
+ * surface the degradation in `sources.remote`, so the consumer can render
99
+ * "18 available — private ones need login" instead of FAILING OPEN EMPTY.
100
+ */
101
+ remoteStatus?: RemoteSourceStatus;
102
+ }
103
+
104
+ // ============================================================================
105
+ // Output
106
+ // ============================================================================
107
+
108
+ interface CatalogEntryBase {
109
+ slug: string;
110
+ name: string;
111
+ description?: string;
112
+ /** Package category / type. Bundled templates are always `"workspace"`. */
113
+ category?: string;
114
+ tags: string[];
115
+ icon?: string | null;
116
+ /** `true` ⇒ not publicly listed (internal bundled template, or private CP row). */
117
+ isPrivate: boolean;
118
+ }
119
+
120
+ /** A catalog entry backed by a bundled template. Install via `toWorkspaceDefinition()` / `toPackageDefinition()` on `template`. */
121
+ export interface BundledCatalogEntry extends CatalogEntryBase {
122
+ source: "bundled";
123
+ template: WorkspaceYaml;
124
+ }
125
+
126
+ /** A catalog entry backed by a CP registry row. Install from `remote.definition`. */
127
+ export interface RemoteCatalogEntry extends CatalogEntryBase {
128
+ source: "remote";
129
+ remote: RemoteEntry;
130
+ }
131
+
132
+ /** One merged card. `source` discriminates the install path; common fields feed a picker. */
133
+ export type CatalogEntry = BundledCatalogEntry | RemoteCatalogEntry;
134
+
135
+ export interface MergeCatalogResult {
136
+ entries: CatalogEntry[];
137
+ /**
138
+ * Per-source health. LOAD-BEARING, not decoration: it is what lets a consumer
139
+ * distinguish "remote returned nothing" from "remote unreachable" and avoid the
140
+ * FAILS-OPEN-EMPTY trap (agent sees empty catalog → thinks pod is empty → dupes).
141
+ */
142
+ sources: { bundled: "ok"; remote: RemoteSourceStatus };
143
+ }
144
+
145
+ // ============================================================================
146
+ // Winner signal
147
+ // ============================================================================
148
+
149
+ function parseSemver(v: string | undefined): [number, number, number] | null {
150
+ if (!v) return null;
151
+ const m = /^\s*v?(\d+)\.(\d+)\.(\d+)/.exec(v);
152
+ if (!m) return null; // e.g. a range string ("range:>=0.6.0 <1.0.0") — not a version
153
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
154
+ }
155
+
156
+ /**
157
+ * Is the remote row STRICTLY newer than this client's bundle? Only then does the
158
+ * remote win a slug conflict — it means THIS client is stale and the CP has a
159
+ * fresher official build. Every other case (equal, older, unparseable, or no
160
+ * `sourcePackage` at all) ⇒ `false` ⇒ BUNDLE WINS. That default is correct four
161
+ * ways, strongest last: (1) the CP is provably staler in the common case; (2) the
162
+ * CP row is a lossy re-encode of the bundle; (3) zero network; (4) the pod's boot
163
+ * reconcile already converges every workspace to the BUNDLED shape — bundle-wins
164
+ * agrees with the reconciler; CP-wins would fight it every boot.
165
+ */
166
+ function remoteIsStrictlyNewer(
167
+ remoteVersion: string | undefined,
168
+ bundleVersion: string,
169
+ ): boolean {
170
+ const r = parseSemver(remoteVersion);
171
+ const b = parseSemver(bundleVersion);
172
+ if (!r || !b) return false;
173
+ for (let i = 0; i < 3; i++) {
174
+ if (r[i] > b[i]) return true;
175
+ if (r[i] < b[i]) return false;
176
+ }
177
+ return false; // equal ⇒ not strictly newer ⇒ bundle wins
178
+ }
179
+
180
+ // ============================================================================
181
+ // Mapping
182
+ // ============================================================================
183
+
184
+ function bundledToEntry(tpl: WorkspaceYaml): BundledCatalogEntry {
185
+ return {
186
+ source: "bundled",
187
+ slug: tpl.meta.slug,
188
+ name: tpl.meta.name,
189
+ description: tpl.meta.description,
190
+ // Every bundled template is a full workspace — the CP calls this category
191
+ // "workspace" (PACKAGE_TYPES). Finer display grouping is derived from `tags`
192
+ // by the consumer (browser `categoryFor`), not baked in here.
193
+ category: "workspace",
194
+ tags: tpl.meta.tags ?? [],
195
+ icon: tpl.meta.icon,
196
+ isPrivate: tpl.meta.isPublic === false,
197
+ template: tpl,
198
+ };
199
+ }
200
+
201
+ function remoteToEntry(row: RemoteEntry): RemoteCatalogEntry {
202
+ return {
203
+ source: "remote",
204
+ slug: row.slug,
205
+ name: row.name,
206
+ description: row.description,
207
+ category: row.category,
208
+ tags: row.tags ?? [],
209
+ icon: row.icon ?? null,
210
+ isPrivate: row.isPrivate ?? false,
211
+ remote: row,
212
+ };
213
+ }
214
+
215
+ // ============================================================================
216
+ // The door
217
+ // ============================================================================
218
+
219
+ /**
220
+ * Merge bundled + remote template catalogs into ONE de-duplicated list.
221
+ *
222
+ * DEDUPE KEY = `slug` — NOT `slug@version`. This is forced by the live data, not
223
+ * a preference: every CP flagship row is hardcoded `version: "1.0.0"`
224
+ * (`seed-templates.ts`) and `TemplateMeta` carries NO version field at all, so a
225
+ * `slug@version` key would collide on nothing → 21 duplicate cards. The real
226
+ * per-build version lives only in `definition.sourcePackage.version`, which is
227
+ * the WINNER signal, never the identity key.
228
+ *
229
+ * CONFLICT RESOLUTION (remote slug already claimed by a bundled template):
230
+ * • remote `sourcePackage.version` strictly > `bundleVersion` → remote wins
231
+ * (this client is stale — the CP has a fresher official build).
232
+ * • otherwise (equal / older / no `sourcePackage`) → BUNDLE wins. The
233
+ * no-`sourcePackage` case is a third-party package SQUATTING an official slug;
234
+ * the bundle wins unconditionally (squatting defense).
235
+ *
236
+ * A remote slug that does NOT collide with any bundled template is a DISTINCT
237
+ * entry (a private or third-party package) and is always kept.
238
+ */
239
+ export function mergeCatalog(input: MergeCatalogInput): MergeCatalogResult {
240
+ const { bundled, bundleVersion } = input;
241
+ const remoteStatus: RemoteSourceStatus = input.remoteStatus ?? "ok";
242
+ const remote = input.remote ?? [];
243
+
244
+ // Insertion-ordered map keyed by slug. Bundled entries seed it first, so they
245
+ // are the default winner; a qualifying remote row overwrites in place.
246
+ const bySlug = new Map<string, CatalogEntry>();
247
+
248
+ for (const tpl of bundled) {
249
+ bySlug.set(tpl.meta.slug, bundledToEntry(tpl));
250
+ }
251
+
252
+ // Only fold in remote rows when the transport actually reached the source.
253
+ // A failed fetch must NOT silently look like "the CP has no templates".
254
+ if (remoteStatus === "ok") {
255
+ for (const row of remote) {
256
+ const existing = bySlug.get(row.slug);
257
+
258
+ if (!existing) {
259
+ // No bundled template owns this slug → distinct (private / third-party).
260
+ bySlug.set(row.slug, remoteToEntry(row));
261
+ continue;
262
+ }
263
+
264
+ if (existing.source !== "bundled") {
265
+ // Two remote rows with the same slug (shouldn't happen — CP slugs are
266
+ // unique) — keep the first, defensively.
267
+ continue;
268
+ }
269
+
270
+ // Slug conflict with an official bundled template. Remote wins ONLY if it
271
+ // is a strictly-newer official build; else the bundle stays (older, equal,
272
+ // or a squatting third-party with no sourcePackage).
273
+ const remoteVersion = row.definition?.sourcePackage?.version;
274
+ if (remoteIsStrictlyNewer(remoteVersion, bundleVersion)) {
275
+ bySlug.set(row.slug, remoteToEntry(row));
276
+ }
277
+ // else: no-op — bundled entry already in place wins.
278
+ }
279
+ }
280
+
281
+ return {
282
+ entries: [...bySlug.values()],
283
+ sources: { bundled: "ok", remote: remoteStatus },
284
+ };
285
+ }