@izara_project/izara-core-generate-service-code 1.0.101 → 1.0.103

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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/emit/application/coreFunction/templates/InitialSetupRunner_uploadSchema.ejs +7 -0
  3. package/src/emit/application/coreFunction/templates/UploadSchema_Main.ejs +7 -0
  4. package/src/emit/application/crud/templates/CreateEndpoint_Main.ejs +21 -0
  5. package/src/emit/application/custom/customFlowS3.js +38 -0
  6. package/src/emit/application/custom/customFlows.js +22 -14
  7. package/src/emit/application/custom/templates/s3/ConfirmComplete_Main.ejs +5 -5
  8. package/src/emit/application/custom/templates/s3/ProcessS3File_Main.ejs +112 -6
  9. package/src/emit/application/custom/templates/s3/Start_HdrWbs.ejs +65 -0
  10. package/src/emit/application/custom/templates/s3/Start_Main.ejs +20 -16
  11. package/src/emit/application/custom/templates/s3/{ConfirmAfterUpload_Main.ejs → legacy/ConfirmAfterUpload_Main.ejs} +1 -1
  12. package/src/emit/application/rbac/templates/CancelUsage_Main.ejs +7 -6
  13. package/src/emit/application/rbac/templates/ConfirmUsage_Main.ejs +7 -6
  14. package/src/emit/application/rbac/templates/CreateTargetRole_Main.ejs +2 -2
  15. package/src/emit/application/rbac/templates/DeleteTargetRole_Main.ejs +2 -2
  16. package/src/emit/application/rbac/templates/GetMyPermissions_Main.ejs +2 -2
  17. package/src/emit/application/rbac/templates/GetTargetRole_Main.ejs +2 -2
  18. package/src/emit/application/rbac/templates/ListRbac_Main.ejs +2 -2
  19. package/src/emit/application/rbac/templates/RbacMutation_Main.ejs +2 -2
  20. package/src/emit/application/rbac/templates/ReservedDynamicUsage_Main.ejs +7 -4
  21. package/src/emit/application/rbac/templates/UpdateTargetRole_Main.ejs +2 -2
  22. package/src/emit/application/rbac/templates/UserRoleMutation_Main.ejs +2 -2
  23. package/src/emit/infrastructure/functions/templates/DssFunction_Yaml.ejs +1 -1
  24. package/src/emit/infrastructure/resources/dynamoResourceArtifacts.js +6 -2
  25. package/src/emit/infrastructure/resources/sqsResourceArtifacts.js +2 -1
  26. package/src/emit/infrastructure/resources/templates/FlowOut_Yaml.ejs +2 -0
  27. package/test/unit/application/createTargetRoleHandler.test.js +1 -1
  28. package/test/unit/application/customFlowS3.test.js +156 -2
  29. package/test/unit/infrastructure/resourceArtifacts.test.js +24 -0
  30. package/test/unit/output/mergeHooks.test.js +49 -0
  31. /package/src/emit/application/custom/templates/s3/{ConfirmAfterUpload_HdrSqs.ejs → legacy/ConfirmAfterUpload_HdrSqs.ejs} +0 -0
  32. /package/src/emit/application/custom/templates/s3/{ConfirmReserved_HdrSqs.ejs → legacy/ConfirmReserved_HdrSqs.ejs} +0 -0
  33. /package/src/emit/application/custom/templates/s3/{ConfirmReserved_Main.ejs → legacy/ConfirmReserved_Main.ejs} +0 -0
  34. /package/src/emit/application/custom/templates/s3/{CreateObject_HdrWbs.ejs → legacy/CreateObject_HdrWbs.ejs} +0 -0
  35. /package/src/emit/application/custom/templates/s3/{CreateObject_Main.ejs → legacy/CreateObject_Main.ejs} +0 -0
  36. /package/src/emit/application/custom/templates/s3/{CreatePreSignUrl_HdrSqs.ejs → legacy/CreatePreSignUrl_HdrSqs.ejs} +0 -0
  37. /package/src/emit/application/custom/templates/s3/{CreatePreSignUrl_Main.ejs → legacy/CreatePreSignUrl_Main.ejs} +0 -0
  38. /package/src/emit/application/custom/templates/s3/{ReservedLimit_HdrSqs.ejs → legacy/ReservedLimit_HdrSqs.ejs} +0 -0
  39. /package/src/emit/application/custom/templates/s3/{ReservedLimit_Main.ejs → legacy/ReservedLimit_Main.ejs} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izara_project/izara-core-generate-service-code",
3
- "version": "1.0.101",
3
+ "version": "1.0.103",
4
4
  "description": "Code for locally generating per service files",
5
5
  "homepage": "https://bitbucket.org/izara-core-support-services/izara-core-support-services-generate-service-code#readme",
6
6
  "repository": {
@@ -1,4 +1,11 @@
1
1
  import initialSetupLib from '@izara_project/izara-core-library-initial-setup';
2
+ import { basicValidatorSchema } from '@izara_project/izara-shared-service-schemas';
3
+
4
+ if (basicValidatorSchema?.basicFlowSchema?.properties?.event?.items?.enum) {
5
+ if (!basicValidatorSchema.basicFlowSchema.properties.event.items.enum.includes('dss')) {
6
+ basicValidatorSchema.basicFlowSchema.properties.event.items.enum.push('dss');
7
+ }
8
+ }
2
9
 
3
10
  const SERVICE_TAG = {
4
11
  TRANSLATIONS: 'Translations',
@@ -16,6 +16,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
16
16
  */
17
17
 
18
18
  import initialSetupSharedLib from '@izara_project/izara-core-library-initial-setup';
19
+ import { basicValidatorSchema } from '@izara_project/izara-shared-service-schemas';
20
+
21
+ if (basicValidatorSchema?.basicFlowSchema?.properties?.event?.items?.enum) {
22
+ if (!basicValidatorSchema.basicFlowSchema.properties.event.items.enum.includes('dss')) {
23
+ basicValidatorSchema.basicFlowSchema.properties.event.items.enum.push('dss');
24
+ }
25
+ }
19
26
 
20
27
  const SERVICE_TAG = {
21
28
  TRANSLATIONS: 'Translations',
@@ -153,6 +153,10 @@ export default async function <%- flowTag %>(
153
153
  requestParams.billable = true;
154
154
  }
155
155
 
156
+ //(<rbacUsageValue_<%- flowTag %>>)
157
+ let usageValue = requestParams.value || requestParams.usageValue || 1;
158
+ //(</rbacUsageValue_<%- flowTag %>>)
159
+
156
160
  const resultPermissionCheck = await checkRbacPermission(
157
161
  _izContext,
158
162
  {
@@ -168,6 +172,7 @@ export default async function <%- flowTag %>(
168
172
  serviceTag: <%- rbacServiceTags.length === 1 ? rbacServiceTags[0] : `{ ${Object.entries(rbacServiceTagMap).map(([k, v]) => `'${k}': ${v}`).join(', ')} }[requestParams.objectType] || process.env.iz_serviceTag` %>
169
173
  },
170
174
  requestParams: requestParams,
175
+ value: usageValue,
171
176
  sync: true
172
177
  }
173
178
  );
@@ -187,6 +192,14 @@ export default async function <%- flowTag %>(
187
192
  );
188
193
  }
189
194
 
195
+ if (resultPermissionCheck?.uniqueIdentify) {
196
+ requestParams.reservedUsageId = resultPermissionCheck.uniqueIdentify;
197
+ }
198
+ if (resultPermissionCheck?.runningTotalReservedId) {
199
+ requestParams.runningTotalReservedId =
200
+ resultPermissionCheck.runningTotalReservedId;
201
+ }
202
+
190
203
  //(<rbacPublish_<%- flowTag %>>)
191
204
  //(</rbacPublish_<%- flowTag %>>)
192
205
  }
@@ -275,6 +288,7 @@ export default async function <%- flowTag %>(
275
288
  //(</afterBeforeLogical_<%= objectType %>>)
276
289
 
277
290
  <%_ if (hasOwnTopic) { _%>
291
+ /*
278
292
  // ── ownTopic (async — dispatch to before-hook and await callback) ──────────
279
293
  <%_ configs.filter(c => c?.generatedCodeConfig?.invocationType === 'ownTopic' || c?.event?.includes('ownTopic')).forEach(config => { _%>
280
294
  let hookPayload_<%= config.flowTag %> = {
@@ -341,6 +355,13 @@ export default async function <%- flowTag %>(
341
355
  status: 'pending',
342
356
  message: 'Dispatched to BeforeHook'
343
357
  };
358
+ */
359
+ return await <%- flowTag %>LogicalLib(
360
+ _izContext,
361
+ requestParams,
362
+ callingFlowConfig,
363
+ foundErrors
364
+ );
344
365
  <%_ } else { _%>
345
366
  return await <%- flowTag %>LogicalLib(
346
367
  _izContext,
@@ -261,6 +261,40 @@ export function resolveParentIdentifierInfo(flow, objects = []) {
261
261
  };
262
262
  }
263
263
 
264
+ export function resolveTargetIdentifierInfo(flow, objects = []) {
265
+ const targetObjType =
266
+ typeof flow.objType === 'string'
267
+ ? flow.objType
268
+ : flow.objType?.objectType || flow.objectType;
269
+ const matchedObject = (objects || []).find(
270
+ (o) => o.objectType === targetObjType
271
+ );
272
+
273
+ let identifierKeys = [];
274
+ if (Array.isArray(flow.identifierKeys) && flow.identifierKeys.length > 0) {
275
+ identifierKeys = [...flow.identifierKeys];
276
+ } else if (flow.identifierKey) {
277
+ identifierKeys = [flow.identifierKey];
278
+ } else if (
279
+ Array.isArray(matchedObject?.identifiers) &&
280
+ matchedObject.identifiers.length > 0
281
+ ) {
282
+ identifierKeys = extractIdentifierFieldNames(matchedObject.identifiers);
283
+ }
284
+
285
+ if (identifierKeys.length === 0 && targetObjType) {
286
+ identifierKeys = [`${targetObjType}Id`];
287
+ }
288
+
289
+ let identifierKey =
290
+ identifierKeys.length > 0 ? identifierKeys[0] : `${targetObjType}Id`;
291
+
292
+ return {
293
+ identifierKey,
294
+ identifierKeys
295
+ };
296
+ }
297
+
264
298
  export function resolveFlowRelationships(
265
299
  flow,
266
300
  { objects = [], relationships = [] } = {}
@@ -454,6 +488,8 @@ function _s3TemplateData({
454
488
  }
455
489
  const { parentObjectType, parentServiceTag, parentIdKey, parentIdKeys } =
456
490
  resolveParentIdentifierInfo(flow, objects);
491
+ const { identifierKey, identifierKeys } =
492
+ resolveTargetIdentifierInfo(flow, objects);
457
493
  let s3KeyPrefix = 'uploads/';
458
494
  if (flow.s3KeyPrefix) {
459
495
  s3KeyPrefix = flow.s3KeyPrefix;
@@ -498,6 +534,8 @@ function _s3TemplateData({
498
534
  action,
499
535
  flowTag: flow.flowTag,
500
536
  objectType: flow.objType?.objectType,
537
+ identifierKey,
538
+ identifierKeys,
501
539
  functionName,
502
540
  nextStepFunctionName,
503
541
  isSimplifiedS3,
@@ -350,22 +350,22 @@ export const LEGACY_S3_PARTS = [
350
350
  action: 'CreateObject',
351
351
  handler: 'HdrWbs',
352
352
  type: 'wbs',
353
- mainTemplate: 'CreateObject_Main.ejs',
354
- handlerTemplate: 'CreateObject_HdrWbs.ejs'
353
+ mainTemplate: 'legacy/CreateObject_Main.ejs',
354
+ handlerTemplate: 'legacy/CreateObject_HdrWbs.ejs'
355
355
  },
356
356
  {
357
357
  action: 'ReservedLimit',
358
358
  handler: 'HdrSqs',
359
359
  type: 'sqs',
360
- mainTemplate: 'ReservedLimit_Main.ejs',
361
- handlerTemplate: 'ReservedLimit_HdrSqs.ejs'
360
+ mainTemplate: 'legacy/ReservedLimit_Main.ejs',
361
+ handlerTemplate: 'legacy/ReservedLimit_HdrSqs.ejs'
362
362
  },
363
363
  {
364
364
  action: 'CreatePreSignUrl',
365
365
  handler: 'HdrSqs',
366
366
  type: 'sqs',
367
- mainTemplate: 'CreatePreSignUrl_Main.ejs',
368
- handlerTemplate: 'CreatePreSignUrl_HdrSqs.ejs'
367
+ mainTemplate: 'legacy/CreatePreSignUrl_Main.ejs',
368
+ handlerTemplate: 'legacy/CreatePreSignUrl_HdrSqs.ejs'
369
369
  },
370
370
  {
371
371
  action: 'ProcessAfterUpload',
@@ -378,8 +378,8 @@ export const LEGACY_S3_PARTS = [
378
378
  action: 'ConfirmAfterUpload',
379
379
  handler: 'HdrSqs',
380
380
  type: 'sqs',
381
- mainTemplate: 'ConfirmReserved_Main.ejs',
382
- handlerTemplate: 'ConfirmReserved_HdrSqs.ejs'
381
+ mainTemplate: 'legacy/ConfirmReserved_Main.ejs',
382
+ handlerTemplate: 'legacy/ConfirmReserved_HdrSqs.ejs'
383
383
  }
384
384
  ];
385
385
 
@@ -419,16 +419,18 @@ export function resolveS3Parts(flow) {
419
419
  const eventType = Array.isArray(step.event) ? step.event[0] : 'sqs';
420
420
 
421
421
  if (eventType === 'wbs' || action === 'Start' || action === 'CreateObject') {
422
- let mainTemplate = 'CreateObject_Main.ejs';
422
+ let mainTemplate = 'legacy/CreateObject_Main.ejs';
423
+ let handlerTemplate = 'legacy/CreateObject_HdrWbs.ejs';
423
424
  if (isDeferredNodeFlow) {
424
425
  mainTemplate = 'Start_Main.ejs';
426
+ handlerTemplate = 'Start_HdrWbs.ejs';
425
427
  }
426
428
  return {
427
429
  action,
428
430
  handler: 'HdrWbs',
429
431
  type: 'wbs',
430
432
  mainTemplate,
431
- handlerTemplate: 'CreateObject_HdrWbs.ejs'
433
+ handlerTemplate
432
434
  };
433
435
  }
434
436
 
@@ -453,12 +455,18 @@ export function resolveS3Parts(flow) {
453
455
  }
454
456
 
455
457
  if (action === 'ConfirmAfterUpload') {
458
+ let mainTemplate = 'legacy/ConfirmAfterUpload_Main.ejs';
459
+ let handlerTemplate = 'legacy/ConfirmAfterUpload_HdrSqs.ejs';
460
+ if (isDeferredNodeFlow) {
461
+ mainTemplate = 'ConfirmComplete_Main.ejs';
462
+ handlerTemplate = 'ConfirmComplete_HdrSqs.ejs';
463
+ }
456
464
  return {
457
465
  action,
458
466
  handler: 'HdrSqs',
459
467
  type: 'sqs',
460
- mainTemplate: 'ConfirmAfterUpload_Main.ejs',
461
- handlerTemplate: 'ConfirmAfterUpload_HdrSqs.ejs'
468
+ mainTemplate,
469
+ handlerTemplate
462
470
  };
463
471
  }
464
472
 
@@ -466,8 +474,8 @@ export function resolveS3Parts(flow) {
466
474
  action,
467
475
  handler: 'HdrSqs',
468
476
  type: 'sqs',
469
- mainTemplate: 'CreatePreSignUrl_Main.ejs',
470
- handlerTemplate: 'CreatePreSignUrl_HdrSqs.ejs'
477
+ mainTemplate: 'legacy/CreatePreSignUrl_Main.ejs',
478
+ handlerTemplate: 'legacy/CreatePreSignUrl_HdrSqs.ejs'
471
479
  };
472
480
  });
473
481
  }
@@ -52,10 +52,10 @@ export default async function <%- functionName %>(
52
52
 
53
53
  let targetId =
54
54
  passBackProperties.rawId ||
55
- passBackProperties.<%- objectType %>Id ||
55
+ passBackProperties.<%- (typeof identifierKey !== 'undefined' && identifierKey) ? identifierKey : `${objectType}Id` %> ||
56
56
  passBackProperties.parentPassBackProperties?.rawId ||
57
- passBackProperties.parentPassBackProperties?.<%- objectType %>Id ||
58
- objInstanceFull?.identifiers?.<%- objectType %>Id;
57
+ passBackProperties.parentPassBackProperties?.<%- (typeof identifierKey !== 'undefined' && identifierKey) ? identifierKey : `${objectType}Id` %> ||
58
+ objInstanceFull?.identifiers?.<%- (typeof identifierKey !== 'undefined' && identifierKey) ? identifierKey : `${objectType}Id` %>;
59
59
 
60
60
  if (!targetId && objInstanceFull?.identifiers) {
61
61
  targetId = Object.values(objInstanceFull.identifiers)[0];
@@ -73,13 +73,13 @@ export default async function <%- functionName %>(
73
73
 
74
74
  if (tasks && tasks.Items && tasks.Items.length > 0) {
75
75
  for (const taskRecord of tasks.Items) {
76
- if (taskRecord.connectionId) {
76
+ if (taskRecord.connectionId && taskRecord.connectionId !== 'NONE') {
77
77
  await postToConnection(
78
78
  _izContext,
79
79
  {
80
80
  status: 'completed',
81
81
  data: {
82
- <%- objectType %>Id: targetId,
82
+ <%- (typeof identifierKey !== 'undefined' && identifierKey) ? identifierKey : `${objectType}Id` %>: targetId,
83
83
  objInstanceFull: objInstanceFull
84
84
  }
85
85
  },
@@ -27,6 +27,11 @@ import callingFlowSharedLib from '@izara_project/izara-core-library-calling-flow
27
27
  import lambdaSharedLib from '@izara_project/izara-core-library-lambda';
28
28
  import { sns } from '@izara_project/izara-core-library-external-request';
29
29
  import { consts } from '@izara_project/izara-middleware';
30
+ <% if (isSimplifiedS3) { -%>
31
+ import dynamodbSharedLib from '@izara_project/izara-core-library-dynamodb';
32
+ import { utils } from '@izara_project/izara-core-library-service-schemas';
33
+ const { createFlowTypeConcat } = utils;
34
+ <% } -%>
30
35
 
31
36
  //(<optionalRequired>)
32
37
  //(</optionalRequired>)
@@ -68,6 +73,16 @@ export default async function <%- functionName %>(
68
73
 
69
74
  const s3Key = field.key;
70
75
  const actualByteSize = field.size;
76
+
77
+ if (!s3Key || s3Key.endsWith('/') || actualByteSize === 0) {
78
+ _izContext.logger.warn('Skipping directory marker or 0-byte object:', {
79
+ bucketName: bucket.name,
80
+ key: s3Key,
81
+ size: actualByteSize
82
+ });
83
+ return { status: 'skipped_empty_or_dir', s3Key };
84
+ }
85
+
71
86
  const actualSizeMb = actualByteSize / (1024 * 1024);
72
87
 
73
88
  _izContext.logger.debug('S3 file details:', {
@@ -120,6 +135,81 @@ export default async function <%- functionName %>(
120
135
  <% } -%>
121
136
  //(</extractIdentifiersFromKeyOrMetadata>)
122
137
 
138
+ <% if (isSimplifiedS3) { -%>
139
+ const flowTypeTag = createFlowTypeConcat(_izContext, {
140
+ flowTag: '<%- flowTag %>',
141
+ serviceTag: process.env.iz_serviceTag
142
+ });
143
+ const taskKey = `${flowTypeTag}${rawId}`;
144
+ const taskTableName = await dynamodbSharedLib.tableName(
145
+ _izContext,
146
+ 'WebSocketTask'
147
+ );
148
+
149
+ let resolvedConnectionId =
150
+ s3Metadata.connectionid || s3Metadata.connectionId || '';
151
+ if (!resolvedConnectionId) {
152
+ const queryTasks = await dynamodbSharedLib.query(
153
+ _izContext,
154
+ taskTableName,
155
+ { taskKey }
156
+ );
157
+ if (queryTasks && queryTasks.Items && queryTasks.Items.length > 0) {
158
+ resolvedConnectionId = queryTasks.Items[0].connectionId;
159
+ } else {
160
+ _izContext.logger.warn(
161
+ `[Idempotency] No task record found for <%- objectType %>Id: ${rawId}.` +
162
+ ' Aborting dispatch to Create_In.'
163
+ );
164
+ return {
165
+ status: 'unauthorized_or_unknown_upload',
166
+ s3Key,
167
+ <%- objectType %>Id: rawId
168
+ };
169
+ }
170
+ }
171
+
172
+ try {
173
+ await dynamodbSharedLib.updateItem(
174
+ _izContext,
175
+ taskTableName,
176
+ {
177
+ taskKey,
178
+ connectionId: resolvedConnectionId
179
+ },
180
+ {
181
+ UpdateExpression: 'SET uploadClaimed = :claimed, claimedAt = :now',
182
+ ConditionExpression:
183
+ 'attribute_exists(taskKey) AND attribute_not_exists(uploadClaimed)',
184
+ ExpressionAttributeValues: {
185
+ ':claimed': true,
186
+ ':now': Date.now()
187
+ }
188
+ }
189
+ );
190
+ _izContext.logger.info(
191
+ `[Idempotency] Successfully claimed upload for <%- (typeof identifierKey !== 'undefined' && identifierKey) ? identifierKey : `${objectType}Id` %>: ${rawId}`
192
+ );
193
+ } catch (claimErr) {
194
+ if (claimErr.name === 'ConditionalCheckFailedException') {
195
+ _izContext.logger.warn(
196
+ `[Idempotency] Duplicate or unauthorized S3 upload detected for` +
197
+ ` <%- (typeof identifierKey !== 'undefined' && identifierKey) ? identifierKey : `${objectType}Id` %>: ${rawId}. Aborting dispatch to Create_In.`
198
+ );
199
+ return {
200
+ status: 'duplicate_upload_ignored',
201
+ s3Key,
202
+ <%- (typeof identifierKey !== 'undefined' && identifierKey) ? identifierKey : `${objectType}Id` %>: rawId
203
+ };
204
+ }
205
+ _izContext.logger.error(
206
+ '[Idempotency] Failed to execute atomic claim on WebSocketTask:',
207
+ claimErr
208
+ );
209
+ throw claimErr;
210
+ }
211
+ <% } -%>
212
+
123
213
  //(<endpointHook>)
124
214
  //(</endpointHook>)
125
215
 
@@ -134,7 +224,7 @@ export default async function <%- functionName %>(
134
224
  <% } -%>
135
225
 
136
226
  _izContext.logger.info('Extracted identifiers and metadata:', {
137
- <%- objectType %>Id: rawId,
227
+ <%- (typeof identifierKey !== 'undefined' && identifierKey) ? identifierKey : `${objectType}Id` %>: rawId,
138
228
  parentId,
139
229
  submittedByUserId: metaUserId,
140
230
  <% if (typeof flowRelationships !== 'undefined' && Array.isArray(flowRelationships) && flowRelationships.length > 0) { -%>
@@ -191,9 +281,18 @@ export default async function <%- functionName %>(
191
281
  let messageObject = {
192
282
  objectType: '<%- objectType %>',
193
283
  submittedByUserId: metaUserId,
284
+ <% if (typeof identifierKeys !== 'undefined' && identifierKeys && identifierKeys.length > 1) { -%>
285
+ identifiers: {
286
+ <%- identifierKeys[0] %>: rawId,
287
+ <% for (let i = 1; i < identifierKeys.length; i++) { -%>
288
+ <%- identifierKeys[i] %>: <%- identifierKeys[i] %><%- i < identifierKeys.length - 1 ? ',' : '' %>
289
+ <% } -%>
290
+ },
291
+ <% } else { -%>
194
292
  identifiers: {
195
- <%- objectType %>Id: rawId
293
+ <%- (typeof identifierKey !== 'undefined' && identifierKey) ? identifierKey : `${objectType}Id` %>: rawId
196
294
  },
295
+ <% } -%>
197
296
  <% if (typeof parentIdKeys !== 'undefined' && parentIdKeys && parentIdKeys.length > 1) { -%>
198
297
  targetIdentifiers: {
199
298
  <%- parentIdKeys[0] %>: parentId,
@@ -208,9 +307,16 @@ export default async function <%- functionName %>(
208
307
  <% } -%>
209
308
  relationships,
210
309
  fieldNames: {
310
+ <% if (typeof identifierKeys !== 'undefined' && identifierKeys && identifierKeys.length > 1) { -%>
311
+ <%- identifierKeys[0] %>: rawId,
312
+ <% for (let i = 1; i < identifierKeys.length; i++) { -%>
313
+ <%- identifierKeys[i] %>: <%- identifierKeys[i] %>,
314
+ <% } -%>
315
+ <% } else { -%>
316
+ <%- (typeof identifierKey !== 'undefined' && identifierKey) ? identifierKey : `${objectType}Id` %>: rawId,
317
+ <% } -%>
211
318
  //(<fieldNamesHook>)
212
319
  //(</fieldNamesHook>)
213
- fileName: fileNameWithExt
214
320
  }
215
321
  };
216
322
 
@@ -230,7 +336,7 @@ export default async function <%- functionName %>(
230
336
  passBackProperties,
231
337
  callingFlowSharedLib.createCallingFlowConfig(
232
338
  confirmCallbackFunction,
233
- { rawId: rawId, <%- objectType %>Id: rawId }
339
+ { rawId: rawId, <%- (typeof identifierKey !== 'undefined' && identifierKey) ? identifierKey : `${objectType}Id` %>: rawId }
234
340
  )
235
341
  )
236
342
  );
@@ -245,7 +351,7 @@ export default async function <%- functionName %>(
245
351
 
246
352
  _izContext.logger.info('Publishing to Create_In SNS topic:', {
247
353
  topicArn,
248
- <%- objectType %>Id: rawId,
354
+ <%- (typeof identifierKey !== 'undefined' && identifierKey) ? identifierKey : `${objectType}Id` %>: rawId,
249
355
  parentId<%- (typeof flowRelationships !== 'undefined' && Array.isArray(flowRelationships) && flowRelationships.length > 0) ? flowRelationships.map(r => { const k = r.targetIdentifierKey || `${r.targetObjType.objectType}Id`; return `,\n ${k}: meta${k.charAt(0).toUpperCase() + k.slice(1)}`; }).join('') : '' %>
250
356
  });
251
357
  _izContext.logger.debug(
@@ -260,7 +366,7 @@ export default async function <%- functionName %>(
260
366
  _izContext.logger.info('Successfully published to Create_In SNS:', {
261
367
  publishResult,
262
368
  s3Key,
263
- <%- objectType %>Id: rawId
369
+ <%- (typeof identifierKey !== 'undefined' && identifierKey) ? identifierKey : `${objectType}Id` %>: rawId
264
370
  });
265
371
 
266
372
  return {
@@ -0,0 +1,65 @@
1
+ /*
2
+ Copyright (C) 2026 Sven Mason <http://izara.io>
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU Affero General Public License as
6
+ published by the Free Software Foundation, either version 3 of the
7
+ License, or (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU Affero General Public License for more details.
13
+
14
+ You should have received a copy of the GNU Affero General Public License
15
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+ */
17
+
18
+ import {
19
+ middlewareHandler as middleware,
20
+ response
21
+ } from '@izara_project/izara-middleware';
22
+ import callingFlowSharedLib from '@izara_project/izara-core-library-calling-flow';
23
+ import { webSocket } from '@izara_project/izara-core-library-external-request';
24
+ const { postToConnection } = webSocket;
25
+
26
+ import <%= functionName %> from './<%= functionName %>_Main.js';
27
+
28
+ export const main = middleware.wrap(async (event, context) => {
29
+ event._izContext.logger.debug('Event:', event);
30
+
31
+ try {
32
+ if (event.requestContext) {
33
+ const route = event.requestContext.routeKey;
34
+
35
+ switch (route) {
36
+ case '<%- route %>':
37
+ console.log('event in route webSocket', event);
38
+ let eventParams = JSON.parse(event.body);
39
+
40
+ let callingFlow = eventParams?.callingFlow
41
+ ? eventParams.callingFlow
42
+ : {};
43
+ await <%= functionName %>(
44
+ event._izContext,
45
+ eventParams,
46
+ callingFlowSharedLib.addCallingFlowToPassOnProperties(callingFlow)
47
+ //(<additionalParams>)
48
+ //(</additionalParams>)
49
+ );
50
+
51
+ break;
52
+ }
53
+ } else {
54
+ const connId = event.connId;
55
+ await postToConnection(event._izContext, { message: event.message }, connId);
56
+ }
57
+
58
+ return response.webSocketSuccess();
59
+ } catch (err) {
60
+ const connId = event.requestContext.connectionId;
61
+ event._izContext.logger.error('Error, WebSocket: ', err);
62
+ await postToConnection(event._izContext, { message: err.message }, connId);
63
+ return response.failure(err);
64
+ }
65
+ });
@@ -15,7 +15,7 @@ You should have received a copy of the GNU Affero General Public License
15
15
  along with this program. If not, see <http://www.gnu.org/licenses/>.
16
16
  */
17
17
 
18
- import { v7 as uuidv7 } from 'uuid';
18
+ import { identifierUuid } from '@izara_project/izara-shared-core';
19
19
  import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
20
20
  import { S3Client } from '@aws-sdk/client-s3';
21
21
  import { consts } from '@izara_project/izara-middleware';
@@ -99,7 +99,8 @@ export default async function <%- functionName %>(
99
99
  }
100
100
  <% } -%>
101
101
 
102
- const <%- objectType %>Id = uuidv7();
102
+ <% const targetIdName = (typeof identifierKey !== 'undefined' && identifierKey) ? identifierKey : `${objectType}Id`; -%>
103
+ const <%- targetIdName %> = identifierUuid();
103
104
 
104
105
  <% if (typeof parentIdKeys !== 'undefined' && parentIdKeys && parentIdKeys.length > 0) { -%>
105
106
  const targetParentId = requestParams.targetIdentifiers?.<%- parentIdKeys[0] %>;
@@ -139,7 +140,7 @@ export default async function <%- functionName %>(
139
140
  Key: Key,
140
141
  Conditions: [
141
142
  ['content-length-range', 0, maximumSize],
142
- ['eq', '$x-amz-meta-<%- objectType %>Id', <%- objectType %>Id],
143
+ ['eq', '$x-amz-meta-<%- targetIdName %>', <%- targetIdName %>],
143
144
  <% if (typeof parentIdKeys !== 'undefined' && parentIdKeys && parentIdKeys.length > 0) { -%>
144
145
  <% for (const key of parentIdKeys) { -%>
145
146
  ['eq', '$x-amz-meta-<%- key %>', target<%- key.charAt(0).toUpperCase() + key.slice(1) %>],
@@ -147,6 +148,7 @@ export default async function <%- functionName %>(
147
148
  <% } -%>
148
149
  ['starts-with', '$x-amz-meta-submittedByUserId', ''],
149
150
  ['starts-with', '$Content-Type', '<%- contentTypePrefix %>'],
151
+ ['starts-with', '$x-amz-meta-connectionId', ''],
150
152
  <% if (typeof flowRelationships !== 'undefined' && Array.isArray(flowRelationships) && flowRelationships.length > 0) { -%>
151
153
  <% for (const rel of flowRelationships) { -%>
152
154
  <% const relIdKey = rel.targetIdentifierKey || `${rel.targetObjType.objectType}Id`; -%>
@@ -157,13 +159,14 @@ export default async function <%- functionName %>(
157
159
  //(</conditionCreateUrlLink>)
158
160
  ],
159
161
  Fields: {
160
- 'x-amz-meta-<%- objectType %>Id': <%- objectType %>Id,
162
+ 'x-amz-meta-<%- targetIdName %>': <%- targetIdName %>,
161
163
  <% if (typeof parentIdKeys !== 'undefined' && parentIdKeys && parentIdKeys.length > 0) { -%>
162
164
  <% for (const key of parentIdKeys) { -%>
163
165
  'x-amz-meta-<%- key %>': target<%- key.charAt(0).toUpperCase() + key.slice(1) %>,
164
166
  <% } -%>
165
167
  <% } -%>
166
168
  'x-amz-meta-submittedByUserId': submittedByUserId,
169
+ 'x-amz-meta-connectionId': connectionId || 'NONE',
167
170
  <% if (typeof flowRelationships !== 'undefined' && Array.isArray(flowRelationships) && flowRelationships.length > 0) { -%>
168
171
  <% for (const rel of flowRelationships) { -%>
169
172
  <% const relIdKey = rel.targetIdentifierKey || `${rel.targetObjType.objectType}Id`; -%>
@@ -181,24 +184,25 @@ export default async function <%- functionName %>(
181
184
 
182
185
  const uploadUrl = await createPresignedPost(client, createPreSignUrlParam);
183
186
 
184
- if (connectionId) {
185
- await dynamodbSharedLib.putItem(
186
- _izContext,
187
- await dynamodbSharedLib.tableName(_izContext, 'WebSocketTask'),
188
- {
189
- taskKey: `${createFlowTypeConcat(_izContext, { flowTag: '<%- flowTag %>', serviceTag: process.env.iz_serviceTag })}${<%- objectType %>Id}`,
190
- connectionId: connectionId,
191
- expirationTime: Math.floor(Date.now() / 1000) + 7200
192
- }
193
- );
187
+ const resolvedConnectionId = connectionId || 'NONE';
188
+ await dynamodbSharedLib.putItem(
189
+ _izContext,
190
+ await dynamodbSharedLib.tableName(_izContext, 'WebSocketTask'),
191
+ {
192
+ taskKey: `${createFlowTypeConcat(_izContext, { flowTag: '<%- flowTag %>', serviceTag: process.env.iz_serviceTag })}${<%- targetIdName %>}`,
193
+ connectionId: resolvedConnectionId,
194
+ expirationTime: Math.floor(Date.now() / 1000) + 7200
195
+ }
196
+ );
194
197
 
198
+ if (connectionId) {
195
199
  await postToConnection(
196
200
  _izContext,
197
201
  {
198
202
  status: 'success',
199
203
  data: {
200
204
  uploadUrl: uploadUrl,
201
- <%- objectType %>Id: <%- objectType %>Id
205
+ <%- targetIdName %>: <%- targetIdName %>
202
206
  }
203
207
  },
204
208
  connectionId
@@ -207,7 +211,7 @@ export default async function <%- functionName %>(
207
211
 
208
212
  return {
209
213
  uploadUrl: uploadUrl,
210
- <%- objectType %>Id: <%- objectType %>Id
214
+ <%- targetIdName %>: <%- targetIdName %>
211
215
  };
212
216
  } catch (err) {
213
217
  _izContext.logger.error('Error in <%- functionName %>:', err);
@@ -64,7 +64,7 @@ export default async function <%- functionName %>(
64
64
  { taskKey: taskKey }
65
65
  );
66
66
 
67
- if (taskRecord && taskRecord.connectionId) {
67
+ if (taskRecord && taskRecord.connectionId && taskRecord.connectionId !== 'NONE') {
68
68
  await postToConnection(
69
69
  _izContext,
70
70
  {
@@ -154,7 +154,7 @@ export default async function CancelUsage(_izContext, requestParams) {
154
154
  };
155
155
 
156
156
  const eventSourceTag =
157
- _izContext.izEventSource?.eventSourceTag ||
157
+ _izContext?.izEventSource?.eventSourceTag ||
158
158
  requestParams?.izEventSource?.eventSourceTag;
159
159
 
160
160
  let isSync = false;
@@ -162,16 +162,17 @@ export default async function CancelUsage(_izContext, requestParams) {
162
162
  eventSourceTag === 'DirectInvoke' ||
163
163
  eventSourceTag === 'ApiGateway' ||
164
164
  eventSourceTag === 'ApiGatewayV2' ||
165
- requestParams?.hasOwnProperty('__context__')
165
+ requestParams?.hasOwnProperty('__context__') ||
166
+ _izContext?.functionName?.endsWith('HdrInv') ||
167
+ _izContext?.functionName?.endsWith('HdrApi') ||
168
+ eventSourceTag !== 'Sqs'
166
169
  ) {
167
170
  isSync = true;
168
171
  }
169
172
 
170
173
  if (!isSync) {
171
- await sns.publishMessage(_izContext, {
172
- message: {
173
- returnResult
174
- },
174
+ await sns.publishAsync(_izContext, {
175
+ Message: JSON.stringify({ returnResult }),
175
176
  TopicArn: snsSharedLib.snsTopicArnByFlowSchema(
176
177
  _izContext,
177
178
  'CancelUsage_Out'
@@ -178,7 +178,7 @@ export default async function ConfirmUsage(_izContext, requestParams) {
178
178
  };
179
179
 
180
180
  const eventSourceTag =
181
- _izContext.izEventSource?.eventSourceTag ||
181
+ _izContext?.izEventSource?.eventSourceTag ||
182
182
  requestParams?.izEventSource?.eventSourceTag;
183
183
 
184
184
  let isSync = false;
@@ -186,16 +186,17 @@ export default async function ConfirmUsage(_izContext, requestParams) {
186
186
  eventSourceTag === 'DirectInvoke' ||
187
187
  eventSourceTag === 'ApiGateway' ||
188
188
  eventSourceTag === 'ApiGatewayV2' ||
189
- requestParams?.hasOwnProperty('__context__')
189
+ requestParams?.hasOwnProperty('__context__') ||
190
+ _izContext?.functionName?.endsWith('HdrInv') ||
191
+ _izContext?.functionName?.endsWith('HdrApi') ||
192
+ eventSourceTag !== 'Sqs'
190
193
  ) {
191
194
  isSync = true;
192
195
  }
193
196
 
194
197
  if (!isSync) {
195
- await sns.publishMessage(_izContext, {
196
- message: {
197
- returnResult
198
- },
198
+ await sns.publishAsync(_izContext, {
199
+ Message: JSON.stringify({ returnResult }),
199
200
  TopicArn: snsSharedLib.snsTopicArnByFlowSchema(
200
201
  _izContext,
201
202
  'ConfirmUsage_Out'
@@ -133,8 +133,8 @@ export default async function CreateTargetRole(_izContext, requestParams) {
133
133
  }
134
134
  }
135
135
 
136
- await sns.publishMessage(_izContext, {
137
- message: { returnResult: { roleId, roleIdKey } },
136
+ await sns.publishAsync(_izContext, {
137
+ Message: JSON.stringify({ returnResult: { roleId, roleIdKey } }),
138
138
  TopicArn: snsSharedLib.snsTopicArnByFlowSchema(
139
139
  _izContext,
140
140
  'CreateTargetRole_Out'
@@ -82,8 +82,8 @@ export default async function DeleteTargetRole(_izContext, requestParams) {
82
82
  }
83
83
  }
84
84
 
85
- await sns.publishMessage(_izContext, {
86
- message: { returnResult: { identifiersConcat, roleIdKey, status: 'deleted' } },
85
+ await sns.publishAsync(_izContext, {
86
+ Message: JSON.stringify({ returnResult: { identifiersConcat, roleIdKey, status: 'deleted' } }),
87
87
  TopicArn: snsSharedLib.snsTopicArnByFlowSchema(
88
88
  _izContext,
89
89
  'DeleteTargetRole_Out'
@@ -95,8 +95,8 @@ export default async function GetMyPermissions(_izContext, requestParams) {
95
95
  permissions: permissionsMap
96
96
  };
97
97
 
98
- await sns.publishMessage(_izContext, {
99
- message: { returnResult: result },
98
+ await sns.publishAsync(_izContext, {
99
+ Message: JSON.stringify({ returnResult: result }),
100
100
  TopicArn: snsSharedLib.snsTopicArnByFlowSchema(
101
101
  _izContext,
102
102
  'GetMyPermissions_Out'
@@ -47,8 +47,8 @@ export default async function GetTargetRole(_izContext, requestParams) {
47
47
  users: usersResult?.Items || []
48
48
  };
49
49
 
50
- await sns.publishMessage(_izContext, {
51
- message: { returnResult: result },
50
+ await sns.publishAsync(_izContext, {
51
+ Message: JSON.stringify({ returnResult: result }),
52
52
  TopicArn: snsSharedLib.snsTopicArnByFlowSchema(
53
53
  _izContext,
54
54
  'GetTargetRole_Out'
@@ -41,8 +41,8 @@ export default async function <%- flowTag %>(_izContext, requestParams) {
41
41
  dynamodbSharedLib.tableName(_izContext, '<%- tableName %>'),
42
42
  queryParams
43
43
  );
44
- await sns.publishMessage(_izContext, {
45
- message: { returnResult: { records: resultQuery.Items || [] } },
44
+ await sns.publishAsync(_izContext, {
45
+ Message: JSON.stringify({ returnResult: { records: resultQuery.Items || [] } }),
46
46
  TopicArn: snsSharedLib.snsTopicArnByFlowSchema(
47
47
  _izContext,
48
48
  '<%- flowTag %>_Out'
@@ -28,8 +28,8 @@ export default async function <%- flowTag %>(_izContext, requestParams) {
28
28
  dynamodbSharedLib.tableName(_izContext, '<%- tableName %>'),
29
29
  { <%- fieldList %> }
30
30
  );
31
- await sns.publishMessage(_izContext, {
32
- message: { returnResult: { <%- fieldList %> } },
31
+ await sns.publishAsync(_izContext, {
32
+ Message: JSON.stringify({ returnResult: { <%- fieldList %> } }),
33
33
  TopicArn: snsSharedLib.snsTopicArnByFlowSchema(
34
34
  _izContext,
35
35
  '<%- flowTag %>_Out'
@@ -197,7 +197,7 @@ export default async function ReservedDynamicUsage(_izContext, requestParams) {
197
197
  };
198
198
 
199
199
  const eventSourceTag =
200
- _izContext.izEventSource?.eventSourceTag ||
200
+ _izContext?.izEventSource?.eventSourceTag ||
201
201
  requestParams?.izEventSource?.eventSourceTag;
202
202
 
203
203
  let isSync = false;
@@ -205,14 +205,17 @@ export default async function ReservedDynamicUsage(_izContext, requestParams) {
205
205
  eventSourceTag === 'DirectInvoke' ||
206
206
  eventSourceTag === 'ApiGateway' ||
207
207
  eventSourceTag === 'ApiGatewayV2' ||
208
- requestParams?.hasOwnProperty('__context__')
208
+ requestParams?.hasOwnProperty('__context__') ||
209
+ _izContext?.functionName?.endsWith('HdrInv') ||
210
+ _izContext?.functionName?.endsWith('HdrApi') ||
211
+ eventSourceTag !== 'Sqs'
209
212
  ) {
210
213
  isSync = true;
211
214
  }
212
215
 
213
216
  if (!isSync) {
214
- await sns.publishMessage(_izContext, {
215
- message: { returnResult },
217
+ await sns.publishAsync(_izContext, {
218
+ Message: JSON.stringify({ returnResult }),
216
219
  TopicArn: snsSharedLib.snsTopicArnByFlowSchema(
217
220
  _izContext,
218
221
  'ReservedDynamicUsage_Out'
@@ -93,8 +93,8 @@ export default async function UpdateTargetRole(_izContext, requestParams) {
93
93
  }
94
94
  }
95
95
 
96
- await sns.publishMessage(_izContext, {
97
- message: { returnResult: { roleIdKey, status: 'updated' } },
96
+ await sns.publishAsync(_izContext, {
97
+ Message: JSON.stringify({ returnResult: { roleIdKey, status: 'updated' } }),
98
98
  TopicArn: snsSharedLib.snsTopicArnByFlowSchema(
99
99
  _izContext,
100
100
  'UpdateTargetRole_Out'
@@ -54,8 +54,8 @@ export default async function <%- flowTag %>(_izContext, requestParams) {
54
54
  }
55
55
  }
56
56
  ]);
57
- await sns.publishMessage(_izContext, {
58
- message: { returnResult: { userId, roleIdKey } },
57
+ await sns.publishAsync(_izContext, {
58
+ Message: JSON.stringify({ returnResult: { userId, roleIdKey } }),
59
59
  TopicArn: snsSharedLib.snsTopicArnByFlowSchema(
60
60
  _izContext,
61
61
  '<%- flowTag %>_Out'
@@ -7,7 +7,7 @@
7
7
  - stream:
8
8
  type: dynamodb
9
9
  arn:
10
- Fn::GetAtt: [<%= tableName %>Table, StreamArn]
10
+ Fn::ImportValue: ${self:custom.iz_resourcePrefix}<%= tableName %>StreamArn
11
11
  batchSize: <%= batchSize %>
12
12
  startingPosition: <%= startingPosition %>
13
13
  <% if (Array.isArray(filterPatterns) && filterPatterns.length > 0) { -%>
@@ -11,8 +11,12 @@ export async function renderDynamo(state) {
11
11
  )
12
12
  ]);
13
13
  const indentedTables = tables.map((t) => indentYaml(t, 2)).join('\n');
14
+ let content = system;
14
15
  if (indentedTables) {
15
- return `${system}\n${indentedTables}`;
16
+ content = `${content}\n${indentedTables}`;
16
17
  }
17
- return `${system}`;
18
+ if (state.selfRbac) {
19
+ content = `${content}\n\nOutputs:\n RunningTotalReservedUsageStreamArn:\n Value:\n Fn::GetAtt: [RunningTotalReservedUsageTable, StreamArn]\n Export:\n Name: \${self:custom.iz_resourcePrefix}RunningTotalReservedUsageStreamArn\n`;
20
+ }
21
+ return content;
18
22
  }
@@ -102,7 +102,8 @@ export async function renderOut(state) {
102
102
  renderResourceTemplate('FlowOut_Yaml.ejs', { topicName })
103
103
  )
104
104
  );
105
- const body = renderedTopics.join('\n');
105
+ const fileHook = '#<#customTopicOut#>\n#<#/customTopicOut#>';
106
+ const body = [...renderedTopics, fileHook].filter(Boolean).join('\n');
106
107
  return `Resources:\n${indentYaml(body, 2)}`;
107
108
  }
108
109
 
@@ -4,3 +4,5 @@ Out<%- topicName %>:
4
4
  Properties:
5
5
  DisplayName: 'SNS Message out'
6
6
  TopicName: ${self:custom.iz_serviceTag}_${self:custom.iz_stage}_<%- topicName %>_Out
7
+ #<#Out<%- topicName %>TopicSetting#>
8
+ #<#/Out<%- topicName %>TopicSetting#>
@@ -332,7 +332,7 @@ test('generates synchronous HdrInv and HdrApi handlers for FlowLimit flows', asy
332
332
  /ReservedUsageTtlExpiredHdrDss:\n\s+handler: src\/generatedCode\/FlowLimit\/ReservedUsageTtlExpired\/source\/ReservedUsageTtlExpired_HdrDss\.main/
333
333
  );
334
334
  assert.match(limitYaml, /type: dynamodb/);
335
- assert.match(limitYaml, /RunningTotalReservedUsageTable/);
335
+ assert.match(limitYaml, /RunningTotalReservedUsageStreamArn/);
336
336
  assert.match(limitYaml, /eventName: \["REMOVE"\]/);
337
337
  });
338
338
 
@@ -131,8 +131,8 @@ test('emitS3Flow with ADR-016 deferred-node pipeline generates correct 3 pairs o
131
131
  'Start_Main should generate S3 presigned post URL'
132
132
  );
133
133
  assert.ok(
134
- startMainArtifact.content.includes('uuidv7'),
135
- 'Start_Main should generate identifier with uuidv7'
134
+ startMainArtifact.content.includes('identifierUuid'),
135
+ 'Start_Main should generate identifier with identifierUuid'
136
136
  );
137
137
  assert.ok(
138
138
  !startMainArtifact.content.includes("snsTopicArnByFlowSchema(_izContext, 'Create_In')"),
@@ -186,6 +186,10 @@ test('emitS3Flow with ADR-016 deferred-node pipeline generates correct 3 pairs o
186
186
  procMainArtifact.content.includes('FeedChunkRecordUrlConfirmAfterUploadHdrSqs'),
187
187
  'ProcessAfterUpload_Main must target ConfirmAfterUploadHdrSqs for callback'
188
188
  );
189
+ assert.ok(
190
+ procMainArtifact.content.includes('feedChunkId: rawId'),
191
+ 'ProcessAfterUpload_Main must include objectTypeId: rawId in fieldNames'
192
+ );
189
193
 
190
194
  const confirmMainArtifact = result.artifacts.find(
191
195
  (a) => a.path === `${source}/FeedChunkRecordUrlConfirmAfterUpload_Main.js`
@@ -853,6 +857,156 @@ test('emitS3Flow resolves relationships from schemas with relationshipDirection
853
857
  );
854
858
  });
855
859
 
860
+ test('emitS3Flow generates WebSocketTask atomic claim in ProcessAfterUpload and connectionId NONE guard in ConfirmComplete', async () => {
861
+ const flow = {
862
+ flowTag: 'FeedChunkRecordUrl',
863
+ initiateBy: 'user',
864
+ handleObj: 'one',
865
+ statusType: 'statusField',
866
+ event: ['s3'],
867
+ objType: {
868
+ objectType: 'feedChunk',
869
+ serviceTag: 'FeedChunk'
870
+ },
871
+ bucketName: 'video-record-temp',
872
+ flowSteps: [
873
+ { stepName: 'Start', event: ['wbs'] },
874
+ { stepName: 'ProcessAfterUpload', event: ['s3'] },
875
+ { stepName: 'ConfirmComplete', event: ['extTopic'] }
876
+ ]
877
+ };
878
+
879
+ const source = 'app/src/generatedCode/FlowSchemas/FeedChunkRecordUrl/source';
880
+ const handlerRoot = 'src/generatedCode/FlowSchemas/FeedChunkRecordUrl/source';
881
+
882
+ const result = await emitS3Flow(flow, source, handlerRoot);
883
+
884
+ const procMain = result.artifacts.find(
885
+ (a) => a.path === `${source}/FeedChunkRecordUrlProcessAfterUpload_Main.js`
886
+ );
887
+ assert.ok(
888
+ procMain.content.includes('attribute_exists(taskKey) AND attribute_not_exists(uploadClaimed)'),
889
+ 'ProcessAfterUpload_Main must enforce attribute_exists(taskKey) AND attribute_not_exists(uploadClaimed)'
890
+ );
891
+ assert.ok(
892
+ procMain.content.includes("status: 'unauthorized_or_unknown_upload'"),
893
+ 'ProcessAfterUpload_Main must abort when task record is missing'
894
+ );
895
+ assert.ok(
896
+ procMain.content.includes('dynamodbSharedLib'),
897
+ 'ProcessAfterUpload_Main must import dynamodbSharedLib for simplified S3'
898
+ );
899
+
900
+ assert.ok(
901
+ procMain.content.includes("status: 'skipped_empty_or_dir'"),
902
+ 'ProcessAfterUpload_Main must guard against directory markers or 0-byte files'
903
+ );
904
+
905
+ const confirmMain = result.artifacts.find(
906
+ (a) => a.path === `${source}/FeedChunkRecordUrlConfirmComplete_Main.js`
907
+ );
908
+ assert.ok(
909
+ confirmMain.content.includes("taskRecord.connectionId !== 'NONE'"),
910
+ 'ConfirmComplete_Main must guard against connectionId NONE'
911
+ );
912
+ });
913
+
914
+ test('resolveTargetIdentifierInfo resolves both DynamoDB and Graph identifier formats', async () => {
915
+ const { resolveTargetIdentifierInfo } = await import(
916
+ '../../../src/emit/application/custom/customFlowS3.js'
917
+ );
918
+
919
+ // 1. Graph style: { type: 'identifier', fieldName: 'chunkUuid' }
920
+ const graphFlow = { objType: { objectType: 'feedChunk' } };
921
+ const graphObjects = [
922
+ {
923
+ objectType: 'feedChunk',
924
+ identifiers: [{ type: 'identifier', fieldName: 'chunkUuid' }]
925
+ }
926
+ ];
927
+ const graphResult = resolveTargetIdentifierInfo(graphFlow, graphObjects);
928
+ assert.equal(graphResult.identifierKey, 'chunkUuid');
929
+ assert.deepEqual(graphResult.identifierKeys, ['chunkUuid']);
930
+
931
+ // 2. DynamoDB style: { type: 'partitionKey', fieldName: 'myFeedId' }
932
+ const dynamoFlow = { objType: { objectType: 'feedChunk' } };
933
+ const dynamoObjects = [
934
+ {
935
+ objectType: 'feedChunk',
936
+ identifiers: [{ type: 'partitionKey', fieldName: 'myFeedId' }]
937
+ }
938
+ ];
939
+ const dynamoResult = resolveTargetIdentifierInfo(dynamoFlow, dynamoObjects);
940
+ assert.equal(dynamoResult.identifierKey, 'myFeedId');
941
+ assert.deepEqual(dynamoResult.identifierKeys, ['myFeedId']);
942
+
943
+ // 3. Fallback when no identifiers defined
944
+ const emptyFlow = { objType: { objectType: 'videoFeed' } };
945
+ const emptyResult = resolveTargetIdentifierInfo(emptyFlow, []);
946
+ assert.equal(emptyResult.identifierKey, 'videoFeedId');
947
+ assert.deepEqual(emptyResult.identifierKeys, ['videoFeedId']);
948
+ });
949
+
950
+ test('emitS3Flow generates dynamic identifier name for both Start_Main and ProcessAfterUpload_Main', async () => {
951
+ const flow = {
952
+ flowTag: 'FeedChunkRecordUrl',
953
+ flowType: 'custom',
954
+ event: ['s3'],
955
+ bucketName: 'video-record-temp',
956
+ contentTypePrefix: 'video/',
957
+ maxFileSizeMb: 50,
958
+ objType: {
959
+ objectType: 'feedChunk',
960
+ serviceTag: 'FeedChunk'
961
+ },
962
+ flowSteps: [
963
+ { stepName: 'Start', event: ['wbs'] },
964
+ { stepName: 'ProcessAfterUpload', event: ['s3'] },
965
+ { stepName: 'ConfirmComplete', event: ['sqs'] }
966
+ ]
967
+ };
968
+
969
+ const customObjects = [
970
+ {
971
+ objectType: 'feedChunk',
972
+ identifiers: [
973
+ {
974
+ type: 'identifier',
975
+ fieldName: 'customChunkId'
976
+ }
977
+ ]
978
+ }
979
+ ];
980
+
981
+ const source = 'app/src/generatedCode/FlowSchemas/FeedChunkRecordUrl/source';
982
+ const handlerRoot = 'src/generatedCode/FlowSchemas/FeedChunkRecordUrl/source';
983
+
984
+ const result = await emitS3Flow(flow, source, handlerRoot, {
985
+ objects: customObjects
986
+ });
987
+
988
+ const startMain = result.artifacts.find(
989
+ (a) => a.path === `${source}/FeedChunkRecordUrl_Main.js`
990
+ );
991
+ assert.ok(
992
+ startMain.content.includes('const customChunkId = identifierUuid()'),
993
+ 'Start_Main must bind custom identifier key'
994
+ );
995
+ assert.ok(
996
+ startMain.content.includes("'x-amz-meta-customChunkId': customChunkId"),
997
+ 'Start_Main must use custom identifier key in S3 metadata'
998
+ );
999
+
1000
+ const procMain = result.artifacts.find(
1001
+ (a) => a.path === `${source}/FeedChunkRecordUrlProcessAfterUpload_Main.js`
1002
+ );
1003
+ assert.ok(
1004
+ procMain.content.includes('customChunkId: rawId'),
1005
+ 'ProcessAfterUpload_Main must use custom identifier key in fieldNames and identifiers'
1006
+ );
1007
+ });
1008
+
1009
+
856
1010
 
857
1011
 
858
1012
 
@@ -97,6 +97,10 @@ test('emits queues, topics, tables, and IAM roles from schema data', async () =>
97
97
  dynamoYaml,
98
98
  /RunningTotalReservedUsageTable:\s+Type: 'AWS::DynamoDB::Table'[\s\S]+TableName: \${self:custom\.iz_resourcePrefix}RunningTotalReservedUsage/
99
99
  );
100
+ assert.match(
101
+ dynamoYaml,
102
+ /Outputs:\s+RunningTotalReservedUsageStreamArn:\s+Value:\s+Fn::GetAtt: \[RunningTotalReservedUsageTable, StreamArn\]\s+Export:\s+Name: \${self:custom\.iz_resourcePrefix}RunningTotalReservedUsageStreamArn/
103
+ );
100
104
  const iam = await readFile(
101
105
  join(
102
106
  rootPath,
@@ -960,3 +964,23 @@ test('roleStatements emits s3:GetObject for custom S3 flow bucket', async () =>
960
964
  assert.match(uploadRole, /arn:aws:s3:::UploadBucket\/\*/);
961
965
  });
962
966
 
967
+ test('emits per-topic hook tags in generated-sns-out.yml', async () => {
968
+ const rootPath = await createProject();
969
+ await generateResourceArtifacts(rootPath);
970
+ const snsOut = await readFile(
971
+ join(rootPath, 'resource/sls_yaml/generatedCode/source/generated-sns-out.yml'),
972
+ 'utf8'
973
+ );
974
+ assert.match(snsOut, /#<#Out\w+TopicSetting#>\s*#<#\/Out\w+TopicSetting#>/);
975
+ });
976
+
977
+ test('emits customTopicOut file-level hook in generated-sns-out.yml', async () => {
978
+ const rootPath = await createProject();
979
+ await generateResourceArtifacts(rootPath);
980
+ const snsOut = await readFile(
981
+ join(rootPath, 'resource/sls_yaml/generatedCode/source/generated-sns-out.yml'),
982
+ 'utf8'
983
+ );
984
+ assert.match(snsOut, /#<#customTopicOut#>\s*#<#\/customTopicOut#>/);
985
+ });
986
+
@@ -402,4 +402,53 @@ test('warns when source content contains custom code in hook missing from templa
402
402
  }
403
403
  });
404
404
 
405
+ test('preserves custom settings inside Out<topicName>TopicSetting and customTopicOut in generated-sns-out.yml', async () => {
406
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'mergeHooks-test-'));
407
+ try {
408
+ const filePath = 'resource/sls_yaml/generatedCode/source/generated-sns-out.yml';
409
+ const diskContent = [
410
+ 'Resources:',
411
+ ' ##===== [Topic Out]',
412
+ ' OutCreate:',
413
+ ' Type: AWS::SNS::Topic',
414
+ ' Properties:',
415
+ " DisplayName: 'SNS Message out'",
416
+ ' TopicName: ${self:custom.iz_serviceTag}_${self:custom.iz_stage}_Create_Out',
417
+ ' #<#OutCreateTopicSetting#>',
418
+ ' FifoTopic: true',
419
+ ' #<#/OutCreateTopicSetting#>',
420
+ '',
421
+ ' #<#customTopicOut#>',
422
+ ' OutCustomNotification:',
423
+ ' Type: AWS::SNS::Topic',
424
+ ' #<#/customTopicOut#>'
425
+ ].join('\n') + '\n';
426
+ await writeTestFile(tmpDir, filePath, diskContent);
427
+
428
+ const generatedArtifact = {
429
+ path: filePath,
430
+ content: [
431
+ 'Resources:',
432
+ ' ##===== [Topic Out]',
433
+ ' OutCreate:',
434
+ ' Type: AWS::SNS::Topic',
435
+ ' Properties:',
436
+ " DisplayName: 'SNS Message out'",
437
+ ' TopicName: ${self:custom.iz_serviceTag}_${self:custom.iz_stage}_Create_Out',
438
+ ' #<#OutCreateTopicSetting#>',
439
+ ' #<#/OutCreateTopicSetting#>',
440
+ '',
441
+ ' #<#customTopicOut#>',
442
+ ' #<#/customTopicOut#>'
443
+ ].join('\n') + '\n'
444
+ };
445
+
446
+ const [result] = await mergeHooks(tmpDir, [generatedArtifact]);
447
+ assert.match(result.content, /FifoTopic: true/);
448
+ assert.match(result.content, /OutCustomNotification:/);
449
+ } finally {
450
+ await rm(tmpDir, { recursive: true, force: true });
451
+ }
452
+ });
453
+
405
454