@xano/cli 1.0.6-beta.3 → 1.0.6-beta.6

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.
Files changed (67) hide show
  1. package/README.md +95 -4
  2. package/dist/commands/branch/list/index.js +5 -1
  3. package/dist/commands/ephemeral/list/index.js +2 -1
  4. package/dist/commands/ephemeral/pull/index.d.ts +0 -6
  5. package/dist/commands/ephemeral/pull/index.js +10 -95
  6. package/dist/commands/ephemeral/static_host/build/list/index.d.ts +2 -3
  7. package/dist/commands/ephemeral/static_host/build/list/index.js +19 -40
  8. package/dist/commands/ephemeral/static_host/list/index.d.ts +2 -3
  9. package/dist/commands/ephemeral/static_host/list/index.js +20 -40
  10. package/dist/commands/function/list/index.d.ts +4 -4
  11. package/dist/commands/function/list/index.js +30 -38
  12. package/dist/commands/knowledge/list/index.js +2 -1
  13. package/dist/commands/platform/list/index.js +13 -14
  14. package/dist/commands/release/list/index.js +13 -14
  15. package/dist/commands/release/pull/index.d.ts +0 -6
  16. package/dist/commands/release/pull/index.js +10 -95
  17. package/dist/commands/sandbox/env/list/index.js +4 -0
  18. package/dist/commands/sandbox/pull/index.d.ts +0 -1
  19. package/dist/commands/sandbox/pull/index.js +10 -77
  20. package/dist/commands/sandbox/unit_test/list/index.d.ts +2 -0
  21. package/dist/commands/sandbox/unit_test/list/index.js +9 -14
  22. package/dist/commands/sandbox/unit_test/run_all/index.d.ts +1 -0
  23. package/dist/commands/sandbox/unit_test/run_all/index.js +67 -37
  24. package/dist/commands/sandbox/workflow_test/list/index.d.ts +2 -0
  25. package/dist/commands/sandbox/workflow_test/list/index.js +9 -14
  26. package/dist/commands/sandbox/workflow_test/run_all/index.d.ts +1 -0
  27. package/dist/commands/sandbox/workflow_test/run_all/index.js +65 -35
  28. package/dist/commands/static_host/build/list/index.d.ts +2 -3
  29. package/dist/commands/static_host/build/list/index.js +19 -40
  30. package/dist/commands/static_host/build/pull/index.js +12 -9
  31. package/dist/commands/static_host/list/index.d.ts +2 -3
  32. package/dist/commands/static_host/list/index.js +20 -40
  33. package/dist/commands/static_host/migrate/index.js +14 -10
  34. package/dist/commands/tenant/backup/list/index.js +13 -14
  35. package/dist/commands/tenant/cluster/list/index.js +9 -10
  36. package/dist/commands/tenant/env/list/index.js +4 -0
  37. package/dist/commands/tenant/list/index.js +19 -20
  38. package/dist/commands/tenant/pull/index.d.ts +0 -6
  39. package/dist/commands/tenant/pull/index.js +7 -142
  40. package/dist/commands/tenant/snapshot/list/index.js +5 -1
  41. package/dist/commands/tenant/unit_test/list/index.d.ts +4 -2
  42. package/dist/commands/tenant/unit_test/list/index.js +9 -14
  43. package/dist/commands/tenant/unit_test/run_all/index.d.ts +1 -0
  44. package/dist/commands/tenant/unit_test/run_all/index.js +67 -37
  45. package/dist/commands/tenant/workflow_test/list/index.d.ts +4 -2
  46. package/dist/commands/tenant/workflow_test/list/index.js +9 -14
  47. package/dist/commands/tenant/workflow_test/run_all/index.d.ts +1 -0
  48. package/dist/commands/tenant/workflow_test/run_all/index.js +65 -35
  49. package/dist/commands/unit_test/list/index.d.ts +3 -1
  50. package/dist/commands/unit_test/list/index.js +9 -14
  51. package/dist/commands/unit_test/run_all/index.d.ts +1 -0
  52. package/dist/commands/unit_test/run_all/index.js +68 -40
  53. package/dist/commands/workflow_test/list/index.d.ts +3 -1
  54. package/dist/commands/workflow_test/list/index.js +10 -13
  55. package/dist/commands/workflow_test/run_all/index.d.ts +1 -0
  56. package/dist/commands/workflow_test/run_all/index.js +67 -36
  57. package/dist/commands/workspace/list/index.js +7 -13
  58. package/dist/commands/workspace/pull/index.d.ts +0 -6
  59. package/dist/commands/workspace/pull/index.js +7 -144
  60. package/dist/utils/concurrency.d.ts +29 -0
  61. package/dist/utils/concurrency.js +54 -0
  62. package/dist/utils/document-parser.d.ts +39 -0
  63. package/dist/utils/document-parser.js +139 -0
  64. package/dist/utils/paging.d.ts +141 -0
  65. package/dist/utils/paging.js +194 -0
  66. package/oclif.manifest.json +2950 -2824
  67. package/package.json +1 -1
@@ -3,9 +3,11 @@ export default class WorkflowTestList extends BaseCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
+ workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ page: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
8
+ per_page: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
6
9
  branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
10
  output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
- workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
11
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
12
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
13
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -1,5 +1,6 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { buildPagingJson, buildPagingParams, formatPagingFooter, normalizeListResponse, pagingFlags } from '../../../utils/paging.js';
3
4
  export default class WorkflowTestList extends BaseCommand {
4
5
  static description = 'List all workflow tests in a workspace';
5
6
  static examples = [
@@ -25,6 +26,7 @@ Workflow tests in workspace 5:
25
26
  options: ['summary', 'json'],
26
27
  required: false,
27
28
  }),
29
+ ...pagingFlags('envelope', { maxPerPage: 10_000 }),
28
30
  workspace: Flags.string({
29
31
  char: 'w',
30
32
  description: 'Workspace ID (uses profile workspace if not provided)',
@@ -38,7 +40,8 @@ Workflow tests in workspace 5:
38
40
  if (!workspaceId) {
39
41
  this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
40
42
  }
41
- const params = new URLSearchParams({ include_xanoscript: 'false' });
43
+ const params = new URLSearchParams(buildPagingParams(flags, 'envelope'));
44
+ params.set('include_xanoscript', 'false');
42
45
  if (flags.branch) {
43
46
  params.set('branch', flags.branch);
44
47
  }
@@ -55,19 +58,10 @@ Workflow tests in workspace 5:
55
58
  const errorText = await response.text();
56
59
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
57
60
  }
58
- const data = await response.json();
59
- let tests;
60
- if (Array.isArray(data)) {
61
- tests = data;
62
- }
63
- else if (data && typeof data === 'object' && 'items' in data && Array.isArray(data.items)) {
64
- tests = data.items;
65
- }
66
- else {
67
- this.error('Unexpected API response format');
68
- }
61
+ const list = normalizeListResponse(await response.json());
62
+ const tests = list.items;
69
63
  if (flags.output === 'json') {
70
- this.log(JSON.stringify(tests, null, 2));
64
+ this.log(JSON.stringify(buildPagingJson(list, { page: flags.page, perPage: flags.per_page, tier: 'envelope' }), null, 2));
71
65
  }
72
66
  else {
73
67
  if (tests.length === 0) {
@@ -80,6 +74,9 @@ Workflow tests in workspace 5:
80
74
  this.log(` - ${test.name} (ID: ${test.id})${desc}`);
81
75
  }
82
76
  }
77
+ const footer = formatPagingFooter(list, { noun: 'workflow test', page: flags.page, tier: 'envelope' });
78
+ if (footer)
79
+ this.log(footer);
83
80
  }
84
81
  }
85
82
  catch (error) {
@@ -4,6 +4,7 @@ export default class WorkflowTestRunAll extends BaseCommand {
4
4
  static examples: string[];
5
5
  static flags: {
6
6
  branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ concurrency: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
7
8
  output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
9
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -1,5 +1,7 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { createOrderedEmitter, mapWithConcurrency } from '../../../utils/concurrency.js';
4
+ import { collectAllPages, INTERNAL_PAGE_SIZE, normalizeListResponse } from '../../../utils/paging.js';
3
5
  export default class WorkflowTestRunAll extends BaseCommand {
4
6
  static description = 'Run all workflow tests in a workspace';
5
7
  static examples = [
@@ -22,6 +24,11 @@ Results: 2 passed, 1 failed (2.691s total)
22
24
  description: 'Filter by branch name',
23
25
  required: false,
24
26
  }),
27
+ concurrency: Flags.integer({
28
+ default: 1,
29
+ description: 'Run this many tests in parallel. Tests share the workspace database, so raise this only when your tests do not depend on shared state.',
30
+ required: false,
31
+ }),
25
32
  output: Flags.string({
26
33
  char: 'o',
27
34
  default: 'summary',
@@ -45,32 +52,35 @@ Results: 2 passed, 1 failed (2.691s total)
45
52
  const baseUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/workflow_test`;
46
53
  try {
47
54
  // Step 1: List all workflow tests
48
- const listParams = new URLSearchParams({ include_xanoscript: 'false' });
49
- if (flags.branch) {
50
- listParams.set('branch', flags.branch);
51
- }
52
- const listResponse = await this.verboseFetch(`${baseUrl}?${listParams}`, {
53
- headers: {
54
- accept: 'application/json',
55
- Authorization: `Bearer ${profile.access_token}`,
55
+ // Page the list rather than asking for everything at once: 100 per
56
+ // request is plenty for typical suites and does not make the instance
57
+ // build one enormous response. Termination is the server's own nextPage,
58
+ // never "the page came back full".
59
+ const branchValue = flags.branch;
60
+ const tests = await collectAllPages(async (page) => {
61
+ const listParams = new URLSearchParams();
62
+ listParams.set('page', String(page));
63
+ listParams.set('per_page', String(INTERNAL_PAGE_SIZE));
64
+ if (branchValue)
65
+ listParams.set('branch', branchValue);
66
+ listParams.set('include_xanoscript', 'false');
67
+ const listResponse = await this.verboseFetch(`${baseUrl}?${listParams}`, {
68
+ headers: {
69
+ accept: 'application/json',
70
+ Authorization: `Bearer ${profile.access_token}`,
71
+ },
72
+ method: 'GET',
73
+ }, flags.verbose, profile.access_token);
74
+ if (!listResponse.ok) {
75
+ const errorText = await listResponse.text();
76
+ this.error(`Failed to list workflow tests: ${listResponse.status}: ${listResponse.statusText}\n${errorText}`);
77
+ }
78
+ return normalizeListResponse(await listResponse.json());
79
+ }, {
80
+ onTruncate: (collected) => {
81
+ this.warn(`Stopped after ${collected} workflow tests — the list did not terminate. Some tests may not have run.`);
56
82
  },
57
- method: 'GET',
58
- }, flags.verbose, profile.access_token);
59
- if (!listResponse.ok) {
60
- const errorText = await listResponse.text();
61
- this.error(`Failed to list workflow tests: ${listResponse.status}: ${listResponse.statusText}\n${errorText}`);
62
- }
63
- const data = (await listResponse.json());
64
- let tests;
65
- if (Array.isArray(data)) {
66
- tests = data;
67
- }
68
- else if (data && typeof data === 'object' && 'items' in data && Array.isArray(data.items)) {
69
- tests = data.items;
70
- }
71
- else {
72
- this.error('Unexpected API response format');
73
- }
83
+ });
74
84
  if (tests.length === 0) {
75
85
  this.log('No workflow tests found');
76
86
  return;
@@ -81,7 +91,15 @@ Results: 2 passed, 1 failed (2.691s total)
81
91
  // Step 2: Run each test
82
92
  const results = [];
83
93
  let totalTiming = 0;
84
- for (const test of tests) {
94
+ const runOne = async (test) => {
95
+ // Shadowed accumulators: the body below is the original sequential
96
+ // logic, but it records output instead of printing it so a concurrent
97
+ // run can still emit in input order.
98
+ const results = [];
99
+ const lines = [];
100
+ const log = (message) => {
101
+ lines.push(message);
102
+ };
85
103
  const runUrl = `${baseUrl}/${test.id}/run`;
86
104
  try {
87
105
  const runResponse = await this.verboseFetch(runUrl, {
@@ -102,10 +120,10 @@ Results: 2 passed, 1 failed (2.691s total)
102
120
  };
103
121
  results.push(result);
104
122
  if (flags.output === 'summary') {
105
- this.log(`FAIL ${test.name} (0.000s)`);
106
- this.log(` Error: API error ${runResponse.status}`);
123
+ log(`FAIL ${test.name} (0.000s)`);
124
+ log(` Error: API error ${runResponse.status}`);
107
125
  }
108
- continue;
126
+ return { lines, results };
109
127
  }
110
128
  const runResult = (await runResponse.json());
111
129
  const passed = runResult.status === 'ok';
@@ -120,12 +138,12 @@ Results: 2 passed, 1 failed (2.691s total)
120
138
  if (flags.output === 'summary') {
121
139
  const timing = `(${runResult.timing.toFixed(3)}s)`;
122
140
  if (passed) {
123
- this.log(`PASS ${test.name} ${timing}`);
141
+ log(`PASS ${test.name} ${timing}`);
124
142
  }
125
143
  else {
126
- this.log(`FAIL ${test.name} ${timing}`);
144
+ log(`FAIL ${test.name} ${timing}`);
127
145
  if (runResult.message) {
128
- this.log(` Error: ${runResult.message}`);
146
+ log(` Error: ${runResult.message}`);
129
147
  }
130
148
  }
131
149
  }
@@ -139,16 +157,29 @@ Results: 2 passed, 1 failed (2.691s total)
139
157
  timing: 0,
140
158
  });
141
159
  if (flags.output === 'summary') {
142
- this.log(`FAIL ${test.name} (0.000s)`);
143
- this.log(` Error: ${message}`);
160
+ log(`FAIL ${test.name} (0.000s)`);
161
+ log(` Error: ${message}`);
144
162
  }
145
163
  }
146
- }
164
+ return { lines, results };
165
+ };
166
+ // Print in input order as each test settles, so concurrent output stays
167
+ // diffable against a sequential run.
168
+ const emitter = createOrderedEmitter((settled) => {
169
+ for (const line of settled.lines)
170
+ this.log(line);
171
+ });
172
+ const perTest = await mapWithConcurrency(tests, flags.concurrency, async (test, index) => {
173
+ const settled = await runOne(test);
174
+ emitter.settle(index, settled);
175
+ return settled;
176
+ });
177
+ results.push(...perTest.flatMap((r) => r.results));
147
178
  // Step 3: Summary
148
179
  const passed = results.filter((r) => r.status === 'pass').length;
149
180
  const failed = results.filter((r) => r.status === 'fail').length;
150
181
  if (flags.output === 'json') {
151
- this.log(JSON.stringify({ passed, failed, total_timing: totalTiming, results }, null, 2));
182
+ this.log(JSON.stringify({ failed, passed, results, total_timing: totalTiming }, null, 2));
152
183
  }
153
184
  else {
154
185
  this.log(`\nResults: ${passed} passed, ${failed} failed (${totalTiming.toFixed(3)}s total)`);
@@ -1,5 +1,6 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { buildPagingJson, formatPagingFooter, normalizeListResponse } from '../../../utils/paging.js';
3
4
  export default class WorkspaceList extends BaseCommand {
4
5
  static description = 'List all workspaces from the Xano Metadata API';
5
6
  static examples = [
@@ -71,24 +72,14 @@ Available workspaces:
71
72
  const errorText = await response.text();
72
73
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
73
74
  }
74
- const data = (await response.json());
75
- // Handle different response formats
76
- let workspaces;
77
- if (Array.isArray(data)) {
78
- workspaces = data;
79
- }
80
- else if (data && typeof data === 'object' && 'workspaces' in data && Array.isArray(data.workspaces)) {
81
- workspaces = data.workspaces;
82
- }
83
- else {
84
- this.error('Unexpected API response format');
85
- }
75
+ const list = normalizeListResponse(await response.json(), ['workspaces']);
76
+ const workspaces = list.items;
86
77
  if (flags.latest) {
87
78
  workspaces.sort((a, b) => b.id - a.id);
88
79
  }
89
80
  // Output results
90
81
  if (flags.output === 'json') {
91
- this.log(JSON.stringify(workspaces, null, 2));
82
+ this.log(JSON.stringify(buildPagingJson(list, { tier: 'none' }), null, 2));
92
83
  }
93
84
  else {
94
85
  // summary format
@@ -107,6 +98,9 @@ Available workspaces:
107
98
  }
108
99
  }
109
100
  }
101
+ const footer = formatPagingFooter({ items: workspaces }, { noun: 'workspace', tier: 'none' });
102
+ if (footer)
103
+ this.log(footer);
110
104
  }
111
105
  }
112
106
  catch (error) {
@@ -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, buildChannelServerResolver, channelPathSegments, parseDocument, } from '../../../utils/document-parser.js';
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';
@@ -141,141 +141,12 @@ Pulled 58 documents
141
141
  const filenameCounters = new Map();
142
142
  let writtenCount = 0;
143
143
  for (const doc of documents) {
144
- let typeDir;
145
- let baseName;
146
- if (doc.type === 'workspace') {
147
- // workspace → workspace/{name}.xs
148
- typeDir = path.join(outputDir, 'workspace');
149
- baseName = this.sanitizeFilename(doc.name);
150
- }
151
- else if (doc.type === 'workspace_trigger') {
152
- // workspace_trigger → workspace/trigger/{name}.xs
153
- typeDir = path.join(outputDir, 'workspace', 'trigger');
154
- baseName = this.sanitizeFilename(doc.name);
155
- }
156
- else if (doc.type === 'error_trigger') {
157
- // error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
158
- typeDir = path.join(outputDir, 'workspace', 'trigger');
159
- baseName = this.sanitizeFilename(doc.name);
160
- }
161
- else if (doc.type === 'agent') {
162
- // agent → ai/agent/{name}.xs
163
- typeDir = path.join(outputDir, 'ai', 'agent');
164
- baseName = this.sanitizeFilename(doc.name);
165
- }
166
- else if (doc.type === 'mcp_server') {
167
- // mcp_server → ai/mcp_server/{name}.xs
168
- typeDir = path.join(outputDir, 'ai', 'mcp_server');
169
- baseName = this.sanitizeFilename(doc.name);
170
- }
171
- else if (doc.type === 'tool') {
172
- // tool → ai/tool/{name}.xs
173
- typeDir = path.join(outputDir, 'ai', 'tool');
174
- baseName = this.sanitizeFilename(doc.name);
175
- }
176
- else if (doc.type === 'agent_trigger') {
177
- // agent_trigger → ai/agent/trigger/{name}.xs
178
- typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
179
- baseName = this.sanitizeFilename(doc.name);
180
- }
181
- else if (doc.type === 'mcp_server_trigger') {
182
- // mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
183
- typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
184
- baseName = this.sanitizeFilename(doc.name);
185
- }
186
- else if (doc.type === 'table_trigger') {
187
- // table_trigger → table/trigger/{name}.xs
188
- typeDir = path.join(outputDir, 'table', 'trigger');
189
- baseName = this.sanitizeFilename(doc.name);
190
- }
191
- else if (doc.type === 'realtime_channel') {
192
- // realtime_channel → realtime/channel/{name}.xs
193
- typeDir = path.join(outputDir, 'realtime', 'channel');
194
- baseName = this.sanitizeFilename(doc.name);
195
- }
196
- else if (doc.type === 'realtime_trigger') {
197
- // realtime_trigger → realtime/trigger/{name}.xs
198
- typeDir = path.join(outputDir, 'realtime', 'trigger');
199
- baseName = this.sanitizeFilename(doc.name);
200
- }
201
- else if (doc.type === 'realtime_server') {
202
- // Realtime v2. The realtime_server is the top-level container that owns
203
- // channels (which own messages). Its own document is named after itself
204
- // inside its name directory, mirroring api_group (api/<group>/<group>.xs)
205
- // — the server name is a simple segment, so no fixed leaf is needed.
206
- // realtime_server "chat" → realtime/server/chat/chat.xs
207
- typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.name));
208
- baseName = this.sanitizeFilename(doc.name);
209
- }
210
- else if (doc.type === 'channel') {
211
- // Realtime v2. The channel owns a directory named after itself (the full
212
- // channel name, snake_cased into a single flat segment — like every other
213
- // object), and its messages live in a message/ subfolder inside it. It
214
- // nests under its owning realtime_server (from `server = "..."`):
215
- // channel "rooms/{room_id}" (server "chat")
216
- // → realtime/server/chat/channel/rooms_room_id/rooms_room_id.xs
217
- //
218
- // The channel name is snake_cased as a whole (rooms/{room_id} →
219
- // rooms_room_id), so there is no path nesting and no bracket escaping —
220
- // the on-disk name is a display form; the .xs content keeps the real
221
- // name. A channel with no resolvable server (e.g. a pre-server export)
222
- // falls back to the legacy flat channel/<path>/_channel.xs layout.
223
- if (doc.server) {
224
- typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.server), 'channel', snakeCase(doc.name));
225
- baseName = snakeCase(doc.name);
226
- }
227
- else {
228
- typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
229
- baseName = '_channel';
230
- }
231
- }
232
- else if (doc.type === 'message' && doc.channel) {
233
- // Realtime v2 message → nests in a message/ subfolder under its channel,
234
- // under that channel's server. The message names only its channel; the
235
- // server is resolved by looking that channel up in the same multidoc
236
- // (two-pass resolve).
237
- // message "post" on channel "rooms/{room_id}" (server "chat")
238
- // → realtime/server/chat/channel/rooms_room_id/message/post.xs
239
- //
240
- // Nesting matters beyond tidiness: message names are unique only WITHIN
241
- // a channel, so a flat message/ directory would collide when two
242
- // channels both define e.g. "say". A channel whose server can't be
243
- // resolved falls back to the legacy flat channel/ layout.
244
- const messageServer = getChannelServer(doc.channel);
245
- typeDir = messageServer
246
- ? path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(messageServer), 'channel', snakeCase(doc.channel), 'message')
247
- : path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
248
- baseName = this.sanitizeFilename(doc.name);
249
- }
250
- else if (doc.type === 'api_group') {
251
- // api_group "test" → api/{resolved_folder}/{name}.xs
252
- const groupFolder = getApiGroupFolder(doc.name);
253
- typeDir = path.join(outputDir, 'api', groupFolder);
254
- baseName = this.sanitizeFilename(doc.name);
255
- }
256
- else if (doc.type === 'query' && doc.apiGroup) {
257
- // query in group "test" → api/{resolved_folder}/{query_name}.xs
258
- const groupFolder = getApiGroupFolder(doc.apiGroup);
259
- const nameParts = doc.name.split('/');
260
- const leafName = nameParts.pop();
261
- const folderParts = nameParts.map((part) => snakeCase(part));
262
- typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
263
- baseName = this.sanitizeFilename(leafName);
264
- if (doc.verb) {
265
- baseName = `${baseName}_${doc.verb}`;
266
- }
267
- }
268
- else {
269
- // Default: split folder path from name
270
- const nameParts = doc.name.split('/');
271
- const leafName = nameParts.pop();
272
- const folderParts = nameParts.map((part) => snakeCase(part));
273
- typeDir = path.join(outputDir, doc.type, ...folderParts);
274
- baseName = this.sanitizeFilename(leafName);
275
- if (doc.verb) {
276
- baseName = `${baseName}_${doc.verb}`;
277
- }
278
- }
144
+ const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
145
+ getApiGroupFolder,
146
+ getChannelServer,
147
+ join: path.join,
148
+ snakeCase,
149
+ });
279
150
  fs.mkdirSync(typeDir, { recursive: true });
280
151
  // Track duplicates per directory
281
152
  const dirKey = path.relative(outputDir, typeDir);
@@ -304,12 +175,4 @@ Pulled 58 documents
304
175
  parts.push(`${knowledgeCount} knowledge file${knowledgeCount === 1 ? '' : 's'}`);
305
176
  this.log(`Pulled ${parts.join(' + ')} to ${flags.directory}`);
306
177
  }
307
- /**
308
- * Sanitize a document name for use as a filename.
309
- * Strips quotes, replaces spaces with underscores, and removes
310
- * characters that are unsafe in filenames.
311
- */
312
- sanitizeFilename(name) {
313
- return snakeCase(name.replaceAll('"', ''));
314
- }
315
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
+ }
@@ -58,6 +58,45 @@ export declare function channelPathSegments(name: string, snakeCaseFn: (s: strin
58
58
  * callers fall back accordingly.
59
59
  */
60
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;
61
100
  /**
62
101
  * Build a unique key for a document based on its type, name, verb, and api_group.
63
102
  * Used to match server GUID map entries back to local files.