pi-model-sync 0.1.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.
@@ -0,0 +1,357 @@
1
+ /**
2
+ * pi-model-sync models.dev enrichment.
3
+ *
4
+ * models.dev carries capabilities (context, costs, modalities, reasoning
5
+ * options) that bare /v1/models lists lack. Live endpoint metadata always
6
+ * wins when present; models.dev fills the rest. Unknown models pass through
7
+ * with live data only, never fabricated defaults.
8
+ */
9
+
10
+ import { defined, isNonEmptyString, isNumber, isObject, isString } from "./decode.js";
11
+
12
+ export const MODELS_DEV_URL = "https://models.dev/api.json";
13
+
14
+ export const MODELS_DEV_TIMEOUT_MS = 30000;
15
+
16
+ // Pi provider id -> models.dev namespace. Deliberately small: identity and
17
+ // global exact-id fallback below cover the rest without guessing.
18
+ const PI_TO_MODELS_DEV = {
19
+ anthropic: "anthropic",
20
+ openai: "openai",
21
+ xai: "xai",
22
+ google: "google",
23
+ "google-vertex": "google-vertex",
24
+ deepseek: "deepseek",
25
+ meta: "meta",
26
+ mistral: "mistral",
27
+ moonshotai: "moonshotai",
28
+ minimax: "minimax",
29
+ groq: "groq",
30
+ cerebras: "cerebras",
31
+ together: "togetherai",
32
+ fireworks: "fireworks-ai",
33
+ openrouter: "openrouter",
34
+ zai: "zai",
35
+ cohere: "cohere",
36
+ perplexity: "perplexity",
37
+ nvidia: "nvidia",
38
+ baseten: "baseten",
39
+ huggingface: "huggingface",
40
+ ollama: "ollama-cloud",
41
+ };
42
+
43
+ export function modelsDevNamespace(piProviderId) {
44
+ return PI_TO_MODELS_DEV[piProviderId] ?? piProviderId;
45
+ }
46
+
47
+ function namespaceModels(catalog, namespace) {
48
+ if (!isObject(catalog)) {
49
+ return undefined;
50
+ }
51
+
52
+ const provider = catalog[namespace];
53
+
54
+ if (!isObject(provider)) {
55
+ return undefined;
56
+ }
57
+
58
+ return isObject(provider.models) ? provider.models : undefined;
59
+ }
60
+
61
+ // Exact-id lookup: mapped namespace first, then a lazily built global
62
+ // index. Model ids are globally distinctive (vendor/model or bare names);
63
+ // a global exact hit beats guessing a namespace mapping. The index builds
64
+ // once per catalog object (WeakMap-held, so catalogs still GC) instead of
65
+ // scanning 223 namespaces per model. First-hit-wins in namespace order,
66
+ // exactly like the scan it replaces.
67
+ const globalIndexCache = new WeakMap();
68
+
69
+ function globalIndex(catalog) {
70
+ const cached = globalIndexCache.get(catalog);
71
+
72
+ if (cached !== undefined) {
73
+ return cached;
74
+ }
75
+
76
+ const index = new Map();
77
+
78
+ for (const namespace of Object.keys(catalog)) {
79
+ const models = namespaceModels(catalog, namespace);
80
+
81
+ if (models === undefined) {
82
+ continue;
83
+ }
84
+
85
+ for (const id of Object.keys(models)) {
86
+ if (!index.has(id)) {
87
+ index.set(id, models[id]);
88
+ }
89
+ }
90
+ }
91
+
92
+ globalIndexCache.set(catalog, index);
93
+
94
+ return index;
95
+ }
96
+
97
+ function findEntry(catalog, piProviderId, modelId) {
98
+ const namespaced = namespaceModels(catalog, modelsDevNamespace(piProviderId));
99
+
100
+ if (namespaced !== undefined && namespaced[modelId] !== undefined) {
101
+ return namespaced[modelId];
102
+ }
103
+
104
+ if (!isObject(catalog)) {
105
+ return undefined;
106
+ }
107
+
108
+ return globalIndex(catalog).get(modelId);
109
+ }
110
+
111
+ function finiteNumber(value) {
112
+ if (isNumber(value)) {
113
+ return value >= 0 ? value : undefined;
114
+ }
115
+
116
+ if (isString(value) && value.trim() !== "") {
117
+ const parsed = Number(value);
118
+
119
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
120
+ }
121
+
122
+ return undefined;
123
+ }
124
+
125
+ // Context windows must be positive: Pi rejects zero-window models at
126
+ // composition time, which would take down the whole provider. Costs may
127
+ // legitimately be 0 (free tiers), so they keep finiteNumber.
128
+ function positiveNumber(value) {
129
+ const number = finiteNumber(value);
130
+
131
+ return number !== undefined && number > 0 ? number : undefined;
132
+ }
133
+
134
+ function asRecord(value) {
135
+ return isObject(value) ? value : undefined;
136
+ }
137
+
138
+ function textImageInput(modalities) {
139
+ const input = asRecord(modalities)?.input;
140
+
141
+ if (!Array.isArray(input)) {
142
+ return undefined;
143
+ }
144
+
145
+ const kept = [...new Set(input.filter((modality) => modality === "text" || modality === "image"))];
146
+
147
+ return kept.length > 0 ? kept : undefined;
148
+ }
149
+
150
+ // Effort value list from reasoning_options, or null when the source says
151
+ // nothing usable. "toggle" dialects and unknown shapes yield null.
152
+ function effortValues(reasoningOptions) {
153
+ if (!Array.isArray(reasoningOptions)) {
154
+ return null;
155
+ }
156
+
157
+ for (const option of reasoningOptions) {
158
+ const entry = asRecord(option);
159
+
160
+ if (entry?.type !== "effort" || !Array.isArray(entry.values)) {
161
+ continue;
162
+ }
163
+
164
+ const values = [...new Set(entry.values.filter(isNonEmptyString))];
165
+
166
+ if (values.length > 0) {
167
+ return values;
168
+ }
169
+ }
170
+
171
+ return null;
172
+ }
173
+
174
+ // Cost legs: each output leg reads every alias from live pricing first,
175
+ // then the models.dev entry. Unknown legs default to 0 once any leg is
176
+ // known (Pi's own zero-cost default shape); all-unknown yields undefined.
177
+ const COST_LEGS = [
178
+ ["input", ["input"]],
179
+ ["output", ["output"]],
180
+ ["cacheRead", ["cache_read", "cacheRead"]],
181
+ ["cacheWrite", ["cache_write", "cacheWrite"]],
182
+ ];
183
+
184
+ // Gateway list prices are USD/token; Pi and models.dev use USD/million tokens.
185
+ // Normalize only this known wire schema, never scale models.dev fallbacks.
186
+ function gatewayCosts(pricing) {
187
+ const cost = {};
188
+
189
+ for (const [leg, field] of [
190
+ ["input", "input"], ["output", "output"],
191
+ ["cacheRead", "input_cache_read"], ["cacheWrite", "input_cache_write"],
192
+ ]) {
193
+ const value = finiteNumber(pricing[field]);
194
+
195
+ if (value !== undefined) cost[leg] = value * 1_000_000;
196
+ }
197
+
198
+ return cost;
199
+ }
200
+
201
+ function costsOf(live, entry, piProviderId) {
202
+ const pricing = asRecord(live?.pricing);
203
+
204
+ const liveCost = piProviderId === "vercel-ai-gateway" && pricing !== undefined
205
+ ? gatewayCosts(pricing) : asRecord(live?.pricing ?? asRecord(live?.cost));
206
+
207
+ const sources = [liveCost, asRecord(entry?.cost)];
208
+
209
+ let known = false;
210
+ const cost = {};
211
+
212
+ for (const [leg, aliases] of COST_LEGS) {
213
+ const value = finiteNumber(firstPresent(sources, aliases));
214
+
215
+ if (value !== undefined) {
216
+ known = true;
217
+ }
218
+
219
+ cost[leg] = value ?? 0;
220
+ }
221
+
222
+ return known ? cost : undefined;
223
+ }
224
+
225
+ // First non-nullish value across sources and aliases, in order. Separates
226
+ // selection from finiteNumber validation exactly like the ?? chain did:
227
+ // an invalid first hit still blocks later aliases.
228
+ function firstPresent(sources, aliases) {
229
+ for (const source of sources) {
230
+ for (const alias of aliases) {
231
+ const value = source?.[alias];
232
+
233
+ if (value !== undefined && value !== null) {
234
+ return value;
235
+ }
236
+ }
237
+ }
238
+
239
+ return undefined;
240
+ }
241
+
242
+ function displayNameOf(modelId) {
243
+ const bare = modelId.includes("/") ? modelId.slice(modelId.indexOf("/") + 1) : modelId;
244
+
245
+ const titled = bare
246
+ .split(/[-_]/)
247
+ .map((word) => (word === "" ? word : word[0].toUpperCase() + word.slice(1)))
248
+ .join(" ")
249
+ .trim();
250
+
251
+ // Pi schema rejects name:"" (minLength 1) and would fail the whole file.
252
+ return titled !== "" ? titled : modelId;
253
+ }
254
+
255
+ // Output modalities when either source states them. Used to drop video,
256
+ // image, and embedding models Pi cannot drive; unknown stays syncable.
257
+ function outputModalities(live, entry) {
258
+ for (const source of [live, entry]) {
259
+ const output = asRecord(source?.modalities)?.output;
260
+
261
+ if (Array.isArray(output) && output.length > 0) {
262
+ return output;
263
+ }
264
+ }
265
+
266
+ return undefined;
267
+ }
268
+
269
+ // Merge live endpoint metadata with the models.dev entry into Pi model
270
+ // fields. Every field is omitted when neither source knows it. Returns
271
+ // undefined for models whose output is known non-text (video, image,
272
+ // embeddings): selectable-but-broken entries help nobody.
273
+ function limitsOf(live, entry) {
274
+ const entryLimit = asRecord(entry?.limit) ?? {};
275
+
276
+ return {
277
+ contextWindow: positiveNumber(live.context_window ?? live.contextWindow ?? live.inputTokenLimit ?? entryLimit.context),
278
+ maxTokens: positiveNumber(live.max_tokens ?? live.maxTokens ?? live.outputTokenLimit ?? entryLimit.output),
279
+ };
280
+ }
281
+
282
+ function reasoningOf(live, entry) {
283
+ const liveEfforts = effortValues(live.reasoning_options);
284
+ const liveTags = Array.isArray(live.tags) ? live.tags : [];
285
+
286
+ return {
287
+ reasoning: liveTags.includes("reasoning") || liveEfforts !== null || entry?.reasoning === true,
288
+ explicitEfforts: liveEfforts ?? effortValues(entry?.reasoning_options),
289
+ };
290
+ }
291
+
292
+ function trainingOf(live, entry) {
293
+ const retained = live.no_training === "none" || live.zdr === "none" || entry?.no_training === "none";
294
+
295
+ return retained ? true : undefined;
296
+ }
297
+
298
+ // Vendor display names win: Anthropic ships display_name, Google ships
299
+ // displayName while its name is a "models/..." resource path, never a
300
+ // label. Resource paths fall through to the title-cased model id.
301
+ function displayNameOfLive(live, entry, modelId) {
302
+ const liveName = live.display_name ?? live.displayName ?? live.name ?? entry?.name;
303
+
304
+ return isNonEmptyString(liveName) && !liveName.startsWith("models/") ? liveName : displayNameOf(modelId);
305
+ }
306
+
307
+ export function enrichModel(catalog, piProviderId, modelId, liveMeta) {
308
+ const entry = findEntry(catalog, piProviderId, modelId);
309
+ const live = asRecord(liveMeta) ?? {};
310
+ const output = outputModalities(live, entry);
311
+ // Google's list advertises API methods, not output modalities. Embedding
312
+ // and predict-only models cannot serve Pi's generateContent chat requests.
313
+ const methods = live.supportedGenerationMethods;
314
+
315
+ if (Array.isArray(methods) && methods.length > 0 && !methods.includes("generateContent")) {
316
+ return undefined;
317
+ }
318
+
319
+ if (output !== undefined && !output.includes("text")) {
320
+ return undefined;
321
+ }
322
+
323
+ const limits = limitsOf(live, entry);
324
+ const thinking = reasoningOf(live, entry);
325
+
326
+ return defined({
327
+ name: displayNameOfLive(live, entry, modelId),
328
+ contextWindow: limits.contextWindow,
329
+ maxTokens: limits.maxTokens,
330
+ cost: costsOf(live, entry, piProviderId),
331
+ input: textImageInput(live.modalities) ?? textImageInput(entry?.modalities),
332
+ reasoning: thinking.reasoning,
333
+ explicitEfforts: thinking.explicitEfforts ?? undefined,
334
+ trainingRetained: trainingOf(live, entry),
335
+ });
336
+ }
337
+
338
+ // Fetch the whole catalog. One 5MB call per sync run; callers treat failure
339
+ // as enrichment-offline (live metadata only), never fatal.
340
+ export async function fetchModelsDevCatalog(fetchImpl, userAgent) {
341
+ const response = await fetchImpl(MODELS_DEV_URL, {
342
+ headers: { Accept: "application/json", "User-Agent": userAgent },
343
+ signal: AbortSignal.timeout(MODELS_DEV_TIMEOUT_MS),
344
+ });
345
+
346
+ if (!response.ok) {
347
+ throw new Error(`models.dev catalog failed (HTTP ${response.status})`);
348
+ }
349
+
350
+ const catalog = await response.json();
351
+
352
+ if (!isObject(catalog)) {
353
+ throw new Error("models.dev catalog returned an unexpected shape");
354
+ }
355
+
356
+ return catalog;
357
+ }
package/lib/store.js ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * pi-model-sync models.json merge.
3
+ *
4
+ * Ownership is by tag and operation: stamped chat entries are ours to update
5
+ * and prune; non-chat entries are always preserved, even if tagged. Pruning only
6
+ * happens for providers whose live discovery succeeded, so a dead token or
7
+ * a failed request can never wipe a catalog. A section that held only pruned
8
+ * chat entries is removed (Pi rejects `{ models: [] }` with no other keys).
9
+ * A corrupt models.json aborts the run instead of being clobbered. Reads
10
+ * accept Pi's JSONC dialect (BOM, // comments, trailing commas); writes are
11
+ * strict JSON.
12
+ */
13
+
14
+ import { randomUUID } from "node:crypto";
15
+ import { isNonEmptyString, isObject } from "./decode.js";
16
+
17
+ export const MANAGED_BY = "pi-model-sync";
18
+
19
+ function stripBom(content) {
20
+ return content.startsWith("\uFEFF") ? content.slice(1) : content;
21
+ }
22
+
23
+ // Same dialect Pi uses for models.json: // comments and trailing commas,
24
+ // with string literals left intact.
25
+ function stripJsonComments(input) {
26
+ return input
27
+ .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
28
+ .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : ""));
29
+ }
30
+
31
+ function canonicalize(value) {
32
+ if (Array.isArray(value)) {
33
+ const items = [];
34
+
35
+ for (const item of value) {
36
+ items.push(canonicalize(item));
37
+ }
38
+
39
+ return `[${items.join(",")}]`;
40
+ }
41
+
42
+ if (isObject(value)) {
43
+ const keys = Object.keys(value).sort();
44
+ const parts = [];
45
+
46
+ for (const key of keys) {
47
+ parts.push(`${JSON.stringify(key)}:${canonicalize(value[key])}`);
48
+ }
49
+
50
+ return `{${parts.join(",")}}`;
51
+ }
52
+
53
+ return JSON.stringify(value) ?? "null";
54
+ }
55
+
56
+ function withoutTag(entry) {
57
+ const copy = { ...entry };
58
+ delete copy._managedBy;
59
+
60
+ return copy;
61
+ }
62
+
63
+ function isChatEntry(value) {
64
+ return isObject(value) && "id" in value && (value.type === undefined || value.type === "chat");
65
+ }
66
+
67
+ export function readModelsFile(modelsPath, fs) {
68
+ let text;
69
+
70
+ try {
71
+ text = fs.readFileSync(modelsPath, "utf8");
72
+ } catch (error) {
73
+ if (error?.code === "ENOENT") {
74
+ return { providers: {} };
75
+ }
76
+
77
+ throw error;
78
+ }
79
+
80
+ let doc;
81
+
82
+ try {
83
+ doc = JSON.parse(stripJsonComments(stripBom(text)));
84
+ } catch {
85
+ throw new Error(`models.json is corrupt (${modelsPath}); refusing to write`);
86
+ }
87
+
88
+ if (!isObject(doc) || (doc.providers !== undefined && !isObject(doc.providers))) {
89
+ throw new Error(`models.json is corrupt (${modelsPath}); refusing to write`);
90
+ }
91
+
92
+ return { providers: {}, ...doc };
93
+ }
94
+
95
+ // Match one live entry against existing models. Returns the model to
96
+ // store plus which counter it bumps: new ids are added tagged, untagged
97
+ // residents and identical managed copies are kept as-is, changed managed
98
+ // copies refresh in place.
99
+ function carryEntry(existing, entry, consumed) {
100
+ const current = existing.find((item) => isChatEntry(item) && item.id === entry.id);
101
+
102
+ if (current === undefined) {
103
+ return { model: { ...entry, _managedBy: MANAGED_BY }, tally: "added" };
104
+ }
105
+
106
+ consumed.add(current);
107
+
108
+ if (current._managedBy !== MANAGED_BY) {
109
+ return { model: current, tally: "kept" };
110
+ }
111
+
112
+ if (canonicalize(withoutTag(current)) === canonicalize(entry)) {
113
+ return { model: current, tally: "kept" };
114
+ }
115
+
116
+ return { model: { ...entry, _managedBy: MANAGED_BY }, tally: "updated" };
117
+ }
118
+
119
+ // Sweep existing models the live list did not carry: unrecognized shapes
120
+ // and user entries stay, managed strays prune only on success.
121
+ function sweepStale(existing, consumed, succeeded, nextModels) {
122
+ let removed = 0;
123
+ let kept = 0;
124
+
125
+ for (const item of existing) {
126
+ if (!isChatEntry(item)) {
127
+ // Chat discovery establishes nothing about another operation's catalog.
128
+ nextModels.push(item);
129
+ kept += 1;
130
+
131
+ continue;
132
+ }
133
+
134
+ if (consumed.has(item)) {
135
+ continue;
136
+ }
137
+
138
+ if (item._managedBy === MANAGED_BY && succeeded) {
139
+ removed += 1;
140
+ } else {
141
+ nextModels.push(item);
142
+ kept += 1;
143
+ }
144
+ }
145
+
146
+ return { removed, kept };
147
+ }
148
+
149
+ // One provider's plan: entries are built model definitions (untagged).
150
+ // succeeded gates pruning: unknown state never deletes.
151
+ // residentIds are chat ids already composed outside this file (builtin /
152
+ // extension seeds). Writing them as models.json overlays replaces Pi's
153
+ // curated definition (compat, input, thinking maps). Refresh in place only
154
+ // when the id is already a file resident.
155
+ export function planProviderUpdate(doc, providerId, entries, succeeded, residentIds) {
156
+ const section = doc.providers?.[providerId];
157
+ const existing = Array.isArray(section?.models) ? section.models : [];
158
+ const nextModels = [];
159
+ const seen = new Set();
160
+ // Existing objects already carried over. Identity, not id: duplicate
161
+ // untagged entries share an id but each is user data to preserve.
162
+ const consumed = new Set();
163
+ const residents = new Set(residentIds ?? []);
164
+ const counts = { added: 0, updated: 0, removed: 0, kept: 0 };
165
+
166
+ for (const entry of entries) {
167
+ if (!isChatEntry(entry) || !isNonEmptyString(entry.id) || seen.has(entry.id)) {
168
+ continue;
169
+ }
170
+
171
+ seen.add(entry.id);
172
+
173
+ if (residents.has(entry.id) && !existing.some((item) => isChatEntry(item) && item.id === entry.id)) {
174
+ continue;
175
+ }
176
+
177
+ const carried = carryEntry(existing, entry, consumed);
178
+ nextModels.push(carried.model);
179
+ counts[carried.tally] += 1;
180
+ }
181
+
182
+ const swept = sweepStale(existing, consumed, succeeded, nextModels);
183
+ counts.removed += swept.removed;
184
+ counts.kept += swept.kept;
185
+
186
+ // Preserve provider-section keys the sync does not own (modelOverrides...).
187
+ // A section that held only pruned chat entries is removed, matching the
188
+ // orphan sweep: Pi composition-errors on `{ models: [] }` with no other keys.
189
+ const nextProviders = { ...doc.providers };
190
+
191
+ if (nextModels.length > 0) {
192
+ nextProviders[providerId] = { ...section, models: nextModels };
193
+ } else if (isObject(section)) {
194
+ const rest = Object.keys(section).filter((key) => key !== "models");
195
+
196
+ if (rest.length === 0) {
197
+ delete nextProviders[providerId];
198
+ } else {
199
+ nextProviders[providerId] = { ...section, models: nextModels };
200
+ }
201
+ }
202
+
203
+ return { next: { ...doc, providers: nextProviders }, ...counts };
204
+ }
205
+
206
+ function backupStamp(when) {
207
+ return when.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
208
+ }
209
+
210
+ export function backupPathFor(modelsPath, when = new Date()) {
211
+ return `${modelsPath}.bak-${backupStamp(when)}`;
212
+ }
213
+
214
+ // Drop managed entries for providers absent from the registry (extension
215
+ // uninstalled: Pi composition errors on the orphan section every startup,
216
+ // and the entries can never sync again). Untagged entries always stay; a
217
+ // section that held only swept entries is removed, while sections with
218
+ // user keys (baseUrl, headers...) keep their shape. Returns {next, swept}
219
+ // with swept sorted by provider for deterministic reports.
220
+ export function planOrphanSweep(doc, knownIds) {
221
+ const known = new Set(knownIds);
222
+ const swept = [];
223
+ const nextProviders = { ...doc.providers };
224
+
225
+ for (const providerId of Object.keys(nextProviders).sort()) {
226
+ if (known.has(providerId)) {
227
+ continue;
228
+ }
229
+
230
+ const section = nextProviders[providerId];
231
+
232
+ if (!isObject(section) || !Array.isArray(section.models)) {
233
+ continue;
234
+ }
235
+
236
+ const kept = section.models.filter((item) => !isChatEntry(item) || item._managedBy !== MANAGED_BY);
237
+ const removed = section.models.length - kept.length;
238
+
239
+ if (removed === 0) {
240
+ continue;
241
+ }
242
+
243
+ swept.push({ providerId, removed });
244
+
245
+ const rest = Object.keys(section).filter((key) => key !== "models");
246
+
247
+ if (kept.length === 0 && rest.length === 0) {
248
+ delete nextProviders[providerId];
249
+ } else {
250
+ nextProviders[providerId] = { ...section, models: kept };
251
+ }
252
+ }
253
+
254
+ return { next: { ...doc, providers: nextProviders }, swept };
255
+ }
256
+
257
+ function publicationTarget(modelsPath, fs) {
258
+ try {
259
+ fs.lstatSync(modelsPath);
260
+ } catch (error) {
261
+ if (error?.code === "ENOENT") return { path: modelsPath, mode: 0o600 };
262
+
263
+ throw error;
264
+ }
265
+
266
+ // Follow existing symlinks rather than replacing the user's link. A dangling
267
+ // link is not a missing file: realpath must reject it before any publication.
268
+ const path = fs.realpathSync.native(modelsPath);
269
+ const info = fs.statSync(path);
270
+
271
+ if (!info.isFile()) throw new Error("models.json must be a regular file");
272
+
273
+ fs.accessSync(path, fs.constants.W_OK);
274
+
275
+ return { path, mode: info.mode & 0o777 };
276
+ }
277
+
278
+ function backUpModels(modelsPath, fs) {
279
+ const first = backupPathFor(modelsPath);
280
+
281
+ for (let suffix = 1; ; suffix += 1) {
282
+ const path = suffix === 1 ? first : `${first}-${suffix}`;
283
+
284
+ try {
285
+ fs.copyFileSync(modelsPath, path, fs.constants.COPYFILE_EXCL);
286
+
287
+ return path;
288
+ } catch (error) {
289
+ if (error?.code === "EEXIST") continue;
290
+
291
+ if (error?.code === "ENOENT") return null;
292
+
293
+ throw error;
294
+ }
295
+ }
296
+ }
297
+
298
+ // Synchronous merge callers cannot interleave in this process. Staging keeps
299
+ // partial writes away from the live file; this is not external-writer CAS.
300
+ export function writeModelsFile(modelsPath, doc, fs) {
301
+ const text = `${JSON.stringify(doc, null, 2)}\n`;
302
+ const target = publicationTarget(modelsPath, fs);
303
+ const backupPath = backUpModels(modelsPath, fs);
304
+ const stagedPath = `${target.path}.tmp-${randomUUID()}`;
305
+ let owned = false;
306
+
307
+ try {
308
+ const fd = fs.openSync(stagedPath, "wx", target.mode);
309
+ owned = true;
310
+
311
+ try {
312
+ fs.fchmodSync(fd, target.mode);
313
+ fs.writeFileSync(fd, text, "utf8");
314
+ fs.fsyncSync(fd);
315
+ } finally {
316
+ fs.closeSync(fd);
317
+ }
318
+
319
+ fs.renameSync(stagedPath, target.path);
320
+ owned = false;
321
+ } finally {
322
+ if (owned) fs.unlinkSync(stagedPath);
323
+ }
324
+
325
+ return { backupPath };
326
+ }