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

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.
@@ -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, channelPathSegments, parseDocument, } from '../../../utils/document-parser.js';
6
+ import { buildApiGroupFolderResolver, buildChannelServerResolver, channelPathSegments, parseDocument, } from '../../../utils/document-parser.js';
7
7
  export default class Pull extends BaseCommand {
8
8
  static description = 'Pull a tenant multidoc from the Xano Metadata API and split into individual files';
9
9
  static examples = [
@@ -129,6 +129,9 @@ Pulled 58 documents from tenant my-tenant
129
129
  fs.mkdirSync(outputDir, { recursive: true });
130
130
  // Resolve api_group names to unique folder names, disambiguating collisions
131
131
  const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
132
+ // Resolve a realtime v2 channel path -> owning realtime_server name, so
133
+ // messages can nest under their channel's server (see resolver docs).
134
+ const getChannelServer = buildChannelServerResolver(documents);
132
135
  // Track filenames per type to handle duplicates
133
136
  const filenameCounters = new Map();
134
137
  let writtenCount = 0;
@@ -190,15 +193,51 @@ Pulled 58 documents from tenant my-tenant
190
193
  typeDir = path.join(outputDir, 'realtime', 'trigger');
191
194
  baseName = this.sanitizeFilename(doc.name);
192
195
  }
196
+ else if (doc.type === 'realtime_server') {
197
+ // Realtime v2 — see workspace/pull for the full rationale. Its own
198
+ // document is named after itself, mirroring api_group.
199
+ // realtime_server "chat" → realtime/server/chat/chat.xs
200
+ typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.name));
201
+ baseName = this.sanitizeFilename(doc.name);
202
+ }
193
203
  else if (doc.type === 'channel') {
194
- // Realtime v2 — see workspace/pull for the full rationale.
195
- // channel "rooms/{room_id}" → channel/rooms/[room_id]/_channel.xs
196
- typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
197
- baseName = '_channel';
204
+ // Realtime v2. The channel owns a directory named after itself (the full
205
+ // channel name, snake_cased into a single flat segment — like every other
206
+ // object), and its messages live in a message/ subfolder inside it. It
207
+ // nests under its owning realtime_server (from `server = "..."`):
208
+ // channel "rooms/{room_id}" (server "chat")
209
+ // → realtime/server/chat/channel/rooms_room_id/rooms_room_id.xs
210
+ //
211
+ // The channel name is snake_cased as a whole (rooms/{room_id} →
212
+ // rooms_room_id), so there is no path nesting and no bracket escaping —
213
+ // the on-disk name is a display form; the .xs content keeps the real
214
+ // name. A channel with no resolvable server (e.g. a pre-server export)
215
+ // falls back to the legacy flat channel/<path>/_channel.xs layout.
216
+ if (doc.server) {
217
+ typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.server), 'channel', snakeCase(doc.name));
218
+ baseName = snakeCase(doc.name);
219
+ }
220
+ else {
221
+ typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
222
+ baseName = '_channel';
223
+ }
198
224
  }
199
225
  else if (doc.type === 'message' && doc.channel) {
200
- // Realtime v2 message → channel/{channel_path}/{message_name}.xs
201
- typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
226
+ // Realtime v2 message → nests in a message/ subfolder under its channel,
227
+ // under that channel's server. The message names only its channel; the
228
+ // server is resolved by looking that channel up in the same multidoc
229
+ // (two-pass resolve).
230
+ // message "post" on channel "rooms/{room_id}" (server "chat")
231
+ // → realtime/server/chat/channel/rooms_room_id/message/post.xs
232
+ //
233
+ // Nesting matters beyond tidiness: message names are unique only WITHIN
234
+ // a channel, so a flat message/ directory would collide when two
235
+ // channels both define e.g. "say". A channel whose server can't be
236
+ // resolved falls back to the legacy flat channel/ layout.
237
+ const messageServer = getChannelServer(doc.channel);
238
+ typeDir = messageServer
239
+ ? path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(messageServer), 'channel', snakeCase(doc.channel), 'message')
240
+ : path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
202
241
  baseName = this.sanitizeFilename(doc.name);
203
242
  }
204
243
  else if (doc.type === 'api_group') {
@@ -5,7 +5,7 @@ import * as os from 'node:os';
5
5
  import * as path from 'node:path';
6
6
  import snakeCase from 'lodash.snakecase';
7
7
  import BaseCommand, { buildUserAgent } from '../../../../base-command.js';
8
- import { buildApiGroupFolderResolver, channelPathSegments, parseDocument, } from '../../../../utils/document-parser.js';
8
+ import { buildApiGroupFolderResolver, buildChannelServerResolver, channelPathSegments, parseDocument, } from '../../../../utils/document-parser.js';
9
9
  export default class GitPull extends BaseCommand {
10
10
  static description = 'Pull XanoScript files from a git repository into a local directory';
11
11
  static examples = [
@@ -97,10 +97,13 @@ export default class GitPull extends BaseCommand {
97
97
  // Write documents to output directory using the same file tree logic as workspace pull
98
98
  fs.mkdirSync(outputDir, { recursive: true });
99
99
  const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
100
+ // Resolve a realtime v2 channel path -> owning realtime_server name, so
101
+ // messages can nest under their channel's server (see resolver docs).
102
+ const getChannelServer = buildChannelServerResolver(documents);
100
103
  const filenameCounters = new Map();
101
104
  let writtenCount = 0;
102
105
  for (const doc of documents) {
103
- const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc, getApiGroupFolder);
106
+ const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc, getApiGroupFolder, getChannelServer);
104
107
  fs.mkdirSync(typeDir, { recursive: true });
105
108
  // Track duplicates per directory
106
109
  const dirKey = path.relative(outputDir, typeDir);
@@ -325,7 +328,7 @@ export default class GitPull extends BaseCommand {
325
328
  * Resolve the output directory and base filename for a parsed document.
326
329
  * Uses the same type-to-directory mapping as workspace pull.
327
330
  */
328
- resolveOutputPath(outputDir, doc, getApiGroupFolder) {
331
+ resolveOutputPath(outputDir, doc, getApiGroupFolder, getChannelServer) {
329
332
  let typeDir;
330
333
  let baseName;
331
334
  if (doc.type === 'workspace') {
@@ -373,13 +376,51 @@ export default class GitPull extends BaseCommand {
373
376
  typeDir = path.join(outputDir, 'realtime', 'trigger');
374
377
  baseName = this.sanitizeFilename(doc.name);
375
378
  }
379
+ else if (doc.type === 'realtime_server') {
380
+ // Realtime v2 — see workspace/pull for the full rationale. Its own
381
+ // document is named after itself, mirroring api_group.
382
+ // realtime_server "chat" → realtime/server/chat/chat.xs
383
+ typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.name));
384
+ baseName = this.sanitizeFilename(doc.name);
385
+ }
376
386
  else if (doc.type === 'channel') {
377
- // Realtime v2 — see workspace/pull for the full rationale.
378
- typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
379
- baseName = '_channel';
387
+ // Realtime v2. The channel owns a directory named after itself (the full
388
+ // channel name, snake_cased into a single flat segment — like every other
389
+ // object), and its messages live in a message/ subfolder inside it. It
390
+ // nests under its owning realtime_server (from `server = "..."`):
391
+ // channel "rooms/{room_id}" (server "chat")
392
+ // → realtime/server/chat/channel/rooms_room_id/rooms_room_id.xs
393
+ //
394
+ // The channel name is snake_cased as a whole (rooms/{room_id} →
395
+ // rooms_room_id), so there is no path nesting and no bracket escaping —
396
+ // the on-disk name is a display form; the .xs content keeps the real
397
+ // name. A channel with no resolvable server (e.g. a pre-server export)
398
+ // falls back to the legacy flat channel/<path>/_channel.xs layout.
399
+ if (doc.server) {
400
+ typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.server), 'channel', snakeCase(doc.name));
401
+ baseName = snakeCase(doc.name);
402
+ }
403
+ else {
404
+ typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
405
+ baseName = '_channel';
406
+ }
380
407
  }
381
408
  else if (doc.type === 'message' && doc.channel) {
382
- typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
409
+ // Realtime v2 message → nests in a message/ subfolder under its channel,
410
+ // under that channel's server. The message names only its channel; the
411
+ // server is resolved by looking that channel up in the same multidoc
412
+ // (two-pass resolve).
413
+ // message "post" on channel "rooms/{room_id}" (server "chat")
414
+ // → realtime/server/chat/channel/rooms_room_id/message/post.xs
415
+ //
416
+ // Nesting matters beyond tidiness: message names are unique only WITHIN
417
+ // a channel, so a flat message/ directory would collide when two
418
+ // channels both define e.g. "say". A channel whose server can't be
419
+ // resolved falls back to the legacy flat channel/ layout.
420
+ const messageServer = getChannelServer(doc.channel);
421
+ typeDir = messageServer
422
+ ? path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(messageServer), 'channel', snakeCase(doc.channel), 'message')
423
+ : path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
383
424
  baseName = this.sanitizeFilename(doc.name);
384
425
  }
385
426
  else if (doc.type === 'api_group') {
@@ -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, channelPathSegments, parseDocument, } from '../../../utils/document-parser.js';
6
+ import { buildApiGroupFolderResolver, buildChannelServerResolver, channelPathSegments, parseDocument, } 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,6 +134,9 @@ 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;
@@ -195,24 +198,53 @@ Pulled 58 documents
195
198
  typeDir = path.join(outputDir, 'realtime', 'trigger');
196
199
  baseName = this.sanitizeFilename(doc.name);
197
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
+ }
198
210
  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
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
202
217
  //
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';
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
+ }
209
231
  }
210
232
  else if (doc.type === 'message' && doc.channel) {
211
- // Realtime v2 message → channel/{channel_path}/{message_name}.xs
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
+ //
212
240
  // Nesting matters beyond tidiness: message names are unique only WITHIN
213
241
  // 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));
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));
216
248
  baseName = this.sanitizeFilename(doc.name);
217
249
  }
218
250
  else if (doc.type === 'api_group') {
@@ -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,24 @@ 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;
41
61
  /**
42
62
  * Build a unique key for a document based on its type, name, verb, and api_group.
43
63
  * Used to match server GUID map entries back to local files.
@@ -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,32 @@ 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
+ }
99
136
  /**
100
137
  * Build a unique key for a document based on its type, name, verb, and api_group.
101
138
  * Used to match server GUID map entries back to local files.
@@ -17,6 +17,7 @@ export interface KnowledgeObject {
17
17
  mode?: string;
18
18
  name: string;
19
19
  scope?: string;
20
+ tag?: string[];
20
21
  }
21
22
  /** A locally-collected object that also remembers its primary file on disk (for GUID writeback). */
22
23
  export interface LocalKnowledgeObject extends KnowledgeObject {
@@ -12,7 +12,7 @@ import { dirname, join, relative, sep } from 'node:path';
12
12
  // let the server dictate file paths.
13
13
  export const KNOWLEDGE_DIR = 'knowledge';
14
14
  // ── Frontmatter ──────────────────────────────────────────────────────────────
15
- const FRONTMATTER_ORDER = ['name', 'description', 'knowledge_type', 'scope', 'inclusion', 'enabled', 'guid'];
15
+ const FRONTMATTER_ORDER = ['name', 'description', 'knowledge_type', 'inclusion', 'tags', 'guid'];
16
16
  /**
17
17
  * Build a primary `.md` file body: YAML frontmatter (built from the object's
18
18
  * structured fields) followed by the markdown content.
@@ -23,12 +23,10 @@ export function buildPrimaryContent(obj) {
23
23
  if (obj.description !== undefined)
24
24
  meta.description = obj.description;
25
25
  meta.knowledge_type = obj.knowledge_type;
26
- if (obj.scope !== undefined)
27
- meta.scope = obj.scope;
28
26
  if (obj.mode !== undefined)
29
27
  meta.inclusion = modeToInclusion(obj.mode);
30
- if (obj.enabled !== undefined)
31
- meta.enabled = obj.enabled;
28
+ if (obj.tag !== undefined && obj.tag.length > 0)
29
+ meta.tags = obj.tag;
32
30
  if (obj.guid !== undefined)
33
31
  meta.guid = obj.guid;
34
32
  // Emit keys in a stable, human-friendly order.
@@ -107,6 +105,10 @@ const MODE_TO_INCLUSION = {
107
105
  function modeToInclusion(mode) {
108
106
  return MODE_TO_INCLUSION[mode] ?? mode;
109
107
  }
108
+ /** Parse the on-disk `tags:` frontmatter value into a string array, or `undefined` if absent/malformed. */
109
+ function parseTags(tags) {
110
+ return Array.isArray(tags) && tags.every((t) => typeof t === 'string') ? tags : undefined;
111
+ }
110
112
  // ── Path mapping (CLI-owned layout) ────────────────────────────────────────────
111
113
  /**
112
114
  * Allocate a unique snake_case folder/file segment per call, suffixing
@@ -246,6 +248,9 @@ export function collectKnowledgeObjects(inputDir, include, exclude) {
246
248
  };
247
249
  if (typeof meta.guid === 'string')
248
250
  obj.guid = meta.guid;
251
+ const tags = parseTags(meta.tags);
252
+ if (tags)
253
+ obj.tag = tags;
249
254
  if (knowledgeType === 'skill') {
250
255
  const skillFolder = posixPath.slice('skills/'.length, posixPath.indexOf('/SKILL.md'));
251
256
  const refPrefix = `skills/${skillFolder}/references/`;
@@ -345,6 +350,7 @@ function canonical(obj) {
345
350
  files,
346
351
  mode: obj.mode ?? '',
347
352
  scope: obj.scope ?? '',
353
+ tag: [...(obj.tag ?? [])].sort(),
348
354
  });
349
355
  }
350
356
  /**
@@ -28,6 +28,16 @@ const REFERENCE_PATTERNS = [
28
28
  },
29
29
  // Schema foreign key references: table = "name" inside field definitions
30
30
  { keyword: 'table (FK)', regex: /\btable\s*=\s*"([^"]*)"/gm, targetType: 'table' },
31
+ // Realtime v2: a channel references its owning realtime_server by name via a
32
+ // top-level `realtime_server = "..."` (renamed from a bare `server` to match
33
+ // the api_group/mcp_server convention). The full keyword is unambiguous, so no
34
+ // false-match against another object type's `server` field.
35
+ {
36
+ keyword: 'channel (realtime_server)',
37
+ regex: /^\s*realtime_server\s*=\s*"([^"]*)"/gm,
38
+ sourceType: 'channel',
39
+ targetType: 'realtime_server',
40
+ },
31
41
  ];
32
42
  /**
33
43
  * Strip surrounding quotes from a name if present.
@@ -119,6 +129,9 @@ export function checkReferences(documents, serverOperations) {
119
129
  if (!parsed)
120
130
  continue;
121
131
  for (const pattern of REFERENCE_PATTERNS) {
132
+ // Skip patterns scoped to a different source document type.
133
+ if (pattern.sourceType && pattern.sourceType !== parsed.type)
134
+ continue;
122
135
  // Reset regex state for each document
123
136
  pattern.regex.lastIndex = 0;
124
137
  let match;