@xano/cli 1.0.6-beta.2 → 1.0.6-beta.20
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/README.md +217 -6
- package/dist/base-command.d.ts +5 -0
- package/dist/base-command.js +61 -4
- package/dist/commands/branch/list/index.js +5 -1
- package/dist/commands/ephemeral/create/index.js +3 -3
- package/dist/commands/ephemeral/list/index.js +2 -1
- package/dist/commands/ephemeral/pull/index.d.ts +0 -6
- package/dist/commands/ephemeral/pull/index.js +10 -95
- package/dist/commands/ephemeral/push/index.d.ts +10 -0
- package/dist/commands/ephemeral/push/index.js +77 -0
- package/dist/commands/ephemeral/static_host/build/list/index.d.ts +2 -3
- package/dist/commands/ephemeral/static_host/build/list/index.js +19 -40
- package/dist/commands/ephemeral/static_host/list/index.d.ts +2 -3
- package/dist/commands/ephemeral/static_host/list/index.js +20 -40
- package/dist/commands/flatten/index.d.ts +34 -0
- package/dist/commands/flatten/index.js +140 -0
- package/dist/commands/function/list/index.d.ts +4 -4
- package/dist/commands/function/list/index.js +30 -38
- package/dist/commands/function/run/index.d.ts +1 -0
- package/dist/commands/function/run/index.js +19 -5
- package/dist/commands/knowledge/list/index.js +2 -1
- package/dist/commands/platform/list/index.js +13 -14
- package/dist/commands/release/list/index.js +13 -14
- package/dist/commands/release/pull/index.d.ts +0 -6
- package/dist/commands/release/pull/index.js +10 -95
- package/dist/commands/sandbox/env/list/index.js +4 -0
- package/dist/commands/sandbox/pull/index.d.ts +0 -1
- package/dist/commands/sandbox/pull/index.js +10 -77
- package/dist/commands/sandbox/unit_test/list/index.d.ts +2 -0
- package/dist/commands/sandbox/unit_test/list/index.js +9 -14
- package/dist/commands/sandbox/unit_test/run/index.js +17 -11
- package/dist/commands/sandbox/unit_test/run_all/index.d.ts +1 -0
- package/dist/commands/sandbox/unit_test/run_all/index.js +76 -38
- package/dist/commands/sandbox/workflow_test/list/index.d.ts +2 -0
- package/dist/commands/sandbox/workflow_test/list/index.js +9 -14
- package/dist/commands/sandbox/workflow_test/run/index.js +12 -9
- package/dist/commands/sandbox/workflow_test/run_all/index.d.ts +1 -0
- package/dist/commands/sandbox/workflow_test/run_all/index.js +74 -36
- package/dist/commands/static_host/build/list/index.d.ts +2 -3
- package/dist/commands/static_host/build/list/index.js +19 -40
- package/dist/commands/static_host/build/pull/index.js +12 -9
- package/dist/commands/static_host/list/index.d.ts +2 -3
- package/dist/commands/static_host/list/index.js +20 -40
- package/dist/commands/static_host/migrate/index.js +14 -10
- package/dist/commands/tenant/backup/list/index.js +13 -14
- package/dist/commands/tenant/cluster/list/index.js +9 -10
- package/dist/commands/tenant/create/index.d.ts +3 -0
- package/dist/commands/tenant/create/index.js +21 -0
- package/dist/commands/tenant/deploy_release/index.d.ts +21 -0
- package/dist/commands/tenant/deploy_release/index.js +94 -2
- package/dist/commands/tenant/edit/index.d.ts +2 -0
- package/dist/commands/tenant/edit/index.js +39 -4
- package/dist/commands/tenant/env/list/index.js +4 -0
- package/dist/commands/tenant/list/index.js +19 -20
- package/dist/commands/tenant/pull/index.d.ts +0 -6
- package/dist/commands/tenant/pull/index.js +10 -106
- package/dist/commands/tenant/snapshot/list/index.js +5 -1
- package/dist/commands/tenant/unit_test/list/index.d.ts +4 -2
- package/dist/commands/tenant/unit_test/list/index.js +9 -14
- package/dist/commands/tenant/unit_test/run/index.js +19 -11
- package/dist/commands/tenant/unit_test/run_all/index.d.ts +1 -0
- package/dist/commands/tenant/unit_test/run_all/index.js +80 -38
- package/dist/commands/tenant/workflow_test/list/index.d.ts +4 -2
- package/dist/commands/tenant/workflow_test/list/index.js +9 -14
- package/dist/commands/tenant/workflow_test/run/index.js +14 -9
- package/dist/commands/tenant/workflow_test/run_all/index.d.ts +1 -0
- package/dist/commands/tenant/workflow_test/run_all/index.js +78 -36
- package/dist/commands/tenant_deploy_request/bypass/index.d.ts +20 -0
- package/dist/commands/tenant_deploy_request/bypass/index.js +79 -0
- package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
- package/dist/commands/tenant_deploy_request/create/index.js +108 -0
- package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
- package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
- package/dist/commands/tenant_deploy_request/get/index.d.ts +19 -0
- package/dist/commands/tenant_deploy_request/get/index.js +75 -0
- package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
- package/dist/commands/tenant_deploy_request/list/index.js +122 -0
- package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
- package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
- package/dist/commands/tenant_deploy_request/set_status/index.d.ts +21 -0
- package/dist/commands/tenant_deploy_request/set_status/index.js +112 -0
- package/dist/commands/unit_test/list/index.d.ts +3 -1
- package/dist/commands/unit_test/list/index.js +9 -14
- package/dist/commands/unit_test/run/index.js +19 -11
- package/dist/commands/unit_test/run_all/index.d.ts +1 -0
- package/dist/commands/unit_test/run_all/index.js +81 -41
- package/dist/commands/workflow_test/list/index.d.ts +3 -1
- package/dist/commands/workflow_test/list/index.js +10 -13
- package/dist/commands/workflow_test/run/index.js +15 -10
- package/dist/commands/workflow_test/run_all/index.d.ts +1 -0
- package/dist/commands/workflow_test/run_all/index.js +82 -39
- package/dist/commands/workspace/git/pull/index.js +48 -7
- package/dist/commands/workspace/list/index.js +7 -13
- package/dist/commands/workspace/pull/index.d.ts +0 -6
- package/dist/commands/workspace/pull/index.js +10 -115
- package/dist/utils/concurrency.d.ts +29 -0
- package/dist/utils/concurrency.js +54 -0
- package/dist/utils/document-parser.d.ts +90 -0
- package/dist/utils/document-parser.js +273 -1
- package/dist/utils/flatten.d.ts +36 -0
- package/dist/utils/flatten.js +68 -0
- package/dist/utils/knowledge-sync.d.ts +1 -0
- package/dist/utils/knowledge-sync.js +11 -5
- package/dist/utils/microservice-wait.d.ts +53 -0
- package/dist/utils/microservice-wait.js +83 -0
- package/dist/utils/multidoc-push.d.ts +88 -0
- package/dist/utils/multidoc-push.js +377 -8
- package/dist/utils/paging.d.ts +145 -0
- package/dist/utils/paging.js +199 -0
- package/dist/utils/reference-checker.js +13 -0
- package/oclif.manifest.json +4999 -3973
- package/package.json +1 -1
|
@@ -14,10 +14,4 @@ export default class Pull extends BaseCommand {
|
|
|
14
14
|
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
15
|
};
|
|
16
16
|
run(): Promise<void>;
|
|
17
|
-
/**
|
|
18
|
-
* Sanitize a document name for use as a filename.
|
|
19
|
-
* Strips quotes, replaces spaces with underscores, and removes
|
|
20
|
-
* characters that are unsafe in filenames.
|
|
21
|
-
*/
|
|
22
|
-
private sanitizeFilename;
|
|
23
17
|
}
|
|
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import snakeCase from 'lodash.snakecase';
|
|
5
5
|
import BaseCommand from '../../../base-command.js';
|
|
6
|
-
import { buildApiGroupFolderResolver,
|
|
6
|
+
import { buildApiGroupFolderResolver, buildChannelServerResolver, parseDocument, resolveDocumentPath, } from '../../../utils/document-parser.js';
|
|
7
7
|
import { fetchKnowledge, writeKnowledge } from '../../../utils/knowledge-sync.js';
|
|
8
8
|
export default class Pull extends BaseCommand {
|
|
9
9
|
static description = 'Pull a workspace multidoc from the Xano Metadata API and split into individual files';
|
|
@@ -134,116 +134,19 @@ Pulled 58 documents
|
|
|
134
134
|
// Resolve api_group names to unique folder names, disambiguating collisions
|
|
135
135
|
// where different names produce the same snakeCase (e.g., "Authentication" vs "authentication")
|
|
136
136
|
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
137
|
+
// Resolve a realtime v2 channel path -> owning realtime_server name, so
|
|
138
|
+
// messages can nest under their channel's server (see resolver docs).
|
|
139
|
+
const getChannelServer = buildChannelServerResolver(documents);
|
|
137
140
|
// Track filenames per type to handle duplicates
|
|
138
141
|
const filenameCounters = new Map();
|
|
139
142
|
let writtenCount = 0;
|
|
140
143
|
for (const doc of documents) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
else if (doc.type === 'workspace_trigger') {
|
|
149
|
-
// workspace_trigger → workspace/trigger/{name}.xs
|
|
150
|
-
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
151
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
152
|
-
}
|
|
153
|
-
else if (doc.type === 'error_trigger') {
|
|
154
|
-
// error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
|
|
155
|
-
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
156
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
157
|
-
}
|
|
158
|
-
else if (doc.type === 'agent') {
|
|
159
|
-
// agent → ai/agent/{name}.xs
|
|
160
|
-
typeDir = path.join(outputDir, 'ai', 'agent');
|
|
161
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
162
|
-
}
|
|
163
|
-
else if (doc.type === 'mcp_server') {
|
|
164
|
-
// mcp_server → ai/mcp_server/{name}.xs
|
|
165
|
-
typeDir = path.join(outputDir, 'ai', 'mcp_server');
|
|
166
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
167
|
-
}
|
|
168
|
-
else if (doc.type === 'tool') {
|
|
169
|
-
// tool → ai/tool/{name}.xs
|
|
170
|
-
typeDir = path.join(outputDir, 'ai', 'tool');
|
|
171
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
172
|
-
}
|
|
173
|
-
else if (doc.type === 'agent_trigger') {
|
|
174
|
-
// agent_trigger → ai/agent/trigger/{name}.xs
|
|
175
|
-
typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
|
|
176
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
177
|
-
}
|
|
178
|
-
else if (doc.type === 'mcp_server_trigger') {
|
|
179
|
-
// mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
|
|
180
|
-
typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
|
|
181
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
182
|
-
}
|
|
183
|
-
else if (doc.type === 'table_trigger') {
|
|
184
|
-
// table_trigger → table/trigger/{name}.xs
|
|
185
|
-
typeDir = path.join(outputDir, 'table', 'trigger');
|
|
186
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
187
|
-
}
|
|
188
|
-
else if (doc.type === 'realtime_channel') {
|
|
189
|
-
// realtime_channel → realtime/channel/{name}.xs
|
|
190
|
-
typeDir = path.join(outputDir, 'realtime', 'channel');
|
|
191
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
192
|
-
}
|
|
193
|
-
else if (doc.type === 'realtime_trigger') {
|
|
194
|
-
// realtime_trigger → realtime/trigger/{name}.xs
|
|
195
|
-
typeDir = path.join(outputDir, 'realtime', 'trigger');
|
|
196
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
197
|
-
}
|
|
198
|
-
else if (doc.type === 'channel') {
|
|
199
|
-
// Realtime v2. The channel owns a directory mirroring its path and its
|
|
200
|
-
// messages live inside it, so ownership is visible in the tree.
|
|
201
|
-
// channel "rooms/{room_id}" → channel/rooms/[room_id]/_channel.xs
|
|
202
|
-
//
|
|
203
|
-
// The document uses a FIXED "_channel.xs" leaf: naming it after the
|
|
204
|
-
// last segment yields "[room_id].xs", which names the parameter rather
|
|
205
|
-
// than the channel. A fixed leaf also sorts first, making "the channel
|
|
206
|
-
// vs its messages" obvious.
|
|
207
|
-
typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
|
|
208
|
-
baseName = '_channel';
|
|
209
|
-
}
|
|
210
|
-
else if (doc.type === 'message' && doc.channel) {
|
|
211
|
-
// Realtime v2 message → channel/{channel_path}/{message_name}.xs
|
|
212
|
-
// Nesting matters beyond tidiness: message names are unique only WITHIN
|
|
213
|
-
// a channel, so a flat message/ directory would collide when two
|
|
214
|
-
// channels both define e.g. "say".
|
|
215
|
-
typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
|
|
216
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
217
|
-
}
|
|
218
|
-
else if (doc.type === 'api_group') {
|
|
219
|
-
// api_group "test" → api/{resolved_folder}/{name}.xs
|
|
220
|
-
const groupFolder = getApiGroupFolder(doc.name);
|
|
221
|
-
typeDir = path.join(outputDir, 'api', groupFolder);
|
|
222
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
223
|
-
}
|
|
224
|
-
else if (doc.type === 'query' && doc.apiGroup) {
|
|
225
|
-
// query in group "test" → api/{resolved_folder}/{query_name}.xs
|
|
226
|
-
const groupFolder = getApiGroupFolder(doc.apiGroup);
|
|
227
|
-
const nameParts = doc.name.split('/');
|
|
228
|
-
const leafName = nameParts.pop();
|
|
229
|
-
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
230
|
-
typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
|
|
231
|
-
baseName = this.sanitizeFilename(leafName);
|
|
232
|
-
if (doc.verb) {
|
|
233
|
-
baseName = `${baseName}_${doc.verb}`;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
else {
|
|
237
|
-
// Default: split folder path from name
|
|
238
|
-
const nameParts = doc.name.split('/');
|
|
239
|
-
const leafName = nameParts.pop();
|
|
240
|
-
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
241
|
-
typeDir = path.join(outputDir, doc.type, ...folderParts);
|
|
242
|
-
baseName = this.sanitizeFilename(leafName);
|
|
243
|
-
if (doc.verb) {
|
|
244
|
-
baseName = `${baseName}_${doc.verb}`;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
144
|
+
const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
|
|
145
|
+
getApiGroupFolder,
|
|
146
|
+
getChannelServer,
|
|
147
|
+
join: path.join,
|
|
148
|
+
snakeCase,
|
|
149
|
+
});
|
|
247
150
|
fs.mkdirSync(typeDir, { recursive: true });
|
|
248
151
|
// Track duplicates per directory
|
|
249
152
|
const dirKey = path.relative(outputDir, typeDir);
|
|
@@ -272,12 +175,4 @@ Pulled 58 documents
|
|
|
272
175
|
parts.push(`${knowledgeCount} knowledge file${knowledgeCount === 1 ? '' : 's'}`);
|
|
273
176
|
this.log(`Pulled ${parts.join(' + ')} to ${flags.directory}`);
|
|
274
177
|
}
|
|
275
|
-
/**
|
|
276
|
-
* Sanitize a document name for use as a filename.
|
|
277
|
-
* Strips quotes, replaces spaces with underscores, and removes
|
|
278
|
-
* characters that are unsafe in filenames.
|
|
279
|
-
*/
|
|
280
|
-
sanitizeFilename(name) {
|
|
281
|
-
return snakeCase(name.replaceAll('"', ''));
|
|
282
|
-
}
|
|
283
178
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `worker` over `items` with at most `limit` in flight at once.
|
|
3
|
+
*
|
|
4
|
+
* Results come back in **input order**, not completion order. That matters for
|
|
5
|
+
* test runners: a concurrent run must still produce output a human can diff
|
|
6
|
+
* against a sequential one, and a JSON results array whose order does not shift
|
|
7
|
+
* between runs.
|
|
8
|
+
*
|
|
9
|
+
* `limit` is clamped to at least 1, so a limit of 1 is exactly sequential
|
|
10
|
+
* execution and callers do not need a separate code path for it.
|
|
11
|
+
*
|
|
12
|
+
* The worker is expected to capture its own failures — one rejected worker
|
|
13
|
+
* rejects the whole call. Callers that want per-item error reporting (as the
|
|
14
|
+
* test runners do) should resolve a failure result instead of throwing.
|
|
15
|
+
*/
|
|
16
|
+
export declare function mapWithConcurrency<T, R>(items: T[], limit: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
17
|
+
/**
|
|
18
|
+
* Emit results in input order as soon as each becomes available, without
|
|
19
|
+
* waiting for the whole batch.
|
|
20
|
+
*
|
|
21
|
+
* Progress lines are the reason this exists: with concurrency the third test may
|
|
22
|
+
* finish before the first, but printing them out of order makes the output hard
|
|
23
|
+
* to follow and non-deterministic between runs. This buffers out-of-order
|
|
24
|
+
* completions and flushes the moment the next expected index arrives, so the
|
|
25
|
+
* user sees an ordered stream that still starts before the run ends.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createOrderedEmitter<R>(onReady: (result: R, index: number) => void): {
|
|
28
|
+
settle(index: number, result: R): void;
|
|
29
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `worker` over `items` with at most `limit` in flight at once.
|
|
3
|
+
*
|
|
4
|
+
* Results come back in **input order**, not completion order. That matters for
|
|
5
|
+
* test runners: a concurrent run must still produce output a human can diff
|
|
6
|
+
* against a sequential one, and a JSON results array whose order does not shift
|
|
7
|
+
* between runs.
|
|
8
|
+
*
|
|
9
|
+
* `limit` is clamped to at least 1, so a limit of 1 is exactly sequential
|
|
10
|
+
* execution and callers do not need a separate code path for it.
|
|
11
|
+
*
|
|
12
|
+
* The worker is expected to capture its own failures — one rejected worker
|
|
13
|
+
* rejects the whole call. Callers that want per-item error reporting (as the
|
|
14
|
+
* test runners do) should resolve a failure result instead of throwing.
|
|
15
|
+
*/
|
|
16
|
+
export async function mapWithConcurrency(items, limit, worker) {
|
|
17
|
+
const effectiveLimit = Math.max(1, Math.floor(limit));
|
|
18
|
+
const results = Array.from({ length: items.length });
|
|
19
|
+
let cursor = 0;
|
|
20
|
+
const runners = Array.from({ length: Math.min(effectiveLimit, items.length) }, async () => {
|
|
21
|
+
while (cursor < items.length) {
|
|
22
|
+
const index = cursor;
|
|
23
|
+
cursor++;
|
|
24
|
+
// eslint-disable-next-line no-await-in-loop
|
|
25
|
+
results[index] = await worker(items[index], index);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
await Promise.all(runners);
|
|
29
|
+
return results;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Emit results in input order as soon as each becomes available, without
|
|
33
|
+
* waiting for the whole batch.
|
|
34
|
+
*
|
|
35
|
+
* Progress lines are the reason this exists: with concurrency the third test may
|
|
36
|
+
* finish before the first, but printing them out of order makes the output hard
|
|
37
|
+
* to follow and non-deterministic between runs. This buffers out-of-order
|
|
38
|
+
* completions and flushes the moment the next expected index arrives, so the
|
|
39
|
+
* user sees an ordered stream that still starts before the run ends.
|
|
40
|
+
*/
|
|
41
|
+
export function createOrderedEmitter(onReady) {
|
|
42
|
+
const pending = new Map();
|
|
43
|
+
let next = 0;
|
|
44
|
+
return {
|
|
45
|
+
settle(index, result) {
|
|
46
|
+
pending.set(index, result);
|
|
47
|
+
while (pending.has(next)) {
|
|
48
|
+
onReady(pending.get(next), next);
|
|
49
|
+
pending.delete(next);
|
|
50
|
+
next++;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -6,6 +6,8 @@ export interface ParsedDocument {
|
|
|
6
6
|
content: string;
|
|
7
7
|
guid?: string;
|
|
8
8
|
name: string;
|
|
9
|
+
/** Owning realtime_server name for realtime v2 `channel` documents. */
|
|
10
|
+
server?: string;
|
|
9
11
|
type: string;
|
|
10
12
|
verb?: string;
|
|
11
13
|
}
|
|
@@ -38,6 +40,63 @@ export declare function parseDocument(content: string): null | ParsedDocument;
|
|
|
38
40
|
* Mirrors Vfs::channelSegment() on the server.
|
|
39
41
|
*/
|
|
40
42
|
export declare function channelPathSegments(name: string, snakeCaseFn: (s: string) => string): string[];
|
|
43
|
+
/**
|
|
44
|
+
* Build a map of realtime v2 channel path -> owning realtime_server name.
|
|
45
|
+
*
|
|
46
|
+
* This is the cross-document lookup the on-disk nesting needs: a `message`
|
|
47
|
+
* document names its channel (`channel = "..."`), while a `channel` document
|
|
48
|
+
* names its server (`realtime_server = "..."`). To place a message under
|
|
49
|
+
* `realtime/server/<server>/channel/<path>/...` we resolve the message's channel
|
|
50
|
+
* to that channel's server — which the channel document carries. (Messages now
|
|
51
|
+
* also carry `realtime_server` directly, but resolving via the channel keeps a
|
|
52
|
+
* single source of truth for the channel→server mapping.)
|
|
53
|
+
*
|
|
54
|
+
* Because every pull path parses the WHOLE multidoc into memory before writing,
|
|
55
|
+
* we can build this map from the `channel` docs in the same batch (a two-pass
|
|
56
|
+
* resolve), keyed by the channel's own name (its path). A channel with no
|
|
57
|
+
* `realtime_server` (e.g. a pre-server export) simply isn't in the map, and
|
|
58
|
+
* callers fall back accordingly.
|
|
59
|
+
*/
|
|
60
|
+
export declare function buildChannelServerResolver(documents: ParsedDocument[]): (channelName: string) => string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Sanitize a document name for use as a single on-disk filename segment.
|
|
63
|
+
* Strips quotes and snake_cases the rest. Mirrors the per-command
|
|
64
|
+
* `sanitizeFilename` the pull commands used to each define privately.
|
|
65
|
+
*/
|
|
66
|
+
export declare function sanitizeDocumentName(name: string, snakeCaseFn: (s: string) => string): string;
|
|
67
|
+
/**
|
|
68
|
+
* The directory + base filename (no extension, no duplicate suffix) a document
|
|
69
|
+
* is written to on disk. `resolveDocumentPath` returns this; callers append the
|
|
70
|
+
* `.xs` extension and any `_N` duplicate suffix.
|
|
71
|
+
*/
|
|
72
|
+
export interface DocumentPlacement {
|
|
73
|
+
baseName: string;
|
|
74
|
+
typeDir: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve where a parsed document lands on disk — the SINGLE source of truth for
|
|
78
|
+
* the pull layout, shared by every pull command (workspace, tenant, release,
|
|
79
|
+
* sandbox, ephemeral). Previously each command inlined a ~130-line copy of this
|
|
80
|
+
* decision; the copies drifted (the realtime v2 realtime_server/channel/message
|
|
81
|
+
* branches existed only in workspace/tenant), so an ephemeral pull dumped v2
|
|
82
|
+
* objects into flat top-level channel/, message/ and realtime_server/ folders —
|
|
83
|
+
* detaching messages from their channels and risking name collisions (message
|
|
84
|
+
* names are unique only WITHIN a channel).
|
|
85
|
+
*
|
|
86
|
+
* @param doc - the parsed document
|
|
87
|
+
* @param outputDir - the resolved pull output root
|
|
88
|
+
* @param deps - injected dependencies
|
|
89
|
+
* @param deps.getApiGroupFolder - resolves an api_group name to its unique folder
|
|
90
|
+
* @param deps.getChannelServer - resolves a v2 channel path to its owning realtime_server
|
|
91
|
+
* @param deps.join - the path.join implementation (injectable for testing)
|
|
92
|
+
* @param deps.snakeCase - the snakeCase implementation the caller uses
|
|
93
|
+
*/
|
|
94
|
+
export declare function resolveDocumentPath(doc: ParsedDocument, outputDir: string, deps: {
|
|
95
|
+
getApiGroupFolder: (groupName: string) => string;
|
|
96
|
+
getChannelServer: (channelName: string) => string | undefined;
|
|
97
|
+
join: (...parts: string[]) => string;
|
|
98
|
+
snakeCase: (s: string) => string;
|
|
99
|
+
}): DocumentPlacement;
|
|
41
100
|
/**
|
|
42
101
|
* Build a unique key for a document based on its type, name, verb, and api_group.
|
|
43
102
|
* Used to match server GUID map entries back to local files.
|
|
@@ -63,3 +122,34 @@ export declare function findFilesWithGuid(entries: Array<{
|
|
|
63
122
|
content: string;
|
|
64
123
|
filePath: string;
|
|
65
124
|
}>, guid: string): string[];
|
|
125
|
+
/**
|
|
126
|
+
* Split a multi-document XanoScript blob (documents joined by `\n---\n`) into
|
|
127
|
+
* parsed documents, skipping empties and any fragment that fails to parse.
|
|
128
|
+
*
|
|
129
|
+
* This is the same split the pull commands apply to the server's multidoc
|
|
130
|
+
* response. A hand-authored bundle is one file of many `---`-separated
|
|
131
|
+
* documents; this turns it back into the per-document units every downstream
|
|
132
|
+
* step (placement, GUID writeback, partial-diff matching) assumes.
|
|
133
|
+
*/
|
|
134
|
+
export declare function splitMultidoc(blob: string): ParsedDocument[];
|
|
135
|
+
/** A document's resolved on-disk location relative to the output root, plus its content. */
|
|
136
|
+
export interface PlacedDocument {
|
|
137
|
+
content: string;
|
|
138
|
+
/** POSIX-style path relative to the output root, e.g. "api/pdf/documents_GET.xs". */
|
|
139
|
+
relPath: string;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Compute where each document lands on disk — a PURE function (no filesystem)
|
|
143
|
+
* mirroring the split-and-place loop the pull commands run. It resolves each
|
|
144
|
+
* document via `resolveDocumentPath`, appends `.xs`, and disambiguates
|
|
145
|
+
* collisions within a directory with a `_N` suffix (the second file named
|
|
146
|
+
* `say.xs` in a dir becomes `say_2.xs`), exactly as the pull writer does.
|
|
147
|
+
*
|
|
148
|
+
* Kept filesystem-free so it can be unit-tested and reused by both the pull
|
|
149
|
+
* commands and `flatten` without duplicating the placement rules.
|
|
150
|
+
*/
|
|
151
|
+
export declare function placeDocuments(documents: ParsedDocument[], deps: {
|
|
152
|
+
join: (...parts: string[]) => string;
|
|
153
|
+
relative: (from: string, to: string) => string;
|
|
154
|
+
snakeCase: (s: string) => string;
|
|
155
|
+
}): PlacedDocument[];
|
|
@@ -53,6 +53,17 @@ export function parseDocument(content) {
|
|
|
53
53
|
if (channelMatch) {
|
|
54
54
|
channel = channelMatch[1];
|
|
55
55
|
}
|
|
56
|
+
// Extract realtime_server if present (e.g., realtime_server = "chat").
|
|
57
|
+
// Realtime v2 channels/messages reference their owning realtime_server by NAME
|
|
58
|
+
// via a top-level `realtime_server = "..."`, which is what nests them under it
|
|
59
|
+
// on disk (mirrors how a message names its channel via `channel = "..."`).
|
|
60
|
+
// The keyword is `realtime_server` (renamed from a bare `server` to match the
|
|
61
|
+
// api_group/mcp_server convention — reference the container by its full type).
|
|
62
|
+
let server;
|
|
63
|
+
const serverMatch = content.match(/^\s*realtime_server\s*=\s*"([^"]*)"/m);
|
|
64
|
+
if (serverMatch) {
|
|
65
|
+
server = serverMatch[1];
|
|
66
|
+
}
|
|
56
67
|
// Extract canonical if present (e.g., canonical = "abc123")
|
|
57
68
|
let canonical;
|
|
58
69
|
const canonicalMatch = content.match(/canonical\s*=\s*"([^"]*)"/);
|
|
@@ -65,7 +76,7 @@ export function parseDocument(content) {
|
|
|
65
76
|
if (guidMatch) {
|
|
66
77
|
guid = guidMatch[1];
|
|
67
78
|
}
|
|
68
|
-
return { apiGroup, canonical, channel, content, guid, name, type, verb };
|
|
79
|
+
return { apiGroup, canonical, channel, content, guid, name, server, type, verb };
|
|
69
80
|
}
|
|
70
81
|
/**
|
|
71
82
|
* Map a realtime v2 channel path onto directory segments.
|
|
@@ -96,6 +107,207 @@ export function channelPathSegments(name, snakeCaseFn) {
|
|
|
96
107
|
const isParam = /^\{.*\}$/;
|
|
97
108
|
return name.split('/').map((segment) => (isParam.test(segment) ? `[${segment.slice(1, -1)}]` : snakeCaseFn(segment)));
|
|
98
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Build a map of realtime v2 channel path -> owning realtime_server name.
|
|
112
|
+
*
|
|
113
|
+
* This is the cross-document lookup the on-disk nesting needs: a `message`
|
|
114
|
+
* document names its channel (`channel = "..."`), while a `channel` document
|
|
115
|
+
* names its server (`realtime_server = "..."`). To place a message under
|
|
116
|
+
* `realtime/server/<server>/channel/<path>/...` we resolve the message's channel
|
|
117
|
+
* to that channel's server — which the channel document carries. (Messages now
|
|
118
|
+
* also carry `realtime_server` directly, but resolving via the channel keeps a
|
|
119
|
+
* single source of truth for the channel→server mapping.)
|
|
120
|
+
*
|
|
121
|
+
* Because every pull path parses the WHOLE multidoc into memory before writing,
|
|
122
|
+
* we can build this map from the `channel` docs in the same batch (a two-pass
|
|
123
|
+
* resolve), keyed by the channel's own name (its path). A channel with no
|
|
124
|
+
* `realtime_server` (e.g. a pre-server export) simply isn't in the map, and
|
|
125
|
+
* callers fall back accordingly.
|
|
126
|
+
*/
|
|
127
|
+
export function buildChannelServerResolver(documents) {
|
|
128
|
+
const channelToServer = new Map();
|
|
129
|
+
for (const doc of documents) {
|
|
130
|
+
if (doc.type === 'channel' && doc.server) {
|
|
131
|
+
channelToServer.set(doc.name, doc.server);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return (channelName) => channelToServer.get(channelName);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Sanitize a document name for use as a single on-disk filename segment.
|
|
138
|
+
* Strips quotes and snake_cases the rest. Mirrors the per-command
|
|
139
|
+
* `sanitizeFilename` the pull commands used to each define privately.
|
|
140
|
+
*/
|
|
141
|
+
export function sanitizeDocumentName(name, snakeCaseFn) {
|
|
142
|
+
return snakeCaseFn(name.replaceAll('"', ''));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Resolve where a parsed document lands on disk — the SINGLE source of truth for
|
|
146
|
+
* the pull layout, shared by every pull command (workspace, tenant, release,
|
|
147
|
+
* sandbox, ephemeral). Previously each command inlined a ~130-line copy of this
|
|
148
|
+
* decision; the copies drifted (the realtime v2 realtime_server/channel/message
|
|
149
|
+
* branches existed only in workspace/tenant), so an ephemeral pull dumped v2
|
|
150
|
+
* objects into flat top-level channel/, message/ and realtime_server/ folders —
|
|
151
|
+
* detaching messages from their channels and risking name collisions (message
|
|
152
|
+
* names are unique only WITHIN a channel).
|
|
153
|
+
*
|
|
154
|
+
* @param doc - the parsed document
|
|
155
|
+
* @param outputDir - the resolved pull output root
|
|
156
|
+
* @param deps - injected dependencies
|
|
157
|
+
* @param deps.getApiGroupFolder - resolves an api_group name to its unique folder
|
|
158
|
+
* @param deps.getChannelServer - resolves a v2 channel path to its owning realtime_server
|
|
159
|
+
* @param deps.join - the path.join implementation (injectable for testing)
|
|
160
|
+
* @param deps.snakeCase - the snakeCase implementation the caller uses
|
|
161
|
+
*/
|
|
162
|
+
export function resolveDocumentPath(doc, outputDir, deps) {
|
|
163
|
+
const { getApiGroupFolder, getChannelServer, join, snakeCase } = deps;
|
|
164
|
+
const sanitize = (name) => sanitizeDocumentName(name, snakeCase);
|
|
165
|
+
if (doc.type === 'workspace') {
|
|
166
|
+
// workspace → workspace/{name}.xs
|
|
167
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'workspace') };
|
|
168
|
+
}
|
|
169
|
+
if (doc.type === 'workspace_trigger' || doc.type === 'error_trigger') {
|
|
170
|
+
// workspace_trigger / error_trigger → workspace/trigger/{name}.xs
|
|
171
|
+
// (error_trigger is a singleton, colocated with workspace triggers)
|
|
172
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'workspace', 'trigger') };
|
|
173
|
+
}
|
|
174
|
+
if (doc.type === 'agent') {
|
|
175
|
+
// agent → ai/agent/{name}.xs
|
|
176
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'agent') };
|
|
177
|
+
}
|
|
178
|
+
if (doc.type === 'mcp_server') {
|
|
179
|
+
// mcp_server → ai/mcp_server/{name}.xs
|
|
180
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'mcp_server') };
|
|
181
|
+
}
|
|
182
|
+
if (doc.type === 'tool') {
|
|
183
|
+
// tool → ai/tool/{name}.xs
|
|
184
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'tool') };
|
|
185
|
+
}
|
|
186
|
+
if (doc.type === 'agent_trigger') {
|
|
187
|
+
// agent_trigger → ai/agent/trigger/{name}.xs
|
|
188
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'agent', 'trigger') };
|
|
189
|
+
}
|
|
190
|
+
if (doc.type === 'mcp_server_trigger') {
|
|
191
|
+
// mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
|
|
192
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'mcp_server', 'trigger') };
|
|
193
|
+
}
|
|
194
|
+
if (doc.type === 'table_trigger') {
|
|
195
|
+
// table_trigger → table/trigger/{name}.xs
|
|
196
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'table', 'trigger') };
|
|
197
|
+
}
|
|
198
|
+
if (doc.type === 'realtime_channel') {
|
|
199
|
+
// Realtime v1: realtime_channel → realtime/channel/{name}.xs
|
|
200
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'realtime', 'channel') };
|
|
201
|
+
}
|
|
202
|
+
if (doc.type === 'realtime_trigger') {
|
|
203
|
+
// Realtime v1: realtime_trigger → realtime/trigger/{name}.xs
|
|
204
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'realtime', 'trigger') };
|
|
205
|
+
}
|
|
206
|
+
if (doc.type === 'realtime_server_trigger') {
|
|
207
|
+
// Realtime v2. A realtime_server's connect/disconnect trigger. It references its
|
|
208
|
+
// owning server by name via `realtime_server = "..."`, so it nests under that
|
|
209
|
+
// server's folder as `<server>/trigger/`, mirroring how the server's own document
|
|
210
|
+
// and its channels already nest under realtime/server/<server>/ (DEV-7712). Keeping
|
|
211
|
+
// it there means every realtime document stays under realtime/ instead of landing at
|
|
212
|
+
// the output root. A server-less doc (a malformed/legacy export) falls back to a flat
|
|
213
|
+
// realtime/server_trigger/ under realtime/ rather than the repo root.
|
|
214
|
+
return {
|
|
215
|
+
baseName: sanitize(doc.name),
|
|
216
|
+
typeDir: doc.server
|
|
217
|
+
? join(outputDir, 'realtime', 'server', sanitize(doc.server), 'trigger')
|
|
218
|
+
: join(outputDir, 'realtime', 'server_trigger'),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (doc.type === 'channel_trigger') {
|
|
222
|
+
// Realtime v2. A channel's join/leave/deliver trigger. It references BOTH its owning
|
|
223
|
+
// server (`realtime_server = "..."`) and its channel (`channel = "..."`), so it nests
|
|
224
|
+
// beside that channel as `<server>/channel/<channel>/trigger/` — the same path the
|
|
225
|
+
// channel's own document and messages already use (DEV-7712). Falls back gracefully:
|
|
226
|
+
// channel but no resolvable server → the legacy flat channel/<path>/trigger/ layout;
|
|
227
|
+
// neither → realtime/channel_trigger/ under realtime/ (never the output root).
|
|
228
|
+
if (doc.server && doc.channel) {
|
|
229
|
+
return {
|
|
230
|
+
baseName: sanitize(doc.name),
|
|
231
|
+
typeDir: join(outputDir, 'realtime', 'server', sanitize(doc.server), 'channel', snakeCase(doc.channel), 'trigger'),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (doc.channel) {
|
|
235
|
+
return {
|
|
236
|
+
baseName: sanitize(doc.name),
|
|
237
|
+
typeDir: join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase), 'trigger'),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'realtime', 'channel_trigger') };
|
|
241
|
+
}
|
|
242
|
+
if (doc.type === 'realtime_server') {
|
|
243
|
+
// Realtime v2. The realtime_server is the top-level container that owns
|
|
244
|
+
// channels (which own messages). Its own document is named after itself
|
|
245
|
+
// inside its name directory, mirroring api_group (api/<group>/<group>.xs).
|
|
246
|
+
// realtime_server "chat" → realtime/server/chat/chat.xs
|
|
247
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'realtime', 'server', sanitize(doc.name)) };
|
|
248
|
+
}
|
|
249
|
+
if (doc.type === 'channel') {
|
|
250
|
+
// Realtime v2. The channel owns a directory named after itself (the full
|
|
251
|
+
// channel name, snake_cased into a single flat segment), and its messages
|
|
252
|
+
// live in a message/ subfolder inside it. It nests under its owning
|
|
253
|
+
// realtime_server (from `realtime_server = "..."`):
|
|
254
|
+
// channel "rooms/{room_id}" (server "chat")
|
|
255
|
+
// → realtime/server/chat/channel/rooms_room_id/rooms_room_id.xs
|
|
256
|
+
//
|
|
257
|
+
// A channel with no resolvable server (e.g. a pre-server export) falls back
|
|
258
|
+
// to the legacy flat channel/<path>/_channel.xs layout.
|
|
259
|
+
if (doc.server) {
|
|
260
|
+
return {
|
|
261
|
+
baseName: snakeCase(doc.name),
|
|
262
|
+
typeDir: join(outputDir, 'realtime', 'server', sanitize(doc.server), 'channel', snakeCase(doc.name)),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return { baseName: '_channel', typeDir: join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase)) };
|
|
266
|
+
}
|
|
267
|
+
if (doc.type === 'message' && doc.channel) {
|
|
268
|
+
// Realtime v2 message → nests in a message/ subfolder under its channel,
|
|
269
|
+
// under that channel's server (resolved via the two-pass channel→server map).
|
|
270
|
+
// message "post" on channel "rooms/{room_id}" (server "chat")
|
|
271
|
+
// → realtime/server/chat/channel/rooms_room_id/message/post.xs
|
|
272
|
+
//
|
|
273
|
+
// Nesting matters beyond tidiness: message names are unique only WITHIN a
|
|
274
|
+
// channel, so a flat message/ directory would collide when two channels
|
|
275
|
+
// both define e.g. "say". A channel whose server can't be resolved falls
|
|
276
|
+
// back to the legacy flat channel/ layout.
|
|
277
|
+
const messageServer = getChannelServer(doc.channel);
|
|
278
|
+
return {
|
|
279
|
+
baseName: sanitize(doc.name),
|
|
280
|
+
typeDir: messageServer
|
|
281
|
+
? join(outputDir, 'realtime', 'server', sanitize(messageServer), 'channel', snakeCase(doc.channel), 'message')
|
|
282
|
+
: join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase)),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (doc.type === 'api_group') {
|
|
286
|
+
// api_group "test" → api/{resolved_folder}/{name}.xs
|
|
287
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'api', getApiGroupFolder(doc.name)) };
|
|
288
|
+
}
|
|
289
|
+
if (doc.type === 'query' && doc.apiGroup) {
|
|
290
|
+
// query in group "test" → api/{resolved_folder}/{folders}/{query_name}[_verb].xs
|
|
291
|
+
const groupFolder = getApiGroupFolder(doc.apiGroup);
|
|
292
|
+
const nameParts = doc.name.split('/');
|
|
293
|
+
const leafName = nameParts.pop();
|
|
294
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
295
|
+
const baseName = sanitize(leafName);
|
|
296
|
+
return {
|
|
297
|
+
baseName: doc.verb ? `${baseName}_${doc.verb}` : baseName,
|
|
298
|
+
typeDir: join(outputDir, 'api', groupFolder, ...folderParts),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
// Default: split folder path from name
|
|
302
|
+
const nameParts = doc.name.split('/');
|
|
303
|
+
const leafName = nameParts.pop();
|
|
304
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
305
|
+
const baseName = sanitize(leafName);
|
|
306
|
+
return {
|
|
307
|
+
baseName: doc.verb ? `${baseName}_${doc.verb}` : baseName,
|
|
308
|
+
typeDir: join(outputDir, doc.type, ...folderParts),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
99
311
|
/**
|
|
100
312
|
* Build a unique key for a document based on its type, name, verb, and api_group.
|
|
101
313
|
* Used to match server GUID map entries back to local files.
|
|
@@ -149,3 +361,63 @@ export function buildApiGroupFolderResolver(documents, snakeCaseFn) {
|
|
|
149
361
|
export function findFilesWithGuid(entries, guid) {
|
|
150
362
|
return entries.filter((e) => e.content.includes(guid)).map((e) => e.filePath);
|
|
151
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* Split a multi-document XanoScript blob (documents joined by `\n---\n`) into
|
|
366
|
+
* parsed documents, skipping empties and any fragment that fails to parse.
|
|
367
|
+
*
|
|
368
|
+
* This is the same split the pull commands apply to the server's multidoc
|
|
369
|
+
* response. A hand-authored bundle is one file of many `---`-separated
|
|
370
|
+
* documents; this turns it back into the per-document units every downstream
|
|
371
|
+
* step (placement, GUID writeback, partial-diff matching) assumes.
|
|
372
|
+
*/
|
|
373
|
+
export function splitMultidoc(blob) {
|
|
374
|
+
const documents = [];
|
|
375
|
+
for (const raw of blob.split('\n---\n')) {
|
|
376
|
+
const trimmed = raw.trim();
|
|
377
|
+
if (!trimmed)
|
|
378
|
+
continue;
|
|
379
|
+
const parsed = parseDocument(trimmed);
|
|
380
|
+
if (parsed)
|
|
381
|
+
documents.push(parsed);
|
|
382
|
+
}
|
|
383
|
+
return documents;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Compute where each document lands on disk — a PURE function (no filesystem)
|
|
387
|
+
* mirroring the split-and-place loop the pull commands run. It resolves each
|
|
388
|
+
* document via `resolveDocumentPath`, appends `.xs`, and disambiguates
|
|
389
|
+
* collisions within a directory with a `_N` suffix (the second file named
|
|
390
|
+
* `say.xs` in a dir becomes `say_2.xs`), exactly as the pull writer does.
|
|
391
|
+
*
|
|
392
|
+
* Kept filesystem-free so it can be unit-tested and reused by both the pull
|
|
393
|
+
* commands and `flatten` without duplicating the placement rules.
|
|
394
|
+
*/
|
|
395
|
+
export function placeDocuments(documents, deps) {
|
|
396
|
+
const { join, relative, snakeCase } = deps;
|
|
397
|
+
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
398
|
+
const getChannelServer = buildChannelServerResolver(documents);
|
|
399
|
+
// Per-directory filename counters, so duplicate leaf names get a _N suffix.
|
|
400
|
+
const filenameCounters = new Map();
|
|
401
|
+
const placed = [];
|
|
402
|
+
for (const doc of documents) {
|
|
403
|
+
const { baseName, typeDir } = resolveDocumentPath(doc, '', {
|
|
404
|
+
getApiGroupFolder,
|
|
405
|
+
getChannelServer,
|
|
406
|
+
join,
|
|
407
|
+
snakeCase,
|
|
408
|
+
});
|
|
409
|
+
const dirKey = typeDir;
|
|
410
|
+
if (!filenameCounters.has(dirKey))
|
|
411
|
+
filenameCounters.set(dirKey, new Map());
|
|
412
|
+
const typeCounters = filenameCounters.get(dirKey);
|
|
413
|
+
const count = typeCounters.get(baseName) || 0;
|
|
414
|
+
typeCounters.set(baseName, count + 1);
|
|
415
|
+
const filename = count === 0 ? `${baseName}.xs` : `${baseName}_${count + 1}.xs`;
|
|
416
|
+
const abs = join(typeDir, filename);
|
|
417
|
+
// typeDir was built off an empty root, so `abs` is already root-relative;
|
|
418
|
+
// normalize any leading separator via relative() for a clean POSIX path.
|
|
419
|
+
const relPath = relative('', abs);
|
|
420
|
+
placed.push({ content: doc.content, relPath });
|
|
421
|
+
}
|
|
422
|
+
return placed;
|
|
423
|
+
}
|