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

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 (22) hide show
  1. package/package.json +1 -1
  2. package/src/emit/application/custom/customFlowS3.js +38 -0
  3. package/src/emit/application/custom/customFlows.js +22 -14
  4. package/src/emit/application/custom/templates/s3/ConfirmComplete_Main.ejs +5 -5
  5. package/src/emit/application/custom/templates/s3/ProcessS3File_Main.ejs +112 -6
  6. package/src/emit/application/custom/templates/s3/Start_HdrWbs.ejs +65 -0
  7. package/src/emit/application/custom/templates/s3/Start_Main.ejs +20 -16
  8. package/src/emit/application/custom/templates/s3/{ConfirmAfterUpload_Main.ejs → legacy/ConfirmAfterUpload_Main.ejs} +1 -1
  9. package/src/emit/infrastructure/functions/templates/DssFunction_Yaml.ejs +1 -1
  10. package/src/emit/infrastructure/resources/dynamoResourceArtifacts.js +6 -2
  11. package/test/unit/application/createTargetRoleHandler.test.js +1 -1
  12. package/test/unit/application/customFlowS3.test.js +156 -2
  13. package/test/unit/infrastructure/resourceArtifacts.test.js +4 -0
  14. /package/src/emit/application/custom/templates/s3/{ConfirmAfterUpload_HdrSqs.ejs → legacy/ConfirmAfterUpload_HdrSqs.ejs} +0 -0
  15. /package/src/emit/application/custom/templates/s3/{ConfirmReserved_HdrSqs.ejs → legacy/ConfirmReserved_HdrSqs.ejs} +0 -0
  16. /package/src/emit/application/custom/templates/s3/{ConfirmReserved_Main.ejs → legacy/ConfirmReserved_Main.ejs} +0 -0
  17. /package/src/emit/application/custom/templates/s3/{CreateObject_HdrWbs.ejs → legacy/CreateObject_HdrWbs.ejs} +0 -0
  18. /package/src/emit/application/custom/templates/s3/{CreateObject_Main.ejs → legacy/CreateObject_Main.ejs} +0 -0
  19. /package/src/emit/application/custom/templates/s3/{CreatePreSignUrl_HdrSqs.ejs → legacy/CreatePreSignUrl_HdrSqs.ejs} +0 -0
  20. /package/src/emit/application/custom/templates/s3/{CreatePreSignUrl_Main.ejs → legacy/CreatePreSignUrl_Main.ejs} +0 -0
  21. /package/src/emit/application/custom/templates/s3/{ReservedLimit_HdrSqs.ejs → legacy/ReservedLimit_HdrSqs.ejs} +0 -0
  22. /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.102",
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": {
@@ -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
  {
@@ -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
  }
@@ -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,