busa-cli 0.19.1 → 0.19.2

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-ZDakuDNP.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-ZDakuDNP.js";
2
2
  import { DEFAULT_BASE_URL, createBusabaseClient, normalizeBaseUrl } from "busabase-sdk";
3
3
  export { DEFAULT_BASE_URL, HELP, createBusabaseClient, normalizeBaseUrl, render, runCli };
@@ -5301,14 +5301,14 @@ z.object({
5301
5301
  page: 1,
5302
5302
  pageSize: 50
5303
5303
  });
5304
- z.object({
5304
+ const listChangeRequestsPageResponseSchema = z.object({
5305
5305
  changeRequests: z.array(changeRequestSchema),
5306
5306
  total: z.number().int().nonnegative(),
5307
5307
  totalPages: z.number().int().nonnegative(),
5308
5308
  page: z.number().int().min(1),
5309
5309
  pageSize: z.number().int().min(1).max(100)
5310
5310
  });
5311
- z.object({
5311
+ const changeRequestCountsSchema = z.object({
5312
5312
  review: z.number().int().nonnegative(),
5313
5313
  changes: z.number().int().nonnegative(),
5314
5314
  created: z.number().int().nonnegative(),
@@ -5316,6 +5316,7 @@ z.object({
5316
5316
  merged: z.number().int().nonnegative(),
5317
5317
  rejected: z.number().int().nonnegative()
5318
5318
  });
5319
+ listChangeRequestsPageResponseSchema.extend({ counts: changeRequestCountsSchema });
5319
5320
  const SEARCH_SOURCES = [
5320
5321
  "records",
5321
5322
  "files",
@@ -5994,8 +5995,75 @@ const reportUnresolvableDocAssets = (slug, body, carried = /* @__PURE__ */ new S
5994
5995
  if (unresolved.length === 0) return void 0;
5995
5996
  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
5997
  };
5998
+ //#endregion
5999
+ //#region ../../packages/busabase-package/src/record-dependencies.ts
6000
+ const recordIdentity = (baseSlug, recordKey) => `${baseSlug}\u0000${recordKey}`;
6001
+ const toRecordKeyArray = (value) => {
6002
+ if (Array.isArray(value)) return value.filter((item) => typeof item === "string" && item.length > 0);
6003
+ if (typeof value === "string" && value.length > 0) return [value];
6004
+ return [];
6005
+ };
6006
+ const collectBases = (nodes) => {
6007
+ const bases = [];
6008
+ for (const node of nodes) {
6009
+ if (node.type === "base") bases.push(node);
6010
+ if (node.type === "folder") bases.push(...collectBases(node.children));
6011
+ }
6012
+ return bases;
6013
+ };
6014
+ const label = (baseSlug, recordKey) => `${baseSlug}/${recordKey}`;
6015
+ /**
6016
+ * Order sample records so every required relation points at an already-created row.
6017
+ *
6018
+ * Optional relations remain a separate linking pass. Required relations cannot: the
6019
+ * Base rejects a record before that later pass if its required value is absent.
6020
+ */
6021
+ const buildRecordCreateLayers = (nodes) => {
6022
+ const bases = collectBases(nodes);
6023
+ const basesBySlug = new Map(bases.map((base) => [base.slug, base]));
6024
+ const records = /* @__PURE__ */ new Map();
6025
+ for (const base of bases) for (const record of base.records) records.set(recordIdentity(base.slug, record.key), {
6026
+ base,
6027
+ record
6028
+ });
6029
+ const dependencies = /* @__PURE__ */ new Map();
6030
+ for (const planned of records.values()) {
6031
+ const id = recordIdentity(planned.base.slug, planned.record.key);
6032
+ const required = /* @__PURE__ */ new Set();
6033
+ for (const field of planned.base.base.fields) {
6034
+ if (field.type !== "relation" || !field.required) continue;
6035
+ const targetBaseSlug = field.options.targetBaseSlug;
6036
+ if (!targetBaseSlug || !basesBySlug.has(targetBaseSlug)) throw new Error(`Required relation "${planned.base.slug}.${field.slug}" targets missing Base "${targetBaseSlug || "(unset)"}". Nothing was installed.`);
6037
+ const keys = toRecordKeyArray(planned.record.fields[field.slug]);
6038
+ 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.`);
6039
+ for (const key of keys) {
6040
+ const dependency = recordIdentity(targetBaseSlug, key);
6041
+ 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.`);
6042
+ required.add(dependency);
6043
+ }
6044
+ }
6045
+ dependencies.set(id, required);
6046
+ }
6047
+ const remaining = new Map(records);
6048
+ const created = /* @__PURE__ */ new Set();
6049
+ const layers = [];
6050
+ while (remaining.size > 0) {
6051
+ 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"));
6052
+ if (ready.length === 0) {
6053
+ const cycle = [...remaining.values()].map((item) => label(item.base.slug, item.record.key)).sort((a, b) => a.localeCompare(b, "en"));
6054
+ throw new Error(`Required sample relations contain a cycle involving: ${cycle.join(", ")}. Required relation targets must be creatable first. Nothing was installed.`);
6055
+ }
6056
+ layers.push(ready.map(([, planned]) => planned));
6057
+ for (const [id] of ready) {
6058
+ remaining.delete(id);
6059
+ created.add(id);
6060
+ }
6061
+ }
6062
+ return layers;
6063
+ };
5997
6064
  const fieldKey = (baseSlug, fieldSlug) => `${baseSlug}\u0000${fieldSlug}`;
5998
6065
  const applyInstall = async (client, plan, options) => {
6066
+ const recordCreateLayers = buildRecordCreateLayers(plan.tree.nodes);
5999
6067
  const state = {
6000
6068
  targetFolderNodeId: "",
6001
6069
  created: {
@@ -6014,90 +6082,144 @@ const applyInstall = async (client, plan, options) => {
6014
6082
  const bases = [];
6015
6083
  const progress = options.onProgress ?? (() => {});
6016
6084
  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`,
6085
+ let phase = "Pass 1/5 (structure)";
6086
+ try {
6087
+ const docAssetIds = await uploadDocAssets(client, plan.tree.assets ?? [], state, progress, options.serverUrl);
6088
+ progress("Pass 1/5: creating the node tree…");
6089
+ if (plan.existingTargetFolderNodeId) state.targetFolderNodeId = plan.existingTargetFolderNodeId;
6090
+ else {
6091
+ state.targetFolderNodeId = await createFolderNode(client, {
6092
+ parentNodeId: void 0,
6093
+ slug: plan.targetFolderSlug,
6094
+ name: plan.tree.manifest.name,
6095
+ description: plan.tree.manifest.description,
6075
6096
  submittedBy: options.submittedBy,
6076
- autoMerge: true
6097
+ metadata: app?.rootMetadata
6077
6098
  });
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.`);
6099
+ state.created.folders++;
6079
6100
  }
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…");
6101
+ await createStructure(client, plan.tree.nodes, state.targetFolderNodeId, options, state, bases, fieldIds, docAssetIds, app);
6102
+ if (app?.rootSkill) {
6103
+ progress("Pass 1/5: installing the app skill…");
6104
+ await createFileTreeNode(client, {
6105
+ type: "skill",
6106
+ slug: app.rootSkill.slug,
6107
+ name: app.rootSkill.name,
6108
+ description: app.rootSkill.description,
6109
+ position: void 0,
6110
+ files: app.rootSkill.files
6111
+ }, state.targetFolderNodeId, options, state, app, { [TEMPLATE_SKILL_METADATA_KEY]: true });
6112
+ }
6113
+ phase = "Pass 2/5 (relation and AI fields)";
6114
+ const deferred = bases.filter((base) => base.node.base.fields.some(isDeferredField));
6115
+ if (deferred.length > 0) {
6116
+ progress("Pass 2/5: adding relation and AI fields…");
6117
+ for (const base of deferred) for (const field of base.node.base.fields.filter(isDeferredField)) {
6118
+ const updated = await client.bases.createField({
6119
+ baseId: base.baseId,
6120
+ slug: field.slug,
6121
+ name: field.name,
6122
+ type: field.type,
6123
+ required: field.required,
6124
+ options: toApiFieldOptions(field.options)
6125
+ });
6126
+ indexFields(fieldIds, base.node.slug, updated.fields);
6127
+ }
6128
+ }
6129
+ phase = "Pass 3/5 (field reference wiring)";
6130
+ const patchable = bases.flatMap((base) => base.node.base.fields.filter(needsFieldIdPatch).map((field) => ({
6131
+ base,
6132
+ field
6133
+ })));
6134
+ if (patchable.length > 0) {
6135
+ progress("Pass 3/5: resolving inverse and AI source field references…");
6136
+ for (const { base, field } of patchable) {
6137
+ const fieldId = fieldIds.get(fieldKey(base.node.slug, field.slug));
6138
+ if (!fieldId) throw new Error(`Internal error: no field id for ${base.node.slug}.${field.slug} in pass 3.`);
6139
+ const patched = resolveFieldIdOptions(field, base.node.slug, fieldIds, state.warnings);
6140
+ if (!patched) continue;
6141
+ const changeRequest = await client.bases.fieldChangeRequest({
6142
+ operation: "update",
6143
+ baseId: base.baseId,
6144
+ fieldId,
6145
+ patch: { options: patched },
6146
+ message: `Wire up ${base.node.slug}.${field.slug} references`,
6147
+ submittedBy: options.submittedBy,
6148
+ autoMerge: true
6149
+ });
6150
+ 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.`);
6151
+ }
6152
+ }
6153
+ phase = "Pass 4/5 (sample records)";
6154
+ const recordIdsByKey = /* @__PURE__ */ new Map();
6155
+ if (recordCreateLayers.length > 0) {
6156
+ progress("Pass 4/5: creating records…");
6157
+ const basesBySlug = new Map(bases.map((base) => [base.node.slug, base]));
6158
+ for (const layer of recordCreateLayers) {
6159
+ const byBase = /* @__PURE__ */ new Map();
6160
+ for (const planned of layer) {
6161
+ const group = byBase.get(planned.base.slug) ?? [];
6162
+ group.push(planned);
6163
+ byBase.set(planned.base.slug, group);
6164
+ }
6165
+ for (const [baseSlug, planned] of [...byBase].sort(([a], [b]) => a.localeCompare(b, "en"))) {
6166
+ const base = basesBySlug.get(baseSlug);
6167
+ if (!base) throw new Error(`Internal error: no materialized Base for ${baseSlug} in pass 4.`);
6168
+ await createRecords(client, base, planned.map((item) => item.record), plan, options, state, recordIdsByKey, app);
6169
+ }
6170
+ }
6171
+ }
6172
+ phase = "Pass 5/5 (relation links)";
6173
+ const relationFields = /* @__PURE__ */ new Map();
6094
6174
  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);
6175
+ const fields = base.node.base.fields.filter((field) => field.type === "relation");
6176
+ if (fields.length > 0) relationFields.set(base.node.slug, fields);
6098
6177
  }
6178
+ if (relationFields.size > 0) {
6179
+ progress("Pass 5/5: linking relations…");
6180
+ for (const base of bases) {
6181
+ const fields = relationFields.get(base.node.slug);
6182
+ if (!fields) continue;
6183
+ await linkRelations(client, base, fields, recordIdsByKey, options, state);
6184
+ }
6185
+ }
6186
+ return state;
6187
+ } catch (error) {
6188
+ const created = Object.entries(state.created).filter(([, count]) => count > 0).map(([kind, count]) => `${count} ${kind}`).join(", ");
6189
+ const partial = created ? `Created before failure: ${created}. Target folder: ${plan.targetFolderSlug}.` : "No package resources were created.";
6190
+ const pending = state.pendingChangeRequests ? ` ${state.pendingChangeRequests} ChangeRequest(s) may still be pending.` : "";
6191
+ const reason = error instanceof Error ? error.message : String(error);
6192
+ const message = `Install stopped during ${phase}. ${partial}${pending} Cause: ${reason}`;
6193
+ const details = {
6194
+ phase,
6195
+ targetFolderSlug: plan.targetFolderSlug,
6196
+ created: { ...state.created },
6197
+ pendingChangeRequests: state.pendingChangeRequests
6198
+ };
6199
+ if (error instanceof Error) {
6200
+ error.message = message;
6201
+ throw attachInstallFailureDetails(error, details);
6202
+ }
6203
+ throw attachInstallFailureDetails(new Error(message), details);
6099
6204
  }
6100
- return state;
6205
+ };
6206
+ /**
6207
+ * A property rather than a subclass: the whole point of the rethrow above is to
6208
+ * keep the original error's prototype (an `ORPCError`'s `code`/`status`/`data`
6209
+ * are what make a permission or quota failure actionable), so the diagnostics
6210
+ * have to ride along on it instead of replacing it.
6211
+ */
6212
+ const INSTALL_FAILURE_DETAILS_KEY = "busabaseInstallFailure";
6213
+ const attachInstallFailureDetails = (error, details) => {
6214
+ try {
6215
+ Object.defineProperty(error, INSTALL_FAILURE_DETAILS_KEY, {
6216
+ value: details,
6217
+ enumerable: false,
6218
+ configurable: true,
6219
+ writable: true
6220
+ });
6221
+ } catch {}
6222
+ return error;
6101
6223
  };
6102
6224
  const resolveAppContext = (plan, options) => {
6103
6225
  if (!plan.isTemplate) return void 0;
@@ -6453,28 +6575,39 @@ const resolveFieldIdOptions = (field, baseSlug, fieldIds, warnings) => {
6453
6575
  const indexFields = (fieldIds, baseSlug, fields) => {
6454
6576
  for (const field of fields) fieldIds.set(fieldKey(baseSlug, field.slug), field.id);
6455
6577
  };
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]));
6578
+ /**
6579
+ * Values the server computes and optional relations are omitted in pass 4.
6580
+ * Required relations are resolved to canonical ids once their dependency layer exists.
6581
+ */
6582
+ const toCreatableFields = (node, fields, recordIdsByKey) => {
6583
+ const fieldBySlug = new Map(node.base.fields.map((field) => [field.slug, field]));
6459
6584
  const result = {};
6460
6585
  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;
6586
+ const field = fieldBySlug.get(slug);
6587
+ if (!field) continue;
6588
+ if (field.type === "relation") {
6589
+ if (!field.required) continue;
6590
+ const targetBaseSlug = field.options.targetBaseSlug;
6591
+ const keys = toRecordKeyArray(value);
6592
+ const targetIds = keys.map((key) => targetBaseSlug ? recordIdsByKey.get(recordIdentity(targetBaseSlug, key)) : void 0).filter((id) => Boolean(id));
6593
+ if (targetIds.length !== keys.length) throw new Error(`Internal error: required relation ${node.slug}.${slug} was scheduled before its targets existed.`);
6594
+ result[slug] = targetIds;
6595
+ continue;
6596
+ }
6597
+ if (PACKAGE_COMPUTED_FIELD_TYPES.includes(field.type)) continue;
6598
+ if (field.type === "attachment") continue;
6466
6599
  result[slug] = value;
6467
6600
  }
6468
6601
  return result;
6469
6602
  };
6470
- const createRecords = async (client, base, plan, options, state, recordIdsByKey, app) => {
6603
+ const createRecords = async (client, base, recordsToCreate, plan, options, state, recordIdsByKey, app) => {
6471
6604
  const mergeRecords = options.autoMerge || (app?.mergeSampleRecords ?? false);
6472
- const records = [...base.node.records].sort((a, b) => a.key.localeCompare(b.key, "en"));
6605
+ const records = [...recordsToCreate].sort((a, b) => a.key.localeCompare(b.key, "en"));
6473
6606
  for (let offset = 0; offset < records.length; offset += 200) {
6474
6607
  const batch = records.slice(offset, offset + 200);
6475
6608
  const changeRequest = await client.bases.createBulkChangeRequest({
6476
6609
  baseId: base.baseId,
6477
- records: batch.map((record) => toCreatableFields(base.node, record.fields)),
6610
+ records: batch.map((record) => toCreatableFields(base.node, record.fields, recordIdsByKey)),
6478
6611
  message: `Install ${batch.length} record(s) into ${base.node.slug}`,
6479
6612
  submittedBy: options.submittedBy,
6480
6613
  idempotencyKey: batchIdempotencyKey(plan.tree.manifest.name, batch[0].key),
@@ -6487,7 +6620,7 @@ const createRecords = async (client, base, plan, options, state, recordIdsByKey,
6487
6620
  const created = (await approveAndMerge(client, changeRequest.id)).changeRequest.operations.filter((operation) => operation.operation === "record_create").sort((a, b) => a.position - b.position);
6488
6621
  for (const [index, record] of batch.entries()) {
6489
6622
  const recordId = created[index]?.mergedRecordId;
6490
- if (recordId) recordIdsByKey.set(record.key, recordId);
6623
+ if (recordId) recordIdsByKey.set(recordIdentity(base.node.slug, record.key), recordId);
6491
6624
  }
6492
6625
  state.created.records += batch.length;
6493
6626
  }
@@ -6502,30 +6635,31 @@ const createRecords = async (client, base, plan, options, state, recordIdsByKey,
6502
6635
  * so the same package always produces the same batches and therefore the same keys.
6503
6636
  */
6504
6637
  const batchIdempotencyKey = (packageName, firstRecordKey) => `pkg:${packageName}:${firstRecordKey}`;
6505
- const linkRelations = async (client, base, relationFieldSlugs, recordIdsByKey, options, state) => {
6638
+ const linkRelations = async (client, base, relationFields, recordIdsByKey, options, state) => {
6506
6639
  for (const record of base.node.records) {
6507
6640
  const relationValues = {};
6508
- let hasRelation = false;
6509
- for (const slug of relationFieldSlugs) {
6510
- const value = record.fields[slug];
6511
- const keys = toKeyArray(value);
6641
+ let needsUpdate = false;
6642
+ for (const field of relationFields) {
6643
+ const value = record.fields[field.slug];
6644
+ const keys = toRecordKeyArray(value);
6512
6645
  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.`);
6646
+ const targetBaseSlug = field.options.targetBaseSlug;
6647
+ const targetIds = keys.map((key) => targetBaseSlug ? recordIdsByKey.get(recordIdentity(targetBaseSlug, key)) : void 0).filter((id) => Boolean(id));
6648
+ const unresolved = keys.filter((key) => !targetBaseSlug || !recordIdsByKey.has(recordIdentity(targetBaseSlug, key)));
6649
+ 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
6650
  if (targetIds.length > 0) {
6517
- relationValues[slug] = targetIds;
6518
- hasRelation = true;
6651
+ relationValues[field.slug] = targetIds;
6652
+ if (!field.required) needsUpdate = true;
6519
6653
  }
6520
6654
  }
6521
- if (!hasRelation) continue;
6522
- const recordId = recordIdsByKey.get(record.key);
6655
+ if (!needsUpdate) continue;
6656
+ const recordId = recordIdsByKey.get(recordIdentity(base.node.slug, record.key));
6523
6657
  if (!recordId) continue;
6524
6658
  const changeRequest = await client.records.changeRequest({
6525
6659
  operation: "update",
6526
6660
  recordId,
6527
6661
  fields: {
6528
- ...toCreatableFields(base.node, record.fields),
6662
+ ...toCreatableFields(base.node, record.fields, recordIdsByKey),
6529
6663
  ...relationValues
6530
6664
  },
6531
6665
  message: `Link relations for ${base.node.slug}`,
@@ -6536,11 +6670,6 @@ const linkRelations = async (client, base, relationFieldSlugs, recordIdsByKey, o
6536
6670
  await approveAndMerge(client, changeRequest.id);
6537
6671
  }
6538
6672
  };
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
6673
  /** Merging requires an `approved` status, so every auto-merge is review-then-merge. */
6545
6674
  const approveAndMerge = async (client, changeRequestId) => {
6546
6675
  const reviewResult = (await client.changeRequests.review({
@@ -7681,6 +7810,7 @@ const buildInstallPlan = (tree, target, options = {}) => {
7681
7810
  planned = applyRenames(prefixed, collisions, target.existingNodeSlugsByType);
7682
7811
  for (const [installedSlug, originalSlug] of collectSlugRewrites(tree, planned)) resourceKeysBySlug[installedSlug] = originalSlug;
7683
7812
  }
7813
+ buildRecordCreateLayers(planned.nodes);
7684
7814
  collectWarnings(planned, warnings);
7685
7815
  const counts = countTree(planned);
7686
7816
  if (counts.files > 5e3) throw new Error(`Package has ${counts.files} files, above the ${PACKAGE_MAX_FILE_COUNT}-file limit. Nothing was installed.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "busa-cli",
3
- "version": "0.19.1",
3
+ "version": "0.19.2",
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.19.2",
37
37
  "commander": "^14.0.0",
38
38
  "tar-stream": "^3.1.7",
39
39
  "yaml": "^2.9.0",