busa-cli 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as runCli } from "./run--bA-jLSP.js";
2
+ import { n as runCli } from "./run-Cx9V5brn.js";
3
3
  //#region src/cli.ts
4
4
  process.exit(await runCli(process.argv.slice(2)));
5
5
  //#endregion
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { n as runCli, r as render, t as HELP } from "./run--bA-jLSP.js";
1
+ import { n as runCli, r as render, t as HELP } from "./run-Cx9V5brn.js";
2
2
  import { DEFAULT_BASE_URL, createBusabaseClient, normalizeBaseUrl } from "busabase-sdk";
3
3
  export { DEFAULT_BASE_URL, HELP, createBusabaseClient, normalizeBaseUrl, render, runCli };
@@ -4992,6 +4992,23 @@ const userRefSchema = z.object({
4992
4992
  image: z.string().nullable(),
4993
4993
  role: z.string().nullable().optional()
4994
4994
  });
4995
+ const sourceChannelSchema = z.enum([
4996
+ "web_ui",
4997
+ "browser",
4998
+ "openapi",
4999
+ "sdk",
5000
+ "cli",
5001
+ "mcp",
5002
+ "skill",
5003
+ "webhook",
5004
+ "automation",
5005
+ "import"
5006
+ ]);
5007
+ const sourceAttributionSchema = z.object({
5008
+ displayName: z.string().nullable(),
5009
+ ownerName: z.string().nullable(),
5010
+ channel: sourceChannelSchema.nullable()
5011
+ });
4995
5012
  const commitSchema = z.object({
4996
5013
  id: z.string(),
4997
5014
  baseId: z.string().nullable(),
@@ -5083,6 +5100,7 @@ const changeRequestSchema = z.object({
5083
5100
  status: changeRequestStatusSchema,
5084
5101
  submittedBy: z.string(),
5085
5102
  submittedByUser: userRefSchema.nullable().optional().default(null),
5103
+ sourceAttribution: sourceAttributionSchema.nullable().optional(),
5086
5104
  sourceMeta: z.record(z.string(), z.unknown()),
5087
5105
  reviewPolicySnapshot: z.record(z.string(), z.unknown()),
5088
5106
  mergeSummary: z.record(z.string(), z.unknown()),
@@ -5171,6 +5189,7 @@ z.object({
5171
5189
  action: auditActionSchema,
5172
5190
  actorId: z.string(),
5173
5191
  actor: userRefSchema.nullable().optional().default(null),
5192
+ sourceAttribution: sourceAttributionSchema.nullable().optional(),
5174
5193
  baseId: z.string().nullable(),
5175
5194
  recordId: z.string().nullable(),
5176
5195
  changeRequestId: z.string().nullable(),
@@ -5286,29 +5305,38 @@ z.object({
5286
5305
  /** Opaque base64 cursor (`createdAt|id`) for keyset pagination. */
5287
5306
  cursor: z.string().optional(),
5288
5307
  status: z.array(changeRequestStatusSchema).optional(),
5289
- mine: z.boolean().optional()
5308
+ mine: z.boolean().optional(),
5309
+ affectsNodeId: z.string().min(1).optional()
5290
5310
  }).optional().default({ limit: 50 });
5291
5311
  z.object({
5292
5312
  changeRequests: z.array(changeRequestSchema),
5293
5313
  nextCursor: z.string().nullable()
5294
5314
  });
5295
- z.object({
5315
+ const changeRequestPageInputShape = {
5296
5316
  page: z.coerce.number().int().min(1).optional().default(1),
5297
5317
  pageSize: z.coerce.number().int().min(1).max(100).optional().default(50),
5298
5318
  status: z.array(changeRequestStatusSchema).optional(),
5299
5319
  mine: z.boolean().optional()
5320
+ };
5321
+ z.object({
5322
+ ...changeRequestPageInputShape,
5323
+ affectsNodeId: z.string().min(1).optional()
5300
5324
  }).optional().default({
5301
5325
  page: 1,
5302
5326
  pageSize: 50
5303
5327
  });
5304
- z.object({
5328
+ z.object(changeRequestPageInputShape).optional().default({
5329
+ page: 1,
5330
+ pageSize: 50
5331
+ });
5332
+ const listChangeRequestsPageResponseSchema = z.object({
5305
5333
  changeRequests: z.array(changeRequestSchema),
5306
5334
  total: z.number().int().nonnegative(),
5307
5335
  totalPages: z.number().int().nonnegative(),
5308
5336
  page: z.number().int().min(1),
5309
5337
  pageSize: z.number().int().min(1).max(100)
5310
5338
  });
5311
- z.object({
5339
+ const changeRequestCountsSchema = z.object({
5312
5340
  review: z.number().int().nonnegative(),
5313
5341
  changes: z.number().int().nonnegative(),
5314
5342
  created: z.number().int().nonnegative(),
@@ -5316,6 +5344,7 @@ z.object({
5316
5344
  merged: z.number().int().nonnegative(),
5317
5345
  rejected: z.number().int().nonnegative()
5318
5346
  });
5347
+ listChangeRequestsPageResponseSchema.extend({ counts: changeRequestCountsSchema });
5319
5348
  const SEARCH_SOURCES = [
5320
5349
  "records",
5321
5350
  "files",
@@ -5994,8 +6023,75 @@ const reportUnresolvableDocAssets = (slug, body, carried = /* @__PURE__ */ new S
5994
6023
  if (unresolved.length === 0) return void 0;
5995
6024
  return `Doc "${slug}" embeds ${unresolved.length} image(s) that this package does not carry (${unresolved.join(", ")}). Everything else about the doc is intact, but those images will not render on another host. Re-upload them in the Doc editor, or check that the source host can still serve them.`;
5996
6025
  };
6026
+ //#endregion
6027
+ //#region ../../packages/busabase-package/src/record-dependencies.ts
6028
+ const recordIdentity = (baseSlug, recordKey) => `${baseSlug}\u0000${recordKey}`;
6029
+ const toRecordKeyArray = (value) => {
6030
+ if (Array.isArray(value)) return value.filter((item) => typeof item === "string" && item.length > 0);
6031
+ if (typeof value === "string" && value.length > 0) return [value];
6032
+ return [];
6033
+ };
6034
+ const collectBases = (nodes) => {
6035
+ const bases = [];
6036
+ for (const node of nodes) {
6037
+ if (node.type === "base") bases.push(node);
6038
+ if (node.type === "folder") bases.push(...collectBases(node.children));
6039
+ }
6040
+ return bases;
6041
+ };
6042
+ const label = (baseSlug, recordKey) => `${baseSlug}/${recordKey}`;
6043
+ /**
6044
+ * Order sample records so every required relation points at an already-created row.
6045
+ *
6046
+ * Optional relations remain a separate linking pass. Required relations cannot: the
6047
+ * Base rejects a record before that later pass if its required value is absent.
6048
+ */
6049
+ const buildRecordCreateLayers = (nodes) => {
6050
+ const bases = collectBases(nodes);
6051
+ const basesBySlug = new Map(bases.map((base) => [base.slug, base]));
6052
+ const records = /* @__PURE__ */ new Map();
6053
+ for (const base of bases) for (const record of base.records) records.set(recordIdentity(base.slug, record.key), {
6054
+ base,
6055
+ record
6056
+ });
6057
+ const dependencies = /* @__PURE__ */ new Map();
6058
+ for (const planned of records.values()) {
6059
+ const id = recordIdentity(planned.base.slug, planned.record.key);
6060
+ const required = /* @__PURE__ */ new Set();
6061
+ for (const field of planned.base.base.fields) {
6062
+ if (field.type !== "relation" || !field.required) continue;
6063
+ const targetBaseSlug = field.options.targetBaseSlug;
6064
+ if (!targetBaseSlug || !basesBySlug.has(targetBaseSlug)) throw new Error(`Required relation "${planned.base.slug}.${field.slug}" targets missing Base "${targetBaseSlug || "(unset)"}". Nothing was installed.`);
6065
+ const keys = toRecordKeyArray(planned.record.fields[field.slug]);
6066
+ if (keys.length === 0) throw new Error(`Sample record "${label(planned.base.slug, planned.record.key)}" is missing required relation "${field.slug}". Nothing was installed.`);
6067
+ for (const key of keys) {
6068
+ const dependency = recordIdentity(targetBaseSlug, key);
6069
+ if (!records.has(dependency)) throw new Error(`Sample record "${label(planned.base.slug, planned.record.key)}" requires "${targetBaseSlug}/${key}" through relation "${field.slug}", but that target is not in this package. Nothing was installed.`);
6070
+ required.add(dependency);
6071
+ }
6072
+ }
6073
+ dependencies.set(id, required);
6074
+ }
6075
+ const remaining = new Map(records);
6076
+ const created = /* @__PURE__ */ new Set();
6077
+ const layers = [];
6078
+ while (remaining.size > 0) {
6079
+ const ready = [...remaining.entries()].filter(([id]) => [...dependencies.get(id) ?? []].every((dependency) => created.has(dependency))).sort(([, a], [, b]) => `${a.base.slug}\u0000${a.record.key}`.localeCompare(`${b.base.slug}\u0000${b.record.key}`, "en"));
6080
+ if (ready.length === 0) {
6081
+ const cycle = [...remaining.values()].map((item) => label(item.base.slug, item.record.key)).sort((a, b) => a.localeCompare(b, "en"));
6082
+ throw new Error(`Required sample relations contain a cycle involving: ${cycle.join(", ")}. Required relation targets must be creatable first. Nothing was installed.`);
6083
+ }
6084
+ layers.push(ready.map(([, planned]) => planned));
6085
+ for (const [id] of ready) {
6086
+ remaining.delete(id);
6087
+ created.add(id);
6088
+ }
6089
+ }
6090
+ return layers;
6091
+ };
5997
6092
  const fieldKey = (baseSlug, fieldSlug) => `${baseSlug}\u0000${fieldSlug}`;
5998
6093
  const applyInstall = async (client, plan, options) => {
6094
+ const recordCreateLayers = buildRecordCreateLayers(plan.tree.nodes);
5999
6095
  const state = {
6000
6096
  targetFolderNodeId: "",
6001
6097
  created: {
@@ -6014,90 +6110,144 @@ const applyInstall = async (client, plan, options) => {
6014
6110
  const bases = [];
6015
6111
  const progress = options.onProgress ?? (() => {});
6016
6112
  const app = resolveAppContext(plan, options);
6017
- const docAssetIds = await uploadDocAssets(client, plan.tree.assets ?? [], state, progress, options.serverUrl);
6018
- progress("Pass 1/5: creating the node tree…");
6019
- if (plan.existingTargetFolderNodeId) state.targetFolderNodeId = plan.existingTargetFolderNodeId;
6020
- else {
6021
- state.targetFolderNodeId = await createFolderNode(client, {
6022
- parentNodeId: void 0,
6023
- slug: plan.targetFolderSlug,
6024
- name: plan.tree.manifest.name,
6025
- description: plan.tree.manifest.description,
6026
- submittedBy: options.submittedBy,
6027
- metadata: app?.rootMetadata
6028
- });
6029
- state.created.folders++;
6030
- }
6031
- await createStructure(client, plan.tree.nodes, state.targetFolderNodeId, options, state, bases, fieldIds, docAssetIds, app);
6032
- if (app?.rootSkill) {
6033
- progress("Pass 1/5: installing the app skill…");
6034
- await createFileTreeNode(client, {
6035
- type: "skill",
6036
- slug: app.rootSkill.slug,
6037
- name: app.rootSkill.name,
6038
- description: app.rootSkill.description,
6039
- position: void 0,
6040
- files: app.rootSkill.files
6041
- }, state.targetFolderNodeId, options, state, app, { [TEMPLATE_SKILL_METADATA_KEY]: true });
6042
- }
6043
- const deferred = bases.filter((base) => base.node.base.fields.some(isDeferredField));
6044
- if (deferred.length > 0) {
6045
- progress("Pass 2/5: adding relation and AI fields…");
6046
- for (const base of deferred) for (const field of base.node.base.fields.filter(isDeferredField)) {
6047
- const updated = await client.bases.createField({
6048
- baseId: base.baseId,
6049
- slug: field.slug,
6050
- name: field.name,
6051
- type: field.type,
6052
- required: field.required,
6053
- options: toApiFieldOptions(field.options)
6054
- });
6055
- indexFields(fieldIds, base.node.slug, updated.fields);
6056
- }
6057
- }
6058
- const patchable = bases.flatMap((base) => base.node.base.fields.filter(needsFieldIdPatch).map((field) => ({
6059
- base,
6060
- field
6061
- })));
6062
- if (patchable.length > 0) {
6063
- progress("Pass 3/5: resolving inverse and AI source field references…");
6064
- for (const { base, field } of patchable) {
6065
- const fieldId = fieldIds.get(fieldKey(base.node.slug, field.slug));
6066
- if (!fieldId) throw new Error(`Internal error: no field id for ${base.node.slug}.${field.slug} in pass 3.`);
6067
- const patched = resolveFieldIdOptions(field, base.node.slug, fieldIds, state.warnings);
6068
- if (!patched) continue;
6069
- const changeRequest = await client.bases.fieldChangeRequest({
6070
- operation: "update",
6071
- baseId: base.baseId,
6072
- fieldId,
6073
- patch: { options: patched },
6074
- message: `Wire up ${base.node.slug}.${field.slug} references`,
6113
+ let phase = "Pass 1/5 (structure)";
6114
+ try {
6115
+ const docAssetIds = await uploadDocAssets(client, plan.tree.assets ?? [], state, progress, options.serverUrl);
6116
+ progress("Pass 1/5: creating the node tree…");
6117
+ if (plan.existingTargetFolderNodeId) state.targetFolderNodeId = plan.existingTargetFolderNodeId;
6118
+ else {
6119
+ state.targetFolderNodeId = await createFolderNode(client, {
6120
+ parentNodeId: void 0,
6121
+ slug: plan.targetFolderSlug,
6122
+ name: plan.tree.manifest.name,
6123
+ description: plan.tree.manifest.description,
6075
6124
  submittedBy: options.submittedBy,
6076
- autoMerge: true
6125
+ metadata: app?.rootMetadata
6077
6126
  });
6078
- if (changeRequest.status !== "merged") throw new Error(`Field reference wiring for ${base.node.slug}.${field.slug} was requested immediately but came back "${changeRequest.status}"; install cannot leave a Base's relation fields unwired.`);
6127
+ state.created.folders++;
6079
6128
  }
6080
- }
6081
- const recordIdsByKey = /* @__PURE__ */ new Map();
6082
- const withRecords = bases.filter((base) => base.node.records.length > 0);
6083
- if (withRecords.length > 0) {
6084
- progress("Pass 4/5: creating records…");
6085
- for (const base of withRecords) await createRecords(client, base, plan, options, state, recordIdsByKey, app);
6086
- }
6087
- const relationFieldSlugs = /* @__PURE__ */ new Map();
6088
- for (const base of bases) {
6089
- const slugs = base.node.base.fields.filter((field) => field.type === "relation").map((field) => field.slug);
6090
- if (slugs.length > 0) relationFieldSlugs.set(base.node.slug, slugs);
6091
- }
6092
- if (relationFieldSlugs.size > 0) {
6093
- progress("Pass 5/5: linking relations…");
6129
+ await createStructure(client, plan.tree.nodes, state.targetFolderNodeId, options, state, bases, fieldIds, docAssetIds, app);
6130
+ if (app?.rootSkill) {
6131
+ progress("Pass 1/5: installing the app skill…");
6132
+ await createFileTreeNode(client, {
6133
+ type: "skill",
6134
+ slug: app.rootSkill.slug,
6135
+ name: app.rootSkill.name,
6136
+ description: app.rootSkill.description,
6137
+ position: void 0,
6138
+ files: app.rootSkill.files
6139
+ }, state.targetFolderNodeId, options, state, app, { [TEMPLATE_SKILL_METADATA_KEY]: true });
6140
+ }
6141
+ phase = "Pass 2/5 (relation and AI fields)";
6142
+ const deferred = bases.filter((base) => base.node.base.fields.some(isDeferredField));
6143
+ if (deferred.length > 0) {
6144
+ progress("Pass 2/5: adding relation and AI fields…");
6145
+ for (const base of deferred) for (const field of base.node.base.fields.filter(isDeferredField)) {
6146
+ const updated = await client.bases.createField({
6147
+ baseId: base.baseId,
6148
+ slug: field.slug,
6149
+ name: field.name,
6150
+ type: field.type,
6151
+ required: field.required,
6152
+ options: toApiFieldOptions(field.options)
6153
+ });
6154
+ indexFields(fieldIds, base.node.slug, updated.fields);
6155
+ }
6156
+ }
6157
+ phase = "Pass 3/5 (field reference wiring)";
6158
+ const patchable = bases.flatMap((base) => base.node.base.fields.filter(needsFieldIdPatch).map((field) => ({
6159
+ base,
6160
+ field
6161
+ })));
6162
+ if (patchable.length > 0) {
6163
+ progress("Pass 3/5: resolving inverse and AI source field references…");
6164
+ for (const { base, field } of patchable) {
6165
+ const fieldId = fieldIds.get(fieldKey(base.node.slug, field.slug));
6166
+ if (!fieldId) throw new Error(`Internal error: no field id for ${base.node.slug}.${field.slug} in pass 3.`);
6167
+ const patched = resolveFieldIdOptions(field, base.node.slug, fieldIds, state.warnings);
6168
+ if (!patched) continue;
6169
+ const changeRequest = await client.bases.fieldChangeRequest({
6170
+ operation: "update",
6171
+ baseId: base.baseId,
6172
+ fieldId,
6173
+ patch: { options: patched },
6174
+ message: `Wire up ${base.node.slug}.${field.slug} references`,
6175
+ submittedBy: options.submittedBy,
6176
+ autoMerge: true
6177
+ });
6178
+ if (changeRequest.status !== "merged") throw new Error(`Field reference wiring for ${base.node.slug}.${field.slug} was requested immediately but came back "${changeRequest.status}"; install cannot leave a Base's relation fields unwired.`);
6179
+ }
6180
+ }
6181
+ phase = "Pass 4/5 (sample records)";
6182
+ const recordIdsByKey = /* @__PURE__ */ new Map();
6183
+ if (recordCreateLayers.length > 0) {
6184
+ progress("Pass 4/5: creating records…");
6185
+ const basesBySlug = new Map(bases.map((base) => [base.node.slug, base]));
6186
+ for (const layer of recordCreateLayers) {
6187
+ const byBase = /* @__PURE__ */ new Map();
6188
+ for (const planned of layer) {
6189
+ const group = byBase.get(planned.base.slug) ?? [];
6190
+ group.push(planned);
6191
+ byBase.set(planned.base.slug, group);
6192
+ }
6193
+ for (const [baseSlug, planned] of [...byBase].sort(([a], [b]) => a.localeCompare(b, "en"))) {
6194
+ const base = basesBySlug.get(baseSlug);
6195
+ if (!base) throw new Error(`Internal error: no materialized Base for ${baseSlug} in pass 4.`);
6196
+ await createRecords(client, base, planned.map((item) => item.record), plan, options, state, recordIdsByKey, app);
6197
+ }
6198
+ }
6199
+ }
6200
+ phase = "Pass 5/5 (relation links)";
6201
+ const relationFields = /* @__PURE__ */ new Map();
6094
6202
  for (const base of bases) {
6095
- const slugs = relationFieldSlugs.get(base.node.slug);
6096
- if (!slugs) continue;
6097
- await linkRelations(client, base, slugs, recordIdsByKey, options, state);
6203
+ const fields = base.node.base.fields.filter((field) => field.type === "relation");
6204
+ if (fields.length > 0) relationFields.set(base.node.slug, fields);
6205
+ }
6206
+ if (relationFields.size > 0) {
6207
+ progress("Pass 5/5: linking relations…");
6208
+ for (const base of bases) {
6209
+ const fields = relationFields.get(base.node.slug);
6210
+ if (!fields) continue;
6211
+ await linkRelations(client, base, fields, recordIdsByKey, options, state);
6212
+ }
6098
6213
  }
6214
+ return state;
6215
+ } catch (error) {
6216
+ const created = Object.entries(state.created).filter(([, count]) => count > 0).map(([kind, count]) => `${count} ${kind}`).join(", ");
6217
+ const partial = created ? `Created before failure: ${created}. Target folder: ${plan.targetFolderSlug}.` : "No package resources were created.";
6218
+ const pending = state.pendingChangeRequests ? ` ${state.pendingChangeRequests} ChangeRequest(s) may still be pending.` : "";
6219
+ const reason = error instanceof Error ? error.message : String(error);
6220
+ const message = `Install stopped during ${phase}. ${partial}${pending} Cause: ${reason}`;
6221
+ const details = {
6222
+ phase,
6223
+ targetFolderSlug: plan.targetFolderSlug,
6224
+ created: { ...state.created },
6225
+ pendingChangeRequests: state.pendingChangeRequests
6226
+ };
6227
+ if (error instanceof Error) {
6228
+ error.message = message;
6229
+ throw attachInstallFailureDetails(error, details);
6230
+ }
6231
+ throw attachInstallFailureDetails(new Error(message), details);
6099
6232
  }
6100
- return state;
6233
+ };
6234
+ /**
6235
+ * A property rather than a subclass: the whole point of the rethrow above is to
6236
+ * keep the original error's prototype (an `ORPCError`'s `code`/`status`/`data`
6237
+ * are what make a permission or quota failure actionable), so the diagnostics
6238
+ * have to ride along on it instead of replacing it.
6239
+ */
6240
+ const INSTALL_FAILURE_DETAILS_KEY = "busabaseInstallFailure";
6241
+ const attachInstallFailureDetails = (error, details) => {
6242
+ try {
6243
+ Object.defineProperty(error, INSTALL_FAILURE_DETAILS_KEY, {
6244
+ value: details,
6245
+ enumerable: false,
6246
+ configurable: true,
6247
+ writable: true
6248
+ });
6249
+ } catch {}
6250
+ return error;
6101
6251
  };
6102
6252
  const resolveAppContext = (plan, options) => {
6103
6253
  if (!plan.isTemplate) return void 0;
@@ -6453,28 +6603,39 @@ const resolveFieldIdOptions = (field, baseSlug, fieldIds, warnings) => {
6453
6603
  const indexFields = (fieldIds, baseSlug, fields) => {
6454
6604
  for (const field of fields) fieldIds.set(fieldKey(baseSlug, field.slug), field.id);
6455
6605
  };
6456
- /** Values the server computes, and relation values (pass 5), are never sent in pass 4. */
6457
- const toCreatableFields = (node, fields) => {
6458
- const typeBySlug = new Map(node.base.fields.map((field) => [field.slug, field.type]));
6606
+ /**
6607
+ * Values the server computes and optional relations are omitted in pass 4.
6608
+ * Required relations are resolved to canonical ids once their dependency layer exists.
6609
+ */
6610
+ const toCreatableFields = (node, fields, recordIdsByKey) => {
6611
+ const fieldBySlug = new Map(node.base.fields.map((field) => [field.slug, field]));
6459
6612
  const result = {};
6460
6613
  for (const [slug, value] of Object.entries(fields)) {
6461
- const type = typeBySlug.get(slug);
6462
- if (!type) continue;
6463
- if (type === "relation") continue;
6464
- if (PACKAGE_COMPUTED_FIELD_TYPES.includes(type)) continue;
6465
- if (type === "attachment") continue;
6614
+ const field = fieldBySlug.get(slug);
6615
+ if (!field) continue;
6616
+ if (field.type === "relation") {
6617
+ if (!field.required) continue;
6618
+ const targetBaseSlug = field.options.targetBaseSlug;
6619
+ const keys = toRecordKeyArray(value);
6620
+ const targetIds = keys.map((key) => targetBaseSlug ? recordIdsByKey.get(recordIdentity(targetBaseSlug, key)) : void 0).filter((id) => Boolean(id));
6621
+ if (targetIds.length !== keys.length) throw new Error(`Internal error: required relation ${node.slug}.${slug} was scheduled before its targets existed.`);
6622
+ result[slug] = targetIds;
6623
+ continue;
6624
+ }
6625
+ if (PACKAGE_COMPUTED_FIELD_TYPES.includes(field.type)) continue;
6626
+ if (field.type === "attachment") continue;
6466
6627
  result[slug] = value;
6467
6628
  }
6468
6629
  return result;
6469
6630
  };
6470
- const createRecords = async (client, base, plan, options, state, recordIdsByKey, app) => {
6631
+ const createRecords = async (client, base, recordsToCreate, plan, options, state, recordIdsByKey, app) => {
6471
6632
  const mergeRecords = options.autoMerge || (app?.mergeSampleRecords ?? false);
6472
- const records = [...base.node.records].sort((a, b) => a.key.localeCompare(b.key, "en"));
6633
+ const records = [...recordsToCreate].sort((a, b) => a.key.localeCompare(b.key, "en"));
6473
6634
  for (let offset = 0; offset < records.length; offset += 200) {
6474
6635
  const batch = records.slice(offset, offset + 200);
6475
6636
  const changeRequest = await client.bases.createBulkChangeRequest({
6476
6637
  baseId: base.baseId,
6477
- records: batch.map((record) => toCreatableFields(base.node, record.fields)),
6638
+ records: batch.map((record) => toCreatableFields(base.node, record.fields, recordIdsByKey)),
6478
6639
  message: `Install ${batch.length} record(s) into ${base.node.slug}`,
6479
6640
  submittedBy: options.submittedBy,
6480
6641
  idempotencyKey: batchIdempotencyKey(plan.tree.manifest.name, batch[0].key),
@@ -6487,7 +6648,7 @@ const createRecords = async (client, base, plan, options, state, recordIdsByKey,
6487
6648
  const created = (await approveAndMerge(client, changeRequest.id)).changeRequest.operations.filter((operation) => operation.operation === "record_create").sort((a, b) => a.position - b.position);
6488
6649
  for (const [index, record] of batch.entries()) {
6489
6650
  const recordId = created[index]?.mergedRecordId;
6490
- if (recordId) recordIdsByKey.set(record.key, recordId);
6651
+ if (recordId) recordIdsByKey.set(recordIdentity(base.node.slug, record.key), recordId);
6491
6652
  }
6492
6653
  state.created.records += batch.length;
6493
6654
  }
@@ -6502,30 +6663,31 @@ const createRecords = async (client, base, plan, options, state, recordIdsByKey,
6502
6663
  * so the same package always produces the same batches and therefore the same keys.
6503
6664
  */
6504
6665
  const batchIdempotencyKey = (packageName, firstRecordKey) => `pkg:${packageName}:${firstRecordKey}`;
6505
- const linkRelations = async (client, base, relationFieldSlugs, recordIdsByKey, options, state) => {
6666
+ const linkRelations = async (client, base, relationFields, recordIdsByKey, options, state) => {
6506
6667
  for (const record of base.node.records) {
6507
6668
  const relationValues = {};
6508
- let hasRelation = false;
6509
- for (const slug of relationFieldSlugs) {
6510
- const value = record.fields[slug];
6511
- const keys = toKeyArray(value);
6669
+ let needsUpdate = false;
6670
+ for (const field of relationFields) {
6671
+ const value = record.fields[field.slug];
6672
+ const keys = toRecordKeyArray(value);
6512
6673
  if (keys.length === 0) continue;
6513
- const targetIds = keys.map((key) => recordIdsByKey.get(key)).filter((id) => Boolean(id));
6514
- const unresolved = keys.filter((key) => !recordIdsByKey.has(key));
6515
- if (unresolved.length > 0) state.warnings.push(`Record "${record.key}" in base "${base.node.slug}" links field "${slug}" to ${unresolved.length} record(s) that are not in this package — those links were skipped.`);
6674
+ const targetBaseSlug = field.options.targetBaseSlug;
6675
+ const targetIds = keys.map((key) => targetBaseSlug ? recordIdsByKey.get(recordIdentity(targetBaseSlug, key)) : void 0).filter((id) => Boolean(id));
6676
+ const unresolved = keys.filter((key) => !targetBaseSlug || !recordIdsByKey.has(recordIdentity(targetBaseSlug, key)));
6677
+ if (unresolved.length > 0) state.warnings.push(`Record "${record.key}" in base "${base.node.slug}" links field "${field.slug}" to ${unresolved.length} record(s) that are not in target Base "${targetBaseSlug || "(unset)"}" — those links were skipped.`);
6516
6678
  if (targetIds.length > 0) {
6517
- relationValues[slug] = targetIds;
6518
- hasRelation = true;
6679
+ relationValues[field.slug] = targetIds;
6680
+ if (!field.required) needsUpdate = true;
6519
6681
  }
6520
6682
  }
6521
- if (!hasRelation) continue;
6522
- const recordId = recordIdsByKey.get(record.key);
6683
+ if (!needsUpdate) continue;
6684
+ const recordId = recordIdsByKey.get(recordIdentity(base.node.slug, record.key));
6523
6685
  if (!recordId) continue;
6524
6686
  const changeRequest = await client.records.changeRequest({
6525
6687
  operation: "update",
6526
6688
  recordId,
6527
6689
  fields: {
6528
- ...toCreatableFields(base.node, record.fields),
6690
+ ...toCreatableFields(base.node, record.fields, recordIdsByKey),
6529
6691
  ...relationValues
6530
6692
  },
6531
6693
  message: `Link relations for ${base.node.slug}`,
@@ -6536,11 +6698,6 @@ const linkRelations = async (client, base, relationFieldSlugs, recordIdsByKey, o
6536
6698
  await approveAndMerge(client, changeRequest.id);
6537
6699
  }
6538
6700
  };
6539
- const toKeyArray = (value) => {
6540
- if (Array.isArray(value)) return value.filter((item) => typeof item === "string" && item.length > 0);
6541
- if (typeof value === "string" && value.length > 0) return [value];
6542
- return [];
6543
- };
6544
6701
  /** Merging requires an `approved` status, so every auto-merge is review-then-merge. */
6545
6702
  const approveAndMerge = async (client, changeRequestId) => {
6546
6703
  const reviewResult = (await client.changeRequests.review({
@@ -7681,6 +7838,7 @@ const buildInstallPlan = (tree, target, options = {}) => {
7681
7838
  planned = applyRenames(prefixed, collisions, target.existingNodeSlugsByType);
7682
7839
  for (const [installedSlug, originalSlug] of collectSlugRewrites(tree, planned)) resourceKeysBySlug[installedSlug] = originalSlug;
7683
7840
  }
7841
+ buildRecordCreateLayers(planned.nodes);
7684
7842
  collectWarnings(planned, warnings);
7685
7843
  const counts = countTree(planned);
7686
7844
  if (counts.files > 5e3) throw new Error(`Package has ${counts.files} files, above the ${PACKAGE_MAX_FILE_COUNT}-file limit. Nothing was installed.`);
@@ -9188,6 +9346,7 @@ function resolveConfig(opts) {
9188
9346
  webUrl: process.env.BUSABASE_WEB_URL ?? file.BUSABASE_WEB_URL ?? baseUrl,
9189
9347
  apiKey: opts.apiKey ?? process.env.BUSABASE_API_KEY ?? file.BUSABASE_API_KEY,
9190
9348
  spaceId: opts.spaceId ?? process.env.BUSABASE_SPACE_ID ?? file.BUSABASE_SPACE_ID,
9349
+ sourceChannel: "cli",
9191
9350
  output: opts.output ?? configuredOutput() ?? "text",
9192
9351
  fetch: withRetry(fetch, { retries: opts.retries ?? DEFAULT_RETRIES })
9193
9352
  };
@@ -9334,7 +9493,8 @@ async function rawFetch(config, method, path, body) {
9334
9493
  headers: {
9335
9494
  ...body !== void 0 ? { "content-type": "application/json" } : {},
9336
9495
  ...config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {},
9337
- ...config.spaceId ? { "x-busabase-space": config.spaceId } : {}
9496
+ ...config.spaceId ? { "x-busabase-space": config.spaceId } : {},
9497
+ "x-busabase-channel": "cli"
9338
9498
  },
9339
9499
  body: body !== void 0 ? JSON.stringify(body) : void 0
9340
9500
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "busa-cli",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "Command-line client for the Busabase OpenAPI REST API. Talks to a local or remote `busabase server`. Short-name alias for busabase-cli.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/busabase/busabase/tree/main/apps/busabase-cli",
@@ -33,7 +33,7 @@
33
33
  "dependencies": {
34
34
  "@zip.js/zip.js": "^2.8.20",
35
35
  "busabase-package": "workspace:*",
36
- "busabase-sdk": "0.19.1",
36
+ "busabase-sdk": "0.20.0",
37
37
  "commander": "^14.0.0",
38
38
  "tar-stream": "^3.1.7",
39
39
  "yaml": "^2.9.0",