@servicenow/sdk-build-plugins 4.3.0 → 4.4.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.
Files changed (64) hide show
  1. package/dist/acl-plugin.js +2 -0
  2. package/dist/acl-plugin.js.map +1 -1
  3. package/dist/column/column-to-record.d.ts +10 -3
  4. package/dist/column/column-to-record.js +44 -7
  5. package/dist/column/column-to-record.js.map +1 -1
  6. package/dist/column-plugin.d.ts +3 -1
  7. package/dist/column-plugin.js +11 -11
  8. package/dist/column-plugin.js.map +1 -1
  9. package/dist/flow/plugins/flow-instance-plugin.js +285 -10
  10. package/dist/flow/plugins/flow-instance-plugin.js.map +1 -1
  11. package/dist/flow/plugins/flow-trigger-instance-plugin.js +21 -7
  12. package/dist/flow/plugins/flow-trigger-instance-plugin.js.map +1 -1
  13. package/dist/flow/utils/flow-constants.d.ts +7 -0
  14. package/dist/flow/utils/flow-constants.js +12 -5
  15. package/dist/flow/utils/flow-constants.js.map +1 -1
  16. package/dist/flow/utils/service-catalog.d.ts +47 -0
  17. package/dist/flow/utils/service-catalog.js +137 -0
  18. package/dist/flow/utils/service-catalog.js.map +1 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/now-attach-plugin.js +7 -10
  21. package/dist/now-attach-plugin.js.map +1 -1
  22. package/dist/now-ref-plugin.js +1 -1
  23. package/dist/now-ref-plugin.js.map +1 -1
  24. package/dist/server-module-plugin/index.d.ts +10 -0
  25. package/dist/server-module-plugin/index.js +45 -55
  26. package/dist/server-module-plugin/index.js.map +1 -1
  27. package/dist/service-catalog/sc-record-producer-plugin.js +1 -0
  28. package/dist/service-catalog/sc-record-producer-plugin.js.map +1 -1
  29. package/dist/service-catalog/service-catalog-base.js +2 -2
  30. package/dist/service-catalog/service-catalog-base.js.map +1 -1
  31. package/dist/service-catalog/service-catalog-diagnostics.js +4 -1
  32. package/dist/service-catalog/service-catalog-diagnostics.js.map +1 -1
  33. package/dist/service-catalog/shape-to-record.d.ts +1 -0
  34. package/dist/service-catalog/shape-to-record.js +4 -1
  35. package/dist/service-catalog/shape-to-record.js.map +1 -1
  36. package/dist/service-catalog/utils.d.ts +10 -0
  37. package/dist/service-catalog/utils.js +72 -0
  38. package/dist/service-catalog/utils.js.map +1 -1
  39. package/dist/static-content-plugin.js +25 -2
  40. package/dist/static-content-plugin.js.map +1 -1
  41. package/dist/table-plugin.js +16 -13
  42. package/dist/table-plugin.js.map +1 -1
  43. package/dist/ui-page-plugin.js +832 -19
  44. package/dist/ui-page-plugin.js.map +1 -1
  45. package/package.json +5 -5
  46. package/src/acl-plugin.ts +2 -0
  47. package/src/column/column-to-record.ts +54 -8
  48. package/src/column-plugin.ts +28 -12
  49. package/src/flow/plugins/flow-instance-plugin.ts +364 -13
  50. package/src/flow/plugins/flow-trigger-instance-plugin.ts +25 -7
  51. package/src/flow/utils/flow-constants.ts +13 -4
  52. package/src/flow/utils/service-catalog.ts +174 -0
  53. package/src/index.ts +0 -1
  54. package/src/now-attach-plugin.ts +10 -11
  55. package/src/now-ref-plugin.ts +1 -1
  56. package/src/server-module-plugin/index.ts +59 -69
  57. package/src/service-catalog/sc-record-producer-plugin.ts +1 -1
  58. package/src/service-catalog/service-catalog-base.ts +2 -2
  59. package/src/service-catalog/service-catalog-diagnostics.ts +4 -1
  60. package/src/service-catalog/shape-to-record.ts +6 -2
  61. package/src/service-catalog/utils.ts +93 -0
  62. package/src/static-content-plugin.ts +25 -2
  63. package/src/table-plugin.ts +30 -14
  64. package/src/ui-page-plugin.ts +1063 -20
@@ -2,9 +2,11 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.UiPagePlugin = void 0;
4
4
  const sdk_build_core_1 = require("@servicenow/sdk-build-core");
5
- const sdk_build_core_2 = require("@servicenow/sdk-build-core");
6
5
  const fast_xml_parser_1 = require("fast-xml-parser");
6
+ const xmlbuilder2_1 = require("xmlbuilder2");
7
7
  const now_id_plugin_1 = require("./now-id-plugin");
8
+ const static_content_plugin_1 = require("./static-content-plugin");
9
+ const now_attach_plugin_1 = require("./now-attach-plugin");
8
10
  const parserOptions = {
9
11
  ignoreAttributes: false,
10
12
  alwaysCreateTextNode: true,
@@ -31,6 +33,49 @@ const builderOptions = {
31
33
  ],
32
34
  };
33
35
  const POLARIS_APPSHELL_THEME_ID = 'c86a62e2c7022010099a308dc7c26022';
36
+ const BYOUI_ARTIFACT_NAME_SUFFIX = 'BYOUI Files';
37
+ // Matches the prefix HtmlImportPlugin prepends when it resolves an `import x from '*.html'`
38
+ // identifier. Its presence on the html value means the developer used the import variable,
39
+ // not an inline string — the only case where a source artifact should be created.
40
+ const HTML_IMPORT_PREFIX = '<!-- @fluent-import-html';
41
+ // sys_update_xml.payload has a max length of 4,096,000 characters. The artifact content goes
42
+ // through two rounds of base64 encoding with gzip compression in between:
43
+ // raw → base64 → gzip → base64 (stored in sys_attachment_doc.data)
44
+ // This means payload_chars ≈ raw_bytes × (4/3) × C × (4/3) = raw_bytes × 16C/9, where C is the
45
+ // gzip compression ratio. For typical TS/JS source files (C ≈ 0.25), 4 MB of raw source produces
46
+ // ~1.8 MB of payload data — safely within the 4,096,000-character limit.
47
+ const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB — per-file guard (half of total budget)
48
+ const MAX_TOTAL_SIZE = 4 * 1024 * 1024; // 4 MB — derived from sys_update_xml.payload limit
49
+ /**
50
+ * Relationship configuration for source artifact tables.
51
+ *
52
+ * Defines the hierarchy: M2M → Artifact → Attachment → Attachment Docs
53
+ */
54
+ const SOURCE_ARTIFACT_RELATIONSHIPS = {
55
+ sn_glider_source_artifact_m2m: {
56
+ via: 'application_file',
57
+ descendant: true,
58
+ relationships: {
59
+ sn_glider_source_artifact: {
60
+ via: 'source_artifact',
61
+ inverse: true,
62
+ descendant: true,
63
+ relationships: {
64
+ sys_attachment: {
65
+ via: 'table_sys_id',
66
+ descendant: true,
67
+ relationships: {
68
+ sys_attachment_doc: {
69
+ via: 'sys_attachment',
70
+ descendant: true,
71
+ },
72
+ },
73
+ },
74
+ },
75
+ },
76
+ },
77
+ },
78
+ };
34
79
  const parser = new fast_xml_parser_1.XMLParser(parserOptions);
35
80
  // TODO: Remove this shim tag once we've shipped Glide support for this feature.
36
81
  const nowUxGlobals = (themeId = POLARIS_APPSHELL_THEME_ID) => {
@@ -72,6 +117,9 @@ const nodeTransformer = (nodes) => {
72
117
  for (let i = 0; i < nodes.length; i++) {
73
118
  const node = nodes[i];
74
119
  const tag = Object.keys(node)[0];
120
+ if (!tag) {
121
+ continue;
122
+ }
75
123
  if (tag === 'sdk:now-ux-globals') {
76
124
  const themeId = node[':@']?.['@_theme-id'];
77
125
  nodes.splice(i, 1, ...nowUxGlobals(themeId));
@@ -88,7 +136,23 @@ exports.UiPagePlugin = sdk_build_core_1.Plugin.create({
88
136
  name: 'UiPagePlugin',
89
137
  records: {
90
138
  sys_ui_page: {
91
- toShape(record) {
139
+ composite: true,
140
+ coalesce: ['endpoint'],
141
+ relationships: SOURCE_ARTIFACT_RELATIONSHIPS,
142
+ async toShape(record, { descendants, fs, project, config, logger, diagnostics }) {
143
+ const shapeWithSourceArtifacts = await getShapeWithSourceArtifacts(record, descendants, {
144
+ fs,
145
+ project,
146
+ config,
147
+ logger,
148
+ diagnostics,
149
+ });
150
+ if (shapeWithSourceArtifacts) {
151
+ return {
152
+ success: true,
153
+ value: shapeWithSourceArtifacts,
154
+ };
155
+ }
92
156
  return {
93
157
  success: true,
94
158
  value: new sdk_build_core_1.CallExpressionShape({
@@ -109,25 +173,132 @@ exports.UiPagePlugin = sdk_build_core_1.Plugin.create({
109
173
  }),
110
174
  };
111
175
  },
176
+ async toFile(uiPage, { config, descendants, transform }) {
177
+ if (!uiPage.has('endpoint')) {
178
+ return { success: false };
179
+ }
180
+ // Only use custom serialization if source artifacts are present
181
+ const sourceArtifacts = descendants.query('sn_glider_source_artifact_m2m');
182
+ if (sourceArtifacts.length === 0) {
183
+ // No source artifacts, use default serialization
184
+ return { success: false };
185
+ }
186
+ const uiPagePropsToSerialize = [
187
+ 'name',
188
+ 'endpoint',
189
+ 'description',
190
+ 'direct',
191
+ 'category',
192
+ 'html',
193
+ 'client_script',
194
+ 'processing_script',
195
+ ];
196
+ const result = await serializeWithArtifact(uiPage, uiPagePropsToSerialize, {
197
+ config,
198
+ descendants,
199
+ transform,
200
+ });
201
+ return {
202
+ success: true,
203
+ value: result,
204
+ };
205
+ },
206
+ },
207
+ // These records are embedded as descendants of sys_ui_page XML but are also
208
+ // downloaded as standalone records during `init + transform`. Defining coalesce
209
+ // here ensures their sys_ids are registered in keys.ts during transform, so
210
+ // subsequent builds by any user reuse the same IDs rather than generating fresh UUIDs.
211
+ sn_glider_source_artifact: {
212
+ coalesce: ['name'],
213
+ relationships: SOURCE_ARTIFACT_RELATIONSHIPS.sn_glider_source_artifact_m2m.relationships.sn_glider_source_artifact
214
+ .relationships,
215
+ async toShape(record) {
216
+ return { success: true, value: sdk_build_core_1.Shape.noOp(record) };
217
+ },
218
+ // Custom diff: attachments use hash-based immutable IDs. New content → new attachment ID.
219
+ // Stale cleanup is handled via delete_multiple in serializeWithArtifact, so we must NOT
220
+ // emit DELETE records here — the default diff would conflict with that mechanism.
221
+ async diff(existing, incoming) {
222
+ if (incoming.query().length === 0 || existing.query().length === 0) {
223
+ return {
224
+ success: true,
225
+ value: incoming.query().length === 0 ? new sdk_build_core_1.Database() : new sdk_build_core_1.Database(incoming.query()),
226
+ };
227
+ }
228
+ const changedRecords = [];
229
+ const existingArtifacts = existing.query('sn_glider_source_artifact');
230
+ const incomingArtifacts = incoming.query('sn_glider_source_artifact');
231
+ const incomingAttachments = incoming.query('sys_attachment');
232
+ const incomingAttachmentDocs = incoming.query('sys_attachment_doc');
233
+ for (const incomingArtifact of incomingArtifacts) {
234
+ const existingArtifact = existingArtifacts.find((a) => a.getId().getValue() === incomingArtifact.getId().getValue());
235
+ changedRecords.push(existingArtifact ? existingArtifact.merge(incomingArtifact) : incomingArtifact);
236
+ // Always add new attachments — unique hash-based IDs per content version.
237
+ const artifactAttachments = incomingAttachments.filter((att) => att.get('table_sys_id').toString().getValue() === incomingArtifact.getId().getValue());
238
+ for (const attachment of artifactAttachments) {
239
+ changedRecords.push(attachment);
240
+ changedRecords.push(...incomingAttachmentDocs.filter((doc) => doc.get('sys_attachment').toString().getValue() === attachment.getId().getValue()));
241
+ }
242
+ }
243
+ return { success: true, value: new sdk_build_core_1.Database(changedRecords) };
244
+ },
245
+ },
246
+ sn_glider_source_artifact_m2m: {
247
+ coalesce: ['application_file', 'source_artifact'],
248
+ relationships: SOURCE_ARTIFACT_RELATIONSHIPS.sn_glider_source_artifact_m2m.relationships,
249
+ async toShape(record) {
250
+ return { success: true, value: sdk_build_core_1.Shape.noOp(record) };
251
+ },
252
+ async toFile() {
253
+ // Page M2Ms are already in handledGuids as descendants of sys_ui_page, so
254
+ // this handler only runs for asset M2Ms (application_file = sys_ux_lib_asset
255
+ // sys_id). Those are embedded in each sys_ux_lib_asset XML by
256
+ // static-content-plugin via sourceArtifactRelationships. Return success with
257
+ // no output to mark them as handled and prevent RecordPlugin's catch-all from
258
+ // serializing them as standalone XMLs.
259
+ return { success: true, value: [] };
260
+ },
112
261
  },
113
262
  },
114
263
  shapes: [
115
264
  {
116
265
  shape: sdk_build_core_1.CallExpressionShape,
117
266
  fileTypes: ['fluent'],
118
- async toRecord(callExpression, { config, factory, diagnostics }) {
267
+ async toRecord(callExpression, { config, factory, diagnostics, fs, project, logger }) {
119
268
  if (callExpression.getCallee() !== 'UiPage') {
120
269
  return { success: false };
121
270
  }
122
271
  const arg = callExpression.getArgument(0).asObject();
123
272
  const endpoint = arg.get('endpoint').asString();
124
273
  const scope = config.scope;
125
- if (scope !== 'global' && !(0, sdk_build_core_2.isSNScope)(scope) && !endpoint.getValue().startsWith(`${scope}_`)) {
274
+ if (scope !== 'global' && !(0, sdk_build_core_1.isSNScope)(scope) && !endpoint.getValue().startsWith(`${scope}_`)) {
126
275
  diagnostics.error(endpoint.getOriginalNode(), `endpoint must begin with '${scope}_'`);
127
276
  return { success: false };
128
277
  }
129
278
  const name = endpoint.asString().getValue().replace(`${scope}_`, '').replace(/\.do$/, '');
130
279
  let html = arg.get('html').toString().getValue();
280
+ let sourceFilePaths = [];
281
+ let assetNames = [];
282
+ // HtmlImportPlugin (which runs before toRecord) resolves `html: _html` identifiers
283
+ // to the file content and prepends an HTML_IMPORT_PREFIX warning comment.
284
+ // Source artifacts are only created when that prefix is present, meaning the html
285
+ // argument actually referenced an imported .html file.
286
+ // An inline string (e.g. `html: '<h1>...</h1>'`) never gets the prefix, so an
287
+ // unrelated `.html` import in the same file does NOT trigger artifact creation.
288
+ if (html.trimStart().startsWith(HTML_IMPORT_PREFIX)) {
289
+ const originalNode = callExpression.getOriginalNode();
290
+ const sourceFile = originalNode.getSourceFile();
291
+ // biome-ignore lint/suspicious/noExplicitAny: ts-morph ImportDeclaration type not exported
292
+ const htmlImport = sourceFile.getImportDeclarations().find((imp) => imp.getModuleSpecifierValue().endsWith('.html'));
293
+ if (htmlImport) {
294
+ const relativeHtmlPath = htmlImport.getModuleSpecifierValue();
295
+ const sourceFileDir = sdk_build_core_1.path.dirname(sourceFile.getFilePath());
296
+ const absoluteHtmlPath = sdk_build_core_1.path.resolve(sourceFileDir, relativeHtmlPath);
297
+ const manifest = getUIPageSourceFilePaths(absoluteHtmlPath, fs, logger, config, project.getRootDir());
298
+ sourceFilePaths = manifest.files;
299
+ assetNames = manifest.assetNames;
300
+ }
301
+ }
131
302
  if (html) {
132
303
  try {
133
304
  const nodes = parser.parse(html);
@@ -144,26 +315,668 @@ exports.UiPagePlugin = sdk_build_core_1.Plugin.create({
144
315
  return { success: false };
145
316
  }
146
317
  }
318
+ const record = await factory.createRecord({
319
+ source: callExpression,
320
+ table: 'sys_ui_page',
321
+ explicitId: arg.get('$id'),
322
+ properties: arg.transform(({ $ }) => ({
323
+ name: $.val(name),
324
+ endpoint: $.val(endpoint),
325
+ description: $,
326
+ direct: $.def(false),
327
+ category: $,
328
+ html: $.val(html).toCdata(),
329
+ client_script: $.from('clientScript').toCdata(),
330
+ processing_script: $.from('processingScript').toCdata(),
331
+ })),
332
+ });
333
+ // Build source artifact if source files are present in the manifest
334
+ if (sourceFilePaths.length > 0) {
335
+ logger.debug(`Found ${sourceFilePaths.length} source files in manifest`);
336
+ // Manifest files already contain paths relative to project root
337
+ const files = sourceFilePaths.map((file) => file.replace(/\\/g, '/'));
338
+ const prebuildPath = sdk_build_core_1.path.join(project.getRootDir(), 'now.prebuild.mjs');
339
+ if (sdk_build_core_1.FileSystem.existsSync(fs, prebuildPath)) {
340
+ files.push('now.prebuild.mjs');
341
+ }
342
+ const artifactName = `${endpoint.getValue()} - ${BYOUI_ARTIFACT_NAME_SUFFIX}`;
343
+ const sourceArtifactRecords = await buildArtifact(artifactName, files, record, {
344
+ fs,
345
+ project,
346
+ factory,
347
+ config,
348
+ logger,
349
+ diagnostics,
350
+ }, assetNames);
351
+ if (sourceArtifactRecords.length > 0) {
352
+ record.with(...sourceArtifactRecords);
353
+ }
354
+ else {
355
+ diagnostics.warn(record, 'No source artifact records were created despite source files being present');
356
+ }
357
+ }
147
358
  return {
148
359
  success: true,
149
- value: await factory.createRecord({
150
- source: callExpression,
151
- table: 'sys_ui_page',
152
- explicitId: arg.get('$id'),
153
- properties: arg.transform(({ $ }) => ({
154
- name: $.val(name),
155
- endpoint: $.val(endpoint),
156
- description: $,
157
- direct: $.def(false),
158
- category: $,
159
- html: $.val(html).toCdata(),
160
- client_script: $.from('clientScript').toCdata(),
161
- processing_script: $.from('processingScript').toCdata(),
162
- })),
163
- }),
360
+ value: record,
164
361
  };
165
362
  },
166
363
  },
167
364
  ],
168
365
  });
366
+ /**
367
+ * Reads source file paths from the UI source manifest file.
368
+ *
369
+ * The manifest is generated by isomorphic-rollup's sourceManifest plugin during build.
370
+ * It's a JSON file with the structure: { html, entry, files: string[] }
371
+ *
372
+ * @param htmlFilePath - Path to the HTML file
373
+ * @param fs - File system interface
374
+ * @param logger - Logger for diagnostics
375
+ * @param config - NowConfig with staticContentDir
376
+ * @param rootDir - Project root directory
377
+ * @returns Array of source file paths (empty array if manifest not found)
378
+ */
379
+ const getUIPageSourceFilePaths = (htmlFilePath, fs, logger, config, rootDir) => {
380
+ const empty = { files: [], assetNames: [] };
381
+ try {
382
+ // Derive manifest path from HTML path
383
+ // The manifest is in the build output directory (staticContentDir), not the source directory
384
+ // e.g., src/client/index.html -> dist/static/index.ui-source-manifest.json
385
+ const htmlBasename = sdk_build_core_1.path.basename(htmlFilePath, '.html');
386
+ const staticContentAbsDir = sdk_build_core_1.path.join(rootDir, config.staticContentDir);
387
+ const manifestPath = sdk_build_core_1.path.join(staticContentAbsDir, `${htmlBasename}.ui-source-manifest.json`);
388
+ // Check if manifest file exists
389
+ try {
390
+ fs.accessSync(manifestPath);
391
+ }
392
+ catch {
393
+ logger.debug(`No source manifest found at ${manifestPath}`);
394
+ return empty;
395
+ }
396
+ const manifestContent = fs.readFileSync(manifestPath, { encoding: 'utf-8' });
397
+ const manifest = JSON.parse(manifestContent);
398
+ if (!manifest.files || !Array.isArray(manifest.files)) {
399
+ logger.warn(`Invalid manifest format at ${manifestPath}`);
400
+ return empty;
401
+ }
402
+ // Derive the JS asset name from the manifest's entry field, matching
403
+ // static-content-plugin's formula: path.join(scope, relativePath_without_ext).
404
+ // The manifest file is named after the HTML entry (e.g. index.ui-source-manifest.json)
405
+ // but the JS bundle is named after the JS entry (e.g. main.tsx -> main.jsdbx).
406
+ // Using manifest.entry's basename ensures the names align.
407
+ if (!manifest.entry || typeof manifest.entry !== 'string') {
408
+ logger.warn(`No entry field in manifest at ${manifestPath}`);
409
+ return empty;
410
+ }
411
+ const entryBasename = sdk_build_core_1.path.basename(manifest.entry, sdk_build_core_1.path.extname(manifest.entry));
412
+ const entryAssetName = sdk_build_core_1.path.join(config.scope, entryBasename).replace(/\\/g, '/');
413
+ // Check if a source map bundle also exists in staticContentDir.
414
+ // static-content-plugin names source map assets as: path.join(scope, relativePath.replace('dbx', ''))
415
+ // e.g. main.jsdbx.map -> main.js.map -> scope/main.js.map
416
+ const assetNames = [entryAssetName];
417
+ const sourceMapFilePath = sdk_build_core_1.path.join(staticContentAbsDir, `${entryBasename}.jsdbx.map`);
418
+ try {
419
+ fs.accessSync(sourceMapFilePath);
420
+ const sourceMapAssetName = sdk_build_core_1.path.join(config.scope, `${entryBasename}.js.map`).replace(/\\/g, '/');
421
+ assetNames.push(sourceMapAssetName);
422
+ }
423
+ catch {
424
+ // no source map in this build output — skip
425
+ }
426
+ return { files: manifest.files, assetNames };
427
+ }
428
+ catch (error) {
429
+ logger.warn(`Failed to read source manifest: ${error}`);
430
+ return empty;
431
+ }
432
+ };
433
+ /**
434
+ * Extracts source files from artifacts and generates a SourceFileShape for the UI page.
435
+ *
436
+ * This function is called during transform to:
437
+ * 1. Find the source artifact associated with the UI page
438
+ * 2. Extract source files to the project directory
439
+ * 3. Generate a .now.ts file that imports the extracted HTML
440
+ *
441
+ * @param uiPageRecord - The UI page record being transformed
442
+ * @param descendants - Database containing descendant records (artifacts, attachments)
443
+ * @param context - Transform context with config, file system, project, diagnostics, and logger
444
+ * @returns SourceFileShape for the UI page, or undefined if no source artifact found
445
+ */
446
+ async function getShapeWithSourceArtifacts(uiPageRecord, descendants, context) {
447
+ const sourceArtifact = getSourceArtifact(descendants, { name: new RegExp(`${BYOUI_ARTIFACT_NAME_SUFFIX}$`) });
448
+ if (!sourceArtifact) {
449
+ return undefined;
450
+ }
451
+ const artifactName = sourceArtifact.get('name').toString().getValue();
452
+ context.logger.debug(`Found source artifact ${artifactName}`);
453
+ const unpackedFiles = await extractArtifact(sourceArtifact, descendants, '', {
454
+ fs: context.fs,
455
+ project: context.project,
456
+ logger: context.logger,
457
+ diagnostics: context.diagnostics,
458
+ });
459
+ context.logger.debug(`Unpacked ${unpackedFiles.length} files from artifact ${artifactName}`);
460
+ const entryHtmlFilePath = unpackedFiles.find((file) => file.endsWith('.html'));
461
+ if (!entryHtmlFilePath) {
462
+ context.logger.debug(`Failed to find entry HTML file in artifact ${artifactName}`);
463
+ return undefined;
464
+ }
465
+ const { generatedDir, taxonomy } = context.config;
466
+ const tableName = uiPageRecord.getTable();
467
+ const fluentFileDirByTaxonomy = tableName && taxonomy.mapping[tableName] ? sdk_build_core_1.path.join(generatedDir, taxonomy.mapping[tableName]) : generatedDir;
468
+ const originalFilePath = uiPageRecord.getOriginalFilePath();
469
+ let dirWhereTheFluentFileWillExistEventually = sdk_build_core_1.path.dirname(originalFilePath);
470
+ if (context.project.isInMetadataDir(originalFilePath) || !context.project.isInRootDir(originalFilePath)) {
471
+ // context.project.isInMetadataDir(originalFilePath) => init --from downloads XML to metadata dir
472
+ // !context.project.isInRootDir(originalFilePath) => simple transform downloads XML is in a temp metadata dir
473
+ dirWhereTheFluentFileWillExistEventually = fluentFileDirByTaxonomy;
474
+ }
475
+ // need an import statement for the html file; normalize to forward slashes for cross-platform imports
476
+ const htmlFileRelativePath = sdk_build_core_1.path
477
+ .relative(dirWhereTheFluentFileWillExistEventually, entryHtmlFilePath)
478
+ .replace(/\\/g, '/');
479
+ if (htmlFileRelativePath && !context.project.isInFluentDir(originalFilePath)) {
480
+ const endpoint = uiPageRecord.get('endpoint').toString().getValue();
481
+ const description = uiPageRecord.get('description').toString().getValue();
482
+ const category = uiPageRecord.get('category').toString().getValue();
483
+ const direct = uiPageRecord.get('direct').toString().getValue();
484
+ const clientScript = uiPageRecord.get('client_script').toString().getValue();
485
+ const processingScript = uiPageRecord.get('processing_script').toString().getValue();
486
+ const fileContent = `
487
+ import '@servicenow/sdk/global'
488
+ import { UiPage } from '@servicenow/sdk/core'
489
+ import htmlFile from '${htmlFileRelativePath}'
490
+
491
+ UiPage({
492
+ $id: Now.ID['${uiPageRecord.getId().getValue()}'],
493
+ endpoint: ${JSON.stringify(endpoint)},
494
+ description: ${JSON.stringify(description)},
495
+ category: ${JSON.stringify(category)},
496
+ direct: ${direct},
497
+ html: htmlFile,
498
+ clientScript: ${JSON.stringify(clientScript)},
499
+ processingScript: ${JSON.stringify(processingScript)},
500
+ })
501
+ `;
502
+ const newFileName = endpoint.replace('.do', '.now.ts');
503
+ const sourceFileShape = new sdk_build_core_1.SourceFileShape({
504
+ source: uiPageRecord,
505
+ path: `${sdk_build_core_1.path.join(fluentFileDirByTaxonomy, newFileName)}`,
506
+ content: fileContent,
507
+ });
508
+ return sourceFileShape;
509
+ }
510
+ return undefined;
511
+ }
512
+ // ─── Source Artifact: Build ───────────────────────────────────────────────────
513
+ //
514
+ // Asset M2M coordination between UiPagePlugin and StaticContentPlugin:
515
+ //
516
+ // UiPagePlugin creates two kinds of M2M records:
517
+ // - Page M2M (application_file = sys_ui_page sys_id): embedded in sys_ui_page XML.
518
+ // - Asset M2Ms (application_file = sys_ux_lib_asset sys_id): embedded in
519
+ // sys_ux_lib_asset XML by StaticContentPlugin (transform direction), or claimed
520
+ // with no output by sn_glider_source_artifact_m2m.toFile (build direction) to
521
+ // prevent RecordPlugin from serializing them as standalone XMLs.
522
+ //
523
+ // A dummy sys_ux_lib_asset is created (but not added to the build database) solely
524
+ // to derive the same stable sys_id that StaticContentPlugin would use, ensuring both
525
+ // plugins always agree on the asset's sys_id.
526
+ /**
527
+ * Builds a source artifact record from the given files and associates it with the provided metadata record.
528
+ *
529
+ * Reads files from disk, encodes them as base64, compresses with gzip, creates
530
+ * artifact/attachment/attachment_doc records with hash-based IDs, and links to metadata via M2M.
531
+ *
532
+ * @param artifactName - Descriptive name for the artifact (e.g., "home_page.do - BYOUI Files")
533
+ * @param files - Array of file paths relative to project root
534
+ * @param record - Parent metadata record to link to (the artifact will be associated via M2M)
535
+ * @param context - Build context (fs, project, factory, config, logger, diagnostics)
536
+ * @returns Array of records: artifact, attachment, attachment docs, M2M link record, and
537
+ * per-asset M2M records (one per assetName). Returns empty array if no files
538
+ * provided or all files were skipped.
539
+ */
540
+ async function buildArtifact(artifactName, files, record, context, assetNames) {
541
+ if (files.length === 0) {
542
+ return [];
543
+ }
544
+ const { files: attachmentFiles, skippedFiles, totalSize, } = await getAttachmentContent(context.fs, context.project.getRootDir(), files);
545
+ // Report skipped files as warnings before the empty-content check so the
546
+ // caller knows *why* no records were created (e.g. all files exceeded size limits).
547
+ for (const warning of skippedFiles) {
548
+ context.diagnostics.warn(record, warning);
549
+ }
550
+ if (!attachmentFiles.size) {
551
+ context.logger.warn(`No source artifact records created for "${artifactName}": all ${files.length} source file(s) were skipped`);
552
+ return [];
553
+ }
554
+ const artifactRecord = await createArtifactRecord(artifactName, record, context);
555
+ const attachmentRecords = await createAttachmentRecords(context.factory, artifactRecord, attachmentFiles, totalSize);
556
+ const m2mRecord = await createArtifactM2mRecord(artifactRecord, record, context);
557
+ const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(2);
558
+ context.logger.info(`Built source artifact "${artifactName}" (${totalSizeMB} MB, ${files.length} files)`);
559
+ const assetM2mRecords = await Promise.all((assetNames ?? []).map((assetName) => createAssetArtifactM2mRecord(artifactRecord, assetName, record, context)));
560
+ return [artifactRecord, ...attachmentRecords, m2mRecord, ...assetM2mRecords];
561
+ }
562
+ const createArtifactRecord = async (artifactName, metadataRecord, context) => {
563
+ return context.factory.createRecord({
564
+ source: metadataRecord.getSource(),
565
+ table: 'sn_glider_source_artifact',
566
+ explicitId: artifactName,
567
+ properties: {
568
+ name: artifactName,
569
+ },
570
+ });
571
+ };
572
+ const createArtifactM2mRecord = async (artifactRecord, metadataRecord, context) => {
573
+ return context.factory.createRecord({
574
+ source: metadataRecord.getSource(),
575
+ table: 'sn_glider_source_artifact_m2m',
576
+ explicitId: `${metadataRecord.getId().getValue()}-${artifactRecord.getId().getValue()}`,
577
+ properties: {
578
+ application_file: metadataRecord.getId().getValue(),
579
+ source_artifact: artifactRecord.getId().getValue(),
580
+ },
581
+ });
582
+ };
583
+ /**
584
+ * Creates a sn_glider_source_artifact_m2m record linking the given asset to the source artifact.
585
+ *
586
+ * A dummy sys_ux_lib_asset record is created with the same explicitId as static-content-plugin's
587
+ * real asset so both plugins resolve to the same sys_id. Only the M2M record is returned and
588
+ * added to the page's .with() tree so it enters the build database. static-content-plugin picks
589
+ * up the M2M via sourceArtifactRelationships (application_file = assetSysId) and embeds it in
590
+ * the sys_ux_lib_asset XML. UiPagePlugin's sn_glider_source_artifact_m2m.toFile marks the M2M as
591
+ * handled so RecordPlugin does not serialize it as a standalone XML in fluentFile.getOutput().
592
+ */
593
+ const createAssetArtifactM2mRecord = async (artifactRecord, entryAssetName, metadataRecord, context) => {
594
+ const dummyAsset = await context.factory.createRecord({
595
+ source: metadataRecord.getSource(),
596
+ table: 'sys_ux_lib_asset',
597
+ explicitId: entryAssetName,
598
+ properties: {
599
+ name: entryAssetName,
600
+ },
601
+ });
602
+ return context.factory.createRecord({
603
+ source: metadataRecord.getSource(),
604
+ table: 'sn_glider_source_artifact_m2m',
605
+ explicitId: `${dummyAsset.getId().getValue()}-${artifactRecord.getId().getValue()}`,
606
+ properties: {
607
+ application_file: dummyAsset.getId().getValue(),
608
+ source_artifact: artifactRecord.getId().getValue(),
609
+ },
610
+ });
611
+ };
612
+ /**
613
+ * Creates attachment and attachment_doc records for the artifact.
614
+ *
615
+ * Attachments are compressed using zip and chunked for storage. The attachment ID includes
616
+ * a hash prefix to ensure immutability (each content version gets a unique ID).
617
+ */
618
+ const createAttachmentRecords = async (factory, artifactRecord, files, totalSize) => {
619
+ // Build zip entries with a fixed mtime for deterministic output.
620
+ // ZIP format only supports dates 1980-2099; we use the minimum valid date.
621
+ // new Date(1982, 0, 1) creates midnight January 1, 1982 in local time,
622
+ // which fflate's local-time date encoding renders identically in any timezone.
623
+ const FIXED_MTIME = new Date(1982, 0, 1);
624
+ const zipEntries = {};
625
+ for (const [filePath, content] of files) {
626
+ zipEntries[filePath] = [content, { mtime: FIXED_MTIME }];
627
+ }
628
+ const zipped = (0, sdk_build_core_1.zipSync)(zipEntries);
629
+ const compressedData = Buffer.from(zipped);
630
+ const hash = await (0, now_attach_plugin_1.sha256)(compressedData);
631
+ // Uniform chunking — no special header split
632
+ const allChunks = (0, static_content_plugin_1.chunkData)(compressedData.toString('base64'));
633
+ // Deterministic sys_id derived from artifact + content hash — no keys registry entry needed.
634
+ // Same content always produces the same attachment ID without inflating keys.json.
635
+ const attachmentSysId = (0, static_content_plugin_1.generateId)(artifactRecord.getId().getValue(), 'sys_attachment', hash);
636
+ const attachment = await factory.createRecord({
637
+ source: artifactRecord.getSource(),
638
+ table: 'sys_attachment',
639
+ properties: {
640
+ sys_id: attachmentSysId,
641
+ average_image_color: '',
642
+ chunk_size_bytes: static_content_plugin_1.CHUNK_SIZE,
643
+ compressed: true,
644
+ content_type: 'application/zip',
645
+ hash,
646
+ image_height: '',
647
+ image_width: '',
648
+ size_bytes: totalSize,
649
+ size_compressed: compressedData.length,
650
+ file_name: `${artifactRecord.get('name').getValue()}.zip`,
651
+ table_name: artifactRecord.getTable(),
652
+ table_sys_id: artifactRecord.getId().getValue(),
653
+ },
654
+ });
655
+ const attachmentDocs = [];
656
+ for (let i = 0; i < allChunks.length; i++) {
657
+ const doc = await factory.createRecord({
658
+ source: artifactRecord.getSource(),
659
+ table: 'sys_attachment_doc',
660
+ properties: {
661
+ sys_id: (0, static_content_plugin_1.generateId)(attachmentSysId, 'sys_attachment_doc', i),
662
+ data: allChunks[i],
663
+ position: i,
664
+ sys_attachment: attachment.getId().getValue(),
665
+ },
666
+ });
667
+ attachmentDocs.push(doc);
668
+ }
669
+ return [attachment, ...attachmentDocs];
670
+ };
671
+ /**
672
+ * Reads files from disk and collects them for attachment storage.
673
+ *
674
+ * Error handling uses two distinct strategies:
675
+ * - **Recoverable** (per-file): files that cannot be read or exceed the per-file size limit are
676
+ * skipped and their paths are collected in the returned `skippedFiles` array.
677
+ * - **Fatal** (aggregate): if adding a file would push the total over `MAX_TOTAL_SIZE`, an error
678
+ * is thrown immediately.
679
+ *
680
+ * @returns Map of file paths to raw buffers, total size in bytes, and array of warning messages for skipped files
681
+ * @throws {Error} if the cumulative size of valid files would exceed `MAX_TOTAL_SIZE`
682
+ */
683
+ const getAttachmentContent = async (fs, rootDir, files) => {
684
+ const fileMap = new Map();
685
+ let totalSize = 0;
686
+ const skippedFiles = [];
687
+ for (const filePath of files) {
688
+ let content;
689
+ try {
690
+ const absolutePath = sdk_build_core_1.path.join(rootDir, filePath.replace(/^[/\\]/, ''));
691
+ content = fs.readFileSync(absolutePath);
692
+ }
693
+ catch (error) {
694
+ skippedFiles.push(`Failed to read file ${filePath}: ${error}`);
695
+ continue;
696
+ }
697
+ const fileSize = content.length;
698
+ if (fileSize > MAX_FILE_SIZE) {
699
+ const sizeMB = (fileSize / (1024 * 1024)).toFixed(2);
700
+ skippedFiles.push(`Skipping file ${filePath}: exceeds max file size (${sizeMB} MB)`);
701
+ continue;
702
+ }
703
+ if (totalSize + fileSize > MAX_TOTAL_SIZE) {
704
+ const currentMB = (totalSize / (1024 * 1024)).toFixed(2);
705
+ const limitMB = (MAX_TOTAL_SIZE / (1024 * 1024)).toFixed(0);
706
+ throw new Error(`Total artifact size would exceed limit (${currentMB}/${limitMB} MB)`);
707
+ }
708
+ fileMap.set(filePath, content);
709
+ totalSize += fileSize;
710
+ }
711
+ return { files: fileMap, totalSize, skippedFiles };
712
+ };
713
+ // ─── Source Artifact: Extract ─────────────────────────────────────────────────
714
+ /**
715
+ * Extracts and unpacks files from a source artifact record.
716
+ *
717
+ * Finds attachments for the artifact, reconstructs compressed data from chunks,
718
+ * decompresses with unzipSync, and writes files to disk.
719
+ *
720
+ * @param artifactRecord - The source artifact record (type: sn_glider_source_artifact)
721
+ * @param allDescendants - Query interface to access attachment and attachment_doc records
722
+ * @param targetDir - Target directory path relative to project root (use '' for project root)
723
+ * @param context - Context with fs, project, logger, diagnostics
724
+ * @returns Array of file paths that were unpacked (relative to project root)
725
+ */
726
+ async function extractArtifact(artifactRecord, allDescendants, targetDir = '', context) {
727
+ const artifactSysId = artifactRecord.getId().getValue();
728
+ // Find all attachments for this artifact and select the most recent one
729
+ const allAttachments = allDescendants.query('sys_attachment');
730
+ const matchingAttachments = allAttachments
731
+ .filter((att) => att.get('table_sys_id').toString().getValue() === artifactSysId)
732
+ .sort((a, b) => {
733
+ const aTime = a.get('sys_created_on').toString().getValue();
734
+ const bTime = b.get('sys_created_on').toString().getValue();
735
+ return aTime.localeCompare(bTime);
736
+ });
737
+ if (matchingAttachments.length === 0) {
738
+ context.logger.debug(`No attachments found for artifact ${artifactSysId}`);
739
+ return [];
740
+ }
741
+ // Use the most recent attachment (last after sorting by creation time)
742
+ const latestAttachment = matchingAttachments.at(-1);
743
+ if (!latestAttachment) {
744
+ return [];
745
+ }
746
+ // Get attachment docs (chunks) for the latest attachment
747
+ const allAttachmentDocs = allDescendants.query('sys_attachment_doc');
748
+ const attachmentDocs = allAttachmentDocs.filter((doc) => doc.get('sys_attachment').toString().getValue() === latestAttachment.getId().getValue());
749
+ if (attachmentDocs.length === 0) {
750
+ context.logger.debug(`No attachment docs found for attachment ${latestAttachment.getId().getValue()}`);
751
+ return [];
752
+ }
753
+ // Sort docs by position to ensure correct byte order
754
+ const sortedDocs = attachmentDocs.sort((a, b) => Number(a.get('position').toString().getValue()) - Number(b.get('position').toString().getValue()));
755
+ // Reconstruct zip bytes from all chunks uniformly
756
+ const chunks = sortedDocs.map((doc) => Buffer.from(doc.get('data').toString().getValue(), 'base64'));
757
+ const compressedData = Buffer.concat(chunks);
758
+ // Decompress — returns { [filePath]: Uint8Array }
759
+ const entries = (0, sdk_build_core_1.unzipSync)(compressedData);
760
+ const unpackedFiles = [];
761
+ for (const [filePath, data] of Object.entries(entries)) {
762
+ try {
763
+ const fileContent = Buffer.from(data);
764
+ const absolutePath = sdk_build_core_1.path.join(context.project.getRootDir(), targetDir, filePath);
765
+ const dir = sdk_build_core_1.path.dirname(absolutePath);
766
+ context.fs.mkdirSync(dir, { recursive: true });
767
+ context.fs.writeFileSync(absolutePath, fileContent);
768
+ context.project.addFile({ path: absolutePath, content: fileContent.toString('utf-8') });
769
+ unpackedFiles.push(filePath);
770
+ context.logger.debug(`Extracted file: ${filePath}`);
771
+ }
772
+ catch (error) {
773
+ context.diagnostics.error(artifactRecord, `Failed to write file ${filePath}: ${error}`);
774
+ }
775
+ }
776
+ if (unpackedFiles.length > 0) {
777
+ context.logger.info(`Extracted ${unpackedFiles.length} files from artifact "${artifactRecord.get('name').toString().getValue()}"`);
778
+ }
779
+ return unpackedFiles;
780
+ }
781
+ /**
782
+ * Finds a source artifact record by ID or name pattern in descendants.
783
+ *
784
+ * @param descendants - Query interface to access source artifact records
785
+ * @param query - Search criteria: use either id (exact sys_id) OR name (regex against name)
786
+ * @returns The matching source artifact record, or undefined if not found
787
+ */
788
+ function getSourceArtifact(descendants, query) {
789
+ return descendants.query('sn_glider_source_artifact').find((record) => {
790
+ if (query.id) {
791
+ return record.getId().getValue() === query.id;
792
+ }
793
+ if (query.name) {
794
+ const nameMatch = record.get('name').toString().getValue().match(query.name);
795
+ return nameMatch && nameMatch.length > 0;
796
+ }
797
+ return false;
798
+ });
799
+ }
800
+ // ─── Source Artifact: Serialize ───────────────────────────────────────────────
801
+ /**
802
+ * Serializes a metadata record along with its embedded source artifacts to XML.
803
+ *
804
+ * Creates a custom XML format where source artifacts, attachments, M2M records,
805
+ * and the parent metadata record are all embedded within a single XML file.
806
+ *
807
+ * The generated XML contains (in order):
808
+ * 1. M2M records (sn_glider_source_artifact_m2m)
809
+ * 2. Artifact records (sn_glider_source_artifact)
810
+ * 3. Attachment records (sys_attachment)
811
+ * 4. Attachment doc records (sys_attachment_doc)
812
+ * 5. Parent metadata record
813
+ *
814
+ * Also emits delete_multiple elements to clean up stale attachments per artifact,
815
+ * since attachments use hash-based IDs that change with each new content version.
816
+ *
817
+ * @param metadataRecord - The main metadata record (e.g., sys_ui_page)
818
+ * @param metadataProps - List of property names to serialize from the metadata record
819
+ * @param context - Serialization context (descendants, config, transform)
820
+ */
821
+ async function serializeWithArtifact(metadataRecord, metadataProps, context) {
822
+ const recordUpdate = (0, xmlbuilder2_1.create)().ele('record_update', { table: metadataRecord.getTable() });
823
+ // Embed the metadata record itself at the root, before descendant records
824
+ const metadataElement = recordUpdate.ele(metadataRecord.getTable(), { action: metadataRecord.getAction() });
825
+ metadataElement.ele('sys_id').txt(metadataRecord.getId().getValue());
826
+ metadataElement.ele('sys_scope', { display_value: context.config.scope }).txt(context.config.scopeId);
827
+ const updateName = await context.transform.getUpdateName(metadataRecord);
828
+ metadataElement.ele('sys_update_name').txt(updateName);
829
+ for (const prop of metadataProps) {
830
+ const value = metadataRecord.get(prop);
831
+ if (value.isDefined()) {
832
+ const stringValue = value.toString().getValue();
833
+ const contentType = value.toString().getContentType();
834
+ if (stringValue) {
835
+ if (contentType === 'plain') {
836
+ metadataElement.ele(prop).txt(stringValue);
837
+ }
838
+ else {
839
+ // Handle CDATA content
840
+ // The ]]> sequence terminates CDATA, so we escape it as: ]]]]><![CDATA[>
841
+ // This creates: <![CDATA[content before]]]]><![CDATA[>content after]]>
842
+ if (stringValue.includes(']]>')) {
843
+ // Split by ]]> and create multiple adjacent CDATA sections
844
+ const parts = stringValue.split(']]>');
845
+ const propElement = metadataElement.ele(prop);
846
+ // Create adjacent CDATA sections for each part
847
+ // First part
848
+ propElement.dat(`${parts[0]}]]`);
849
+ // Remaining parts (each starts with a new CDATA section containing >)
850
+ for (let i = 1; i < parts.length; i++) {
851
+ propElement.dat(`>${parts[i]}`);
852
+ }
853
+ }
854
+ else {
855
+ // No ]]> sequences, safe to use .dat() directly
856
+ metadataElement.ele(prop).dat(stringValue);
857
+ }
858
+ }
859
+ }
860
+ else {
861
+ metadataElement.ele(prop);
862
+ }
863
+ }
864
+ }
865
+ // Embed the M2M record linking this metadata record to its source artifact.
866
+ // Asset M2Ms (application_file = sys_ux_lib_asset sys_id) are embedded in
867
+ // each sys_ux_lib_asset XML by static-content-plugin.
868
+ context.descendants.query('sn_glider_source_artifact_m2m').forEach((m2m) => {
869
+ const m2mElement = recordUpdate.ele('sn_glider_source_artifact_m2m', {
870
+ action: m2m.getAction(),
871
+ });
872
+ m2mElement.ele('sys_id').txt(m2m.getId().getValue());
873
+ const applicationFile = m2m.get('application_file');
874
+ if (applicationFile.isDefined()) {
875
+ const appFileId = applicationFile.isRecord()
876
+ ? applicationFile.asRecord().getId().getValue()
877
+ : applicationFile.toString().getValue();
878
+ m2mElement.ele('application_file').txt(appFileId);
879
+ }
880
+ const artifactId = m2m.get('source_artifact');
881
+ if (artifactId.isDefined()) {
882
+ const artifactSysId = artifactId.isRecord()
883
+ ? artifactId.asRecord().getId().getValue()
884
+ : artifactId.toString().getValue();
885
+ m2mElement.ele('source_artifact').txt(artifactSysId);
886
+ }
887
+ });
888
+ // Embed artifact records
889
+ context.descendants.query('sn_glider_source_artifact').forEach((artifact) => {
890
+ const artifactElement = recordUpdate.ele('sn_glider_source_artifact', {
891
+ action: artifact.getAction(),
892
+ });
893
+ artifactElement.ele('sys_id').txt(artifact.getId().getValue());
894
+ const artifactName = artifact.get('name');
895
+ if (artifactName.isDefined()) {
896
+ artifactElement.ele('name').txt(artifactName.toString().getValue());
897
+ }
898
+ });
899
+ // Embed attachment records
900
+ context.descendants.query('sys_attachment').forEach((attachment) => {
901
+ const attachmentElement = recordUpdate.ele('sys_attachment', { action: attachment.getAction() });
902
+ attachmentElement.ele('sys_id').txt(attachment.getId().getValue());
903
+ attachmentElement.ele('sys_scope', { display_value: context.config.scope }).txt(context.config.scopeId);
904
+ const tableId = attachment.get('table_sys_id');
905
+ if (tableId.isDefined()) {
906
+ attachmentElement.ele('table_sys_id').txt(tableId.toString().getValue());
907
+ }
908
+ const tableName = attachment.get('table_name');
909
+ if (tableName.isDefined()) {
910
+ attachmentElement.ele('table_name').txt(tableName.toString().getValue());
911
+ }
912
+ const attachmentProps = [
913
+ 'file_name',
914
+ 'content_type',
915
+ 'size_bytes',
916
+ 'size_compressed',
917
+ 'compressed',
918
+ 'chunk_size_bytes',
919
+ 'hash',
920
+ 'average_image_color',
921
+ 'image_width',
922
+ 'image_height',
923
+ ];
924
+ for (const prop of attachmentProps) {
925
+ const value = attachment.get(prop);
926
+ if (value.isDefined()) {
927
+ attachmentElement.ele(prop).txt(value.toString().getValue());
928
+ }
929
+ }
930
+ });
931
+ // Embed attachment doc records (chunks)
932
+ context.descendants.query('sys_attachment_doc').forEach((doc) => {
933
+ const docElement = recordUpdate.ele('sys_attachment_doc', { action: doc.getAction() });
934
+ docElement.ele('sys_id').txt(doc.getId().getValue());
935
+ // Link to the attachment
936
+ const attachmentId = doc.get('sys_attachment');
937
+ if (attachmentId.isDefined()) {
938
+ const sysId = attachmentId.isRecord()
939
+ ? attachmentId.asRecord().getId().getValue()
940
+ : attachmentId.toString().getValue();
941
+ docElement.ele('sys_attachment').txt(sysId);
942
+ }
943
+ const position = doc.get('position');
944
+ if (position.isDefined()) {
945
+ docElement.ele('position').txt(position.toString().getValue());
946
+ }
947
+ const data = doc.get('data');
948
+ if (data.isDefined()) {
949
+ docElement.ele('data').txt(data.toString().getValue());
950
+ }
951
+ });
952
+ // Emit delete_multiple elements to clean up stale attachments and docs per artifact.
953
+ // Since attachments use hash-based IDs, each build produces a new attachment ID.
954
+ // These queries tell the platform to remove any attachment/doc on this artifact
955
+ // that is not part of the current build.
956
+ context.descendants.query('sn_glider_source_artifact').forEach((artifact) => {
957
+ const artifactId = artifact.getId().getValue();
958
+ const currentAttachments = context.descendants
959
+ .query('sys_attachment')
960
+ .filter((att) => att.get('table_sys_id').toString().getValue() === artifactId);
961
+ const currentAttachmentIds = currentAttachments.map((att) => att.getId().getValue());
962
+ const currentDocIds = context.descendants
963
+ .query('sys_attachment_doc')
964
+ .filter((doc) => currentAttachmentIds.includes(doc.get('sys_attachment').toString().getValue()))
965
+ .map((doc) => doc.getId().getValue());
966
+ const attachmentQuery = currentAttachmentIds.length > 0
967
+ ? `table_sys_id=${artifactId}^sys_idNOT IN${currentAttachmentIds.join(',')}`
968
+ : `table_sys_id=${artifactId}`;
969
+ recordUpdate.ele('sys_attachment', { action: 'delete_multiple', query: attachmentQuery });
970
+ const docQuery = currentDocIds.length > 0
971
+ ? `sys_attachment.table_sys_id=${artifactId}^sys_idNOT IN${currentDocIds.join(',')}`
972
+ : `sys_attachment.table_sys_id=${artifactId}`;
973
+ recordUpdate.ele('sys_attachment_doc', { action: 'delete_multiple', query: docQuery });
974
+ });
975
+ return {
976
+ source: metadataRecord,
977
+ name: `${updateName}.xml`,
978
+ category: metadataRecord.getInstallCategory(),
979
+ content: recordUpdate.end({ prettyPrint: true }),
980
+ };
981
+ }
169
982
  //# sourceMappingURL=ui-page-plugin.js.map