@openhi/constructs 0.0.111 → 0.0.112

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 (111) hide show
  1. package/lib/chunk-23PUSHBV.mjs +24 -0
  2. package/lib/chunk-23PUSHBV.mjs.map +1 -0
  3. package/lib/{chunk-7FUAMZOF.mjs → chunk-53OHXLIL.mjs} +3 -3
  4. package/lib/chunk-6NBGYGFL.mjs +1803 -0
  5. package/lib/chunk-6NBGYGFL.mjs.map +1 -0
  6. package/lib/chunk-7RZHFI77.mjs +22 -0
  7. package/lib/chunk-7RZHFI77.mjs.map +1 -0
  8. package/lib/{chunk-7Q2IJ2J5.mjs → chunk-CUUKXDB2.mjs} +6 -6
  9. package/lib/chunk-FYHBHHWK.mjs +47 -0
  10. package/lib/chunk-FYHBHHWK.mjs.map +1 -0
  11. package/lib/{chunk-MULKGFIJ.mjs → chunk-GBDIGTNV.mjs} +165 -10
  12. package/lib/chunk-GBDIGTNV.mjs.map +1 -0
  13. package/lib/chunk-HQ67J7BP.mjs +199 -0
  14. package/lib/chunk-HQ67J7BP.mjs.map +1 -0
  15. package/lib/{chunk-AJ3G3THO.mjs → chunk-KO64HPWQ.mjs} +2 -2
  16. package/lib/{chunk-BB5MK4L3.mjs → chunk-KSFC72TT.mjs} +3 -3
  17. package/lib/{chunk-2TPJ6HOF.mjs → chunk-NZRW7ROK.mjs} +72 -54
  18. package/lib/chunk-NZRW7ROK.mjs.map +1 -0
  19. package/lib/chunk-QJDHVMKT.mjs +117 -0
  20. package/lib/chunk-QJDHVMKT.mjs.map +1 -0
  21. package/lib/{chunk-IS4VQRI4.mjs → chunk-QMBJ4VHC.mjs} +12 -47
  22. package/lib/chunk-QMBJ4VHC.mjs.map +1 -0
  23. package/lib/chunk-TRY7JGWO.mjs +16 -0
  24. package/lib/chunk-TRY7JGWO.mjs.map +1 -0
  25. package/lib/chunk-W4KR4CSL.mjs +236 -0
  26. package/lib/chunk-W4KR4CSL.mjs.map +1 -0
  27. package/lib/{chunk-AGF3RAAZ.mjs → chunk-WPCBVDFZ.mjs} +2 -2
  28. package/lib/chunk-WQWFVEVX.mjs +66 -0
  29. package/lib/chunk-WQWFVEVX.mjs.map +1 -0
  30. package/lib/{chunk-SYBADQXI.mjs → chunk-ZM4GDHHC.mjs} +77 -2
  31. package/lib/chunk-ZM4GDHHC.mjs.map +1 -0
  32. package/lib/delete-chunk.handler.d.mts +29 -0
  33. package/lib/delete-chunk.handler.d.ts +29 -0
  34. package/lib/delete-chunk.handler.js +2716 -0
  35. package/lib/delete-chunk.handler.js.map +1 -0
  36. package/lib/delete-chunk.handler.mjs +47 -0
  37. package/lib/delete-chunk.handler.mjs.map +1 -0
  38. package/lib/events-CjS-sm0W.d.mts +107 -0
  39. package/lib/events-CjS-sm0W.d.ts +107 -0
  40. package/lib/events-Da_cFgtc.d.mts +208 -0
  41. package/lib/events-Da_cFgtc.d.ts +208 -0
  42. package/lib/finalize.handler.d.mts +35 -0
  43. package/lib/finalize.handler.d.ts +35 -0
  44. package/lib/finalize.handler.js +875 -0
  45. package/lib/finalize.handler.js.map +1 -0
  46. package/lib/finalize.handler.mjs +166 -0
  47. package/lib/finalize.handler.mjs.map +1 -0
  48. package/lib/index.d.mts +189 -2
  49. package/lib/index.d.ts +500 -3
  50. package/lib/index.js +1753 -174
  51. package/lib/index.js.map +1 -1
  52. package/lib/index.mjs +571 -17
  53. package/lib/index.mjs.map +1 -1
  54. package/lib/list-chunks.handler.d.mts +28 -0
  55. package/lib/list-chunks.handler.d.ts +28 -0
  56. package/lib/list-chunks.handler.js +2746 -0
  57. package/lib/list-chunks.handler.js.map +1 -0
  58. package/lib/list-chunks.handler.mjs +54 -0
  59. package/lib/list-chunks.handler.mjs.map +1 -0
  60. package/lib/platform-deploy-bridge.handler.js +76 -1
  61. package/lib/platform-deploy-bridge.handler.js.map +1 -1
  62. package/lib/platform-deploy-bridge.handler.mjs +1 -1
  63. package/lib/pre-token-generation.handler.js +1106 -155
  64. package/lib/pre-token-generation.handler.js.map +1 -1
  65. package/lib/pre-token-generation.handler.mjs +6 -4
  66. package/lib/pre-token-generation.handler.mjs.map +1 -1
  67. package/lib/provision-default-workspace.handler.js +1529 -142
  68. package/lib/provision-default-workspace.handler.js.map +1 -1
  69. package/lib/provision-default-workspace.handler.mjs +8 -4
  70. package/lib/provision-default-workspace.handler.mjs.map +1 -1
  71. package/lib/rename-finalize.handler.d.mts +30 -0
  72. package/lib/rename-finalize.handler.d.ts +30 -0
  73. package/lib/rename-finalize.handler.js +795 -0
  74. package/lib/rename-finalize.handler.js.map +1 -0
  75. package/lib/rename-finalize.handler.mjs +90 -0
  76. package/lib/rename-finalize.handler.mjs.map +1 -0
  77. package/lib/rename-list-targets.handler.d.mts +26 -0
  78. package/lib/rename-list-targets.handler.d.ts +26 -0
  79. package/lib/rename-list-targets.handler.js +2985 -0
  80. package/lib/rename-list-targets.handler.js.map +1 -0
  81. package/lib/rename-list-targets.handler.mjs +431 -0
  82. package/lib/rename-list-targets.handler.mjs.map +1 -0
  83. package/lib/rename-rewrite-chunk.handler.d.mts +35 -0
  84. package/lib/rename-rewrite-chunk.handler.d.ts +35 -0
  85. package/lib/rename-rewrite-chunk.handler.js +2021 -0
  86. package/lib/rename-rewrite-chunk.handler.js.map +1 -0
  87. package/lib/rename-rewrite-chunk.handler.mjs +27 -0
  88. package/lib/rename-rewrite-chunk.handler.mjs.map +1 -0
  89. package/lib/rest-api-lambda.handler.js +4021 -932
  90. package/lib/rest-api-lambda.handler.js.map +1 -1
  91. package/lib/rest-api-lambda.handler.mjs +1786 -80
  92. package/lib/rest-api-lambda.handler.mjs.map +1 -1
  93. package/lib/seed-demo-data.handler.js +1588 -124
  94. package/lib/seed-demo-data.handler.js.map +1 -1
  95. package/lib/seed-demo-data.handler.mjs +10 -6
  96. package/lib/seed-system-data.handler.js +1179 -155
  97. package/lib/seed-system-data.handler.js.map +1 -1
  98. package/lib/seed-system-data.handler.mjs +5 -4
  99. package/lib/seed-system-data.handler.mjs.map +1 -1
  100. package/package.json +3 -3
  101. package/lib/chunk-2TPJ6HOF.mjs.map +0 -1
  102. package/lib/chunk-IS4VQRI4.mjs.map +0 -1
  103. package/lib/chunk-MULKGFIJ.mjs.map +0 -1
  104. package/lib/chunk-QR5JVSCF.mjs +0 -862
  105. package/lib/chunk-QR5JVSCF.mjs.map +0 -1
  106. package/lib/chunk-SYBADQXI.mjs.map +0 -1
  107. /package/lib/{chunk-7FUAMZOF.mjs.map → chunk-53OHXLIL.mjs.map} +0 -0
  108. /package/lib/{chunk-7Q2IJ2J5.mjs.map → chunk-CUUKXDB2.mjs.map} +0 -0
  109. /package/lib/{chunk-AJ3G3THO.mjs.map → chunk-KO64HPWQ.mjs.map} +0 -0
  110. /package/lib/{chunk-BB5MK4L3.mjs.map → chunk-KSFC72TT.mjs.map} +0 -0
  111. /package/lib/{chunk-AGF3RAAZ.mjs.map → chunk-WPCBVDFZ.mjs.map} +0 -0
@@ -24,10 +24,10 @@ __export(provision_default_workspace_handler_exports, {
24
24
  });
25
25
  module.exports = __toCommonJS(provision_default_workspace_handler_exports);
26
26
  var import_node_crypto = require("crypto");
27
- var import_types10 = require("@openhi/types");
27
+ var import_types14 = require("@openhi/types");
28
28
 
29
29
  // src/data/dynamo/dynamo-control-service.ts
30
- var import_electrodb8 = require("electrodb");
30
+ var import_electrodb14 = require("electrodb");
31
31
 
32
32
  // src/data/dynamo/dynamo-client.ts
33
33
  var import_client_dynamodb = require("@aws-sdk/client-dynamodb");
@@ -91,6 +91,60 @@ var gsi1skAttribute = {
91
91
  return label !== void 0 ? `${label}#${id}` : fallback;
92
92
  }
93
93
  };
94
+ function extractRoleId(resource) {
95
+ const flat = resource.roleId;
96
+ if (typeof flat === "string" && flat.length > 0) return flat;
97
+ const role = resource.role;
98
+ if (role && typeof role === "object") {
99
+ const reference = role.reference;
100
+ if (typeof reference === "string" && reference.length > 0) {
101
+ const slash = reference.lastIndexOf("/");
102
+ const tail = slash >= 0 ? reference.slice(slash + 1) : reference;
103
+ if (tail.length > 0) return tail;
104
+ }
105
+ }
106
+ return void 0;
107
+ }
108
+ var roleAssignmentGsi1skAttribute = {
109
+ type: "string",
110
+ watch: ["resource", "denormalizedUserName", "lastUpdated", "id"],
111
+ set: (_val, item) => {
112
+ const id = typeof item?.id === "string" ? item.id : "";
113
+ const lastUpdated = typeof item?.lastUpdated === "string" ? item.lastUpdated : "";
114
+ const fallback = `${lastUpdated}#${id}`;
115
+ if (typeof item?.resource !== "string" || item.resource.length === 0) {
116
+ return fallback;
117
+ }
118
+ let parsed;
119
+ try {
120
+ parsed = JSON.parse(item.resource);
121
+ } catch {
122
+ return fallback;
123
+ }
124
+ if (!parsed || typeof parsed !== "object") return fallback;
125
+ const roleId = extractRoleId(parsed);
126
+ if (roleId === void 0) return fallback;
127
+ const denormalizedUserName = typeof item.denormalizedUserName === "string" ? item.denormalizedUserName : "";
128
+ const normalizedUserName = denormalizedUserName.length > 0 ? (0, import_types.normalizeLabel)(denormalizedUserName) : "";
129
+ if (normalizedUserName.length === 0) return fallback;
130
+ return `${roleId}#${normalizedUserName}#${id}`;
131
+ }
132
+ };
133
+ var membershipGsi1skAttribute = {
134
+ type: "string",
135
+ watch: ["denormalizedUserName", "lastUpdated", "id"],
136
+ set: (_val, item) => {
137
+ const id = typeof item?.id === "string" ? item.id : "";
138
+ const lastUpdated = typeof item?.lastUpdated === "string" ? item.lastUpdated : "";
139
+ const fallback = `${lastUpdated}#${id}`;
140
+ const denormalizedUserName = typeof item?.denormalizedUserName === "string" ? item.denormalizedUserName : "";
141
+ const normalizedUserName = denormalizedUserName.length > 0 ? (0, import_types.normalizeLabel)(denormalizedUserName) : "";
142
+ if (normalizedUserName.length === 0) {
143
+ return fallback;
144
+ }
145
+ return `${normalizedUserName}#${id}`;
146
+ }
147
+ };
94
148
 
95
149
  // src/data/dynamo/entities/control/configuration-entity.ts
96
150
  var ConfigurationEntity = new import_electrodb.Entity({
@@ -217,9 +271,239 @@ var ConfigurationEntity = new import_electrodb.Entity({
217
271
  }
218
272
  });
219
273
 
220
- // src/data/dynamo/entities/control/membership-entity.ts
274
+ // src/data/dynamo/entities/control/configuration-user-projection-entity.ts
221
275
  var import_electrodb2 = require("electrodb");
222
- var MembershipEntity = new import_electrodb2.Entity({
276
+ var ConfigurationUserProjectionEntity = new import_electrodb2.Entity({
277
+ model: {
278
+ entity: "configurationUserProjection",
279
+ service: "control",
280
+ version: "01"
281
+ },
282
+ attributes: {
283
+ /**
284
+ * User partition discriminator. Renders as `USER#ID#<userId>` on the
285
+ * base-table PK. Always required — the projection has no meaning
286
+ * outside a user partition.
287
+ */
288
+ userId: {
289
+ type: "string",
290
+ required: true
291
+ },
292
+ /**
293
+ * Pre-composed sort key — built by the operations-layer projection
294
+ * writer via `buildConfigurationUserProjectionSk`. The entity stores
295
+ * the value verbatim so the SK grammar (pattern #10 user-scope) is
296
+ * owned by the operations layer, not duplicated here.
297
+ */
298
+ sk: {
299
+ type: "string",
300
+ required: true
301
+ },
302
+ /**
303
+ * Configuration canonical-record id. Stored as a discriminating
304
+ * field so consumers can hydrate the canonical row via the
305
+ * Configuration get-by-id operation when the projection's `summary`
306
+ * is insufficient.
307
+ */
308
+ configurationId: {
309
+ type: "string",
310
+ required: true
311
+ },
312
+ /**
313
+ * Tenant the Configuration is associated with. The canonical row
314
+ * keys off `(tenantId, workspaceId, userId, roleId)`; the projection
315
+ * carries `tenantId` so consumers reconstructing the canonical PK
316
+ * have the tenant segment without a hop.
317
+ */
318
+ tenantId: {
319
+ type: "string",
320
+ required: true
321
+ },
322
+ /**
323
+ * Scope marker. Always `"user"` on this projection — recorded
324
+ * explicitly so future scope-bearing projections (workspace,
325
+ * tenant, role) can share filter semantics in a unified
326
+ * cross-projection list query if one ever lands.
327
+ */
328
+ scope: {
329
+ type: "string",
330
+ required: true,
331
+ default: "user"
332
+ },
333
+ /**
334
+ * Configuration's `key` attribute (config category, e.g. endpoints,
335
+ * branding, display). Mirrored from the canonical row so consumers
336
+ * reading the projection get the natural display label without a
337
+ * BatchGet hop. Doubles as the source of `<normalizedConfigName>` in
338
+ * the SK.
339
+ */
340
+ displayName: {
341
+ type: "string",
342
+ required: false
343
+ },
344
+ /**
345
+ * Summary projection (key display fields as JSON string) — mirrored
346
+ * from the canonical Configuration row so user-partition queries do
347
+ * not need a BatchGet hop.
348
+ */
349
+ summary: {
350
+ type: "string",
351
+ required: true
352
+ },
353
+ /** Version id mirrored from the canonical Configuration row. */
354
+ vid: {
355
+ type: "string",
356
+ required: true
357
+ },
358
+ /** Last-updated timestamp mirrored from the canonical Configuration row. */
359
+ lastUpdated: {
360
+ type: "string",
361
+ required: true
362
+ }
363
+ },
364
+ indexes: {
365
+ /**
366
+ * Base table: PK = USER#ID#<userId>, SK = operation-supplied. A
367
+ * single `Query(PK = USER#ID#<userId>, SK begins_with
368
+ * 'CONFIGURATION#')` returns the user's user-scoped Configurations
369
+ * sorted by `<normalizedConfigName>` (then `<configurationId>` as
370
+ * the tiebreaker).
371
+ */
372
+ record: {
373
+ pk: {
374
+ field: "PK",
375
+ composite: ["userId"],
376
+ template: "USER#ID#${userId}"
377
+ },
378
+ sk: {
379
+ field: "SK",
380
+ casing: "none",
381
+ composite: ["sk"],
382
+ template: "${sk}"
383
+ }
384
+ }
385
+ }
386
+ });
387
+
388
+ // src/data/dynamo/entities/control/configuration-workspace-projection-entity.ts
389
+ var import_electrodb3 = require("electrodb");
390
+ var ConfigurationWorkspaceProjectionEntity = new import_electrodb3.Entity({
391
+ model: {
392
+ entity: "configurationWorkspaceProjection",
393
+ service: "control",
394
+ version: "01"
395
+ },
396
+ attributes: {
397
+ /**
398
+ * Tenant the workspace belongs to. Renders as the leading segment
399
+ * of the base-table PK. Always required — the workspace partition
400
+ * is tenant-scoped per ADR-011.
401
+ */
402
+ tenantId: {
403
+ type: "string",
404
+ required: true
405
+ },
406
+ /**
407
+ * Workspace partition discriminator. Renders as the trailing
408
+ * segment of the base-table PK
409
+ * (`TID#<tenantId>#WORKSPACE#ID#<workspaceId>`). Always required —
410
+ * the projection has no meaning outside a workspace partition.
411
+ */
412
+ workspaceId: {
413
+ type: "string",
414
+ required: true
415
+ },
416
+ /**
417
+ * Pre-composed sort key — built by the operations-layer projection
418
+ * writer via `buildConfigurationWorkspaceProjectionSk`. The entity
419
+ * stores the value verbatim so the SK grammar (pattern #10
420
+ * workspace-scope) is owned by the operations layer, not
421
+ * duplicated here.
422
+ */
423
+ sk: {
424
+ type: "string",
425
+ required: true
426
+ },
427
+ /**
428
+ * Configuration canonical-record id. Stored as a discriminating
429
+ * field so consumers can hydrate the canonical row via the
430
+ * Configuration get-by-id operation when the projection's `summary`
431
+ * is insufficient.
432
+ */
433
+ configurationId: {
434
+ type: "string",
435
+ required: true
436
+ },
437
+ /**
438
+ * Scope marker. Always `"workspace"` on this projection — recorded
439
+ * explicitly so future scope-bearing projections (user, tenant,
440
+ * role) can share filter semantics in a unified cross-projection
441
+ * list query if one ever lands.
442
+ */
443
+ scope: {
444
+ type: "string",
445
+ required: true,
446
+ default: "workspace"
447
+ },
448
+ /**
449
+ * Configuration's `key` attribute (config category, e.g. endpoints,
450
+ * branding, display). Mirrored from the canonical row so consumers
451
+ * reading the projection get the natural display label without a
452
+ * BatchGet hop. Doubles as the source of `<normalizedConfigName>`
453
+ * in the SK.
454
+ */
455
+ displayName: {
456
+ type: "string",
457
+ required: false
458
+ },
459
+ /**
460
+ * Summary projection (key display fields as JSON string) — mirrored
461
+ * from the canonical Configuration row so workspace-partition
462
+ * queries do not need a BatchGet hop.
463
+ */
464
+ summary: {
465
+ type: "string",
466
+ required: true
467
+ },
468
+ /** Version id mirrored from the canonical Configuration row. */
469
+ vid: {
470
+ type: "string",
471
+ required: true
472
+ },
473
+ /** Last-updated timestamp mirrored from the canonical Configuration row. */
474
+ lastUpdated: {
475
+ type: "string",
476
+ required: true
477
+ }
478
+ },
479
+ indexes: {
480
+ /**
481
+ * Base table: PK = TID#<tenantId>#WORKSPACE#ID#<workspaceId>,
482
+ * SK = operation-supplied. A single
483
+ * `Query(PK = TID#<tenantId>#WORKSPACE#ID#<workspaceId>, SK begins_with 'CONFIGURATION#')`
484
+ * returns the workspace's workspace-scoped Configurations sorted by
485
+ * `<normalizedConfigName>` (then `<configurationId>` as the
486
+ * tiebreaker).
487
+ */
488
+ record: {
489
+ pk: {
490
+ field: "PK",
491
+ composite: ["tenantId", "workspaceId"],
492
+ template: "TID#${tenantId}#WORKSPACE#ID#${workspaceId}"
493
+ },
494
+ sk: {
495
+ field: "SK",
496
+ casing: "none",
497
+ composite: ["sk"],
498
+ template: "${sk}"
499
+ }
500
+ }
501
+ }
502
+ });
503
+
504
+ // src/data/dynamo/entities/control/membership-entity.ts
505
+ var import_electrodb4 = require("electrodb");
506
+ var MembershipEntity = new import_electrodb4.Entity({
223
507
  model: {
224
508
  entity: "membership",
225
509
  service: "control",
@@ -265,8 +549,14 @@ var MembershipEntity = new import_electrodb2.Entity({
265
549
  required: true
266
550
  },
267
551
  gsi1Shard: gsi1ShardAttribute,
268
- /** Derived GSI1 sort key — name-based when extractable; else `<lastUpdated>#<id>`. */
269
- gsi1sk: gsi1skAttribute,
552
+ /**
553
+ * Derived GSI1 sort key — `<normalizedUserName>#<id>` per ADR-018
554
+ * pattern #1 so a GSI1 query partitioned on the tenant range-scans
555
+ * by user-name prefix and returns memberships sorted by user name.
556
+ * Falls back to `<lastUpdated>#<id>` when `denormalizedUserName`
557
+ * is missing.
558
+ */
559
+ gsi1sk: membershipGsi1skAttribute,
270
560
  deleted: {
271
561
  type: "boolean",
272
562
  required: false
@@ -288,6 +578,36 @@ var MembershipEntity = new import_electrodb2.Entity({
288
578
  linkedDataIdentityRef: {
289
579
  type: "string",
290
580
  required: false
581
+ },
582
+ /**
583
+ * Denormalized display name of the linked Tenant, captured at row
584
+ * last-write time. Promoted to a top-level attribute so the ADR-018
585
+ * adjacency-list projection SKs (pattern #3 — `MEMBERSHIP#TENANT#<normalizedTenantName>#…`)
586
+ * can be composed from a top-level field instead of digging into the
587
+ * `resource` JSON. Optional on the schema so pre-TR-024 rows do not
588
+ * break; the operations-layer multi-write helper (#1010) makes the
589
+ * field load-bearing at write time per TR-024 rule 2 (write-time
590
+ * source = canonical Tenant.displayName).
591
+ * @see TR-024 — Denormalized display-name attributes
592
+ */
593
+ denormalizedTenantName: {
594
+ type: "string",
595
+ required: false
596
+ },
597
+ /**
598
+ * Denormalized display name of the linked User, captured at row
599
+ * last-write time. Promoted to a top-level attribute so the ADR-018
600
+ * adjacency-list canonical-record GSI1SK (pattern #1 —
601
+ * `<normalizedUserName>#<id>`) and workspace-projection SK (pattern #2)
602
+ * can be composed from a top-level field. Optional on the schema so
603
+ * pre-TR-024 rows do not break; the operations-layer multi-write helper
604
+ * (#1010) makes the field load-bearing at write time per TR-024 rule 2
605
+ * (write-time source = canonical User.displayName).
606
+ * @see TR-024 — Denormalized display-name attributes
607
+ */
608
+ denormalizedUserName: {
609
+ type: "string",
610
+ required: false
291
611
  }
292
612
  },
293
613
  indexes: {
@@ -307,9 +627,11 @@ var MembershipEntity = new import_electrodb2.Entity({
307
627
  /**
308
628
  * GSI1 — Unified Sharded List per ADR-011: list all Memberships for a tenant across the
309
629
  * four shards. Membership is tenant-scoped only, so `WID#-` is a sentinel.
310
- * SK is derived via `gsi1skAttribute` — uses the resource's natural label when
311
- * extractable, else `<lastUpdated>#<id>` (DR-004). `casing: "none"` preserves the
312
- * normalized label and ISO-8601 `T`/`Z`.
630
+ * SK is derived via `membershipGsi1skAttribute` — composes
631
+ * `<normalizedUserName>#<id>` per ADR-018 pattern #1 (users in a
632
+ * tenant, sorted by user name); falls back to `<lastUpdated>#<id>`
633
+ * when `denormalizedUserName` is missing. `casing: "none"` preserves
634
+ * the normalized label and ISO-8601 `T`/`Z`.
313
635
  */
314
636
  gsi1: {
315
637
  index: "GSI1",
@@ -328,206 +650,781 @@ var MembershipEntity = new import_electrodb2.Entity({
328
650
  }
329
651
  });
330
652
 
331
- // src/data/dynamo/entities/control/role-entity.ts
332
- var import_electrodb3 = require("electrodb");
333
- var RoleEntity = new import_electrodb3.Entity({
653
+ // src/data/dynamo/entities/control/membership-user-projection-entity.ts
654
+ var import_electrodb5 = require("electrodb");
655
+ var MembershipUserProjectionEntity = new import_electrodb5.Entity({
334
656
  model: {
335
- entity: "role",
657
+ entity: "membershipUserProjection",
336
658
  service: "control",
337
659
  version: "01"
338
660
  },
339
661
  attributes: {
340
- /** Sort key sentinel. Always "CURRENT". */
662
+ /**
663
+ * User partition discriminator. Renders as `USER#ID#<userId>` on the
664
+ * base-table PK. Always required — the projection has no meaning
665
+ * outside a user partition.
666
+ */
667
+ userId: {
668
+ type: "string",
669
+ required: true
670
+ },
671
+ /**
672
+ * Pre-composed sort key — built by the operations-layer projection
673
+ * writer via `buildMembershipUserProjectionSk*` helpers. The entity
674
+ * stores the value verbatim so the SK grammar (patterns #3 and #4)
675
+ * is owned by the operations layer, not duplicated here.
676
+ */
341
677
  sk: {
342
678
  type: "string",
343
- required: true,
344
- default: "CURRENT"
679
+ required: true
345
680
  },
346
- /** FHIR Resource.id; role id. */
347
- id: {
681
+ /** Tenant in which the membership applies. Always required. */
682
+ tenantId: {
348
683
  type: "string",
349
684
  required: true
350
685
  },
351
- /** Full Role resource serialized as JSON string. */
352
- resource: {
686
+ /**
687
+ * Workspace the membership scopes to. Present iff the projection
688
+ * row is a pattern-#4 workspace sub-lane row; absent for pattern-#3
689
+ * tenant sub-lane rows.
690
+ */
691
+ workspaceId: {
692
+ type: "string",
693
+ required: false
694
+ },
695
+ /**
696
+ * Membership canonical-record id. Stored as a discriminating field
697
+ * so consumers can hydrate the canonical row via
698
+ * `MembershipEntity.get({ tenantId, id: membershipId })` when the
699
+ * projection's `summary` is insufficient.
700
+ */
701
+ membershipId: {
353
702
  type: "string",
354
703
  required: true
355
704
  },
356
705
  /**
357
- * Summary projection (key display fields as JSON string: id, displayName, status).
358
- * Populated on every write via extractSummary(resource); GSI1 INCLUDE surfaces it on lists.
706
+ * Summary projection (key display fields as JSON string: id,
707
+ * displayName, status) mirrored from the canonical Membership row
708
+ * so user-partition queries do not need a BatchGet hop.
359
709
  */
360
710
  summary: {
361
711
  type: "string",
362
712
  required: true
363
713
  },
364
- /** Version id (e.g. ULID). */
714
+ /** Version id mirrored from the canonical Membership row. */
365
715
  vid: {
366
716
  type: "string",
367
717
  required: true
368
718
  },
719
+ /** Last-updated timestamp mirrored from the canonical Membership row. */
369
720
  lastUpdated: {
370
721
  type: "string",
371
722
  required: true
372
723
  },
373
- gsi1Shard: gsi1ShardAttribute,
374
- /** Derived GSI1 sort keyname-based when extractable; else `<lastUpdated>#<id>`. */
375
- gsi1sk: gsi1skAttribute,
376
- deleted: {
377
- type: "boolean",
724
+ /**
725
+ * Denormalized Tenant display namerequired to compose pattern-#3
726
+ * SK (`MEMBERSHIP#TENANT#<normalizedTenantName>#…`). Optional on the
727
+ * schema because pre-TR-024 rows may not carry a display name; the
728
+ * operations layer falls back gracefully when missing.
729
+ */
730
+ denormalizedTenantName: {
731
+ type: "string",
378
732
  required: false
379
733
  },
380
- bundleId: {
734
+ /**
735
+ * Denormalized User display name — mirrored from the canonical
736
+ * Membership row per TR-024 rule 3 (canonical-record symmetry).
737
+ * Carried on the projection so consumers can render the user's
738
+ * display name without a hop to the User record.
739
+ */
740
+ denormalizedUserName: {
381
741
  type: "string",
382
742
  required: false
383
743
  },
384
- msgId: {
744
+ /**
745
+ * Denormalized Workspace display name — required to compose
746
+ * pattern-#4 SK (`MEMBERSHIP#WORKSPACE#TID#<tenantId>#<normalizedWorkspaceName>#…`).
747
+ * Optional on the schema (TR-024 § Open Item #4 defers a formal
748
+ * Workspace-rename cascade); the operations layer falls back to a
749
+ * sentinel when missing so the SK still has a valid shape.
750
+ */
751
+ denormalizedWorkspaceName: {
385
752
  type: "string",
386
753
  required: false
387
754
  }
388
755
  },
389
756
  indexes: {
390
- /** Base table: PK = ROLE#ID#<id>, SK = CURRENT. Do not supply PK or SK from outside. */
757
+ /**
758
+ * Base table: PK = USER#ID#<userId>, SK = operation-supplied.
759
+ * Both pattern #3 and pattern #4 use this same index — the SK string
760
+ * encodes the lane discriminator (`MEMBERSHIP#TENANT#…` vs
761
+ * `MEMBERSHIP#WORKSPACE#…`) so a single `Query(PK = USER#ID#<userId>,
762
+ * SK begins_with 'MEMBERSHIP#')` returns both lanes interleaved.
763
+ */
764
+ record: {
765
+ pk: {
766
+ field: "PK",
767
+ composite: ["userId"],
768
+ template: "USER#ID#${userId}"
769
+ },
770
+ sk: {
771
+ field: "SK",
772
+ casing: "none",
773
+ composite: ["sk"],
774
+ template: "${sk}"
775
+ }
776
+ }
777
+ }
778
+ });
779
+
780
+ // src/data/dynamo/entities/control/membership-workspace-projection-entity.ts
781
+ var import_electrodb6 = require("electrodb");
782
+ var MembershipWorkspaceProjectionEntity = new import_electrodb6.Entity({
783
+ model: {
784
+ entity: "membershipWorkspaceProjection",
785
+ service: "control",
786
+ version: "01"
787
+ },
788
+ attributes: {
789
+ /**
790
+ * Tenant the workspace belongs to. Renders as the leading segment
791
+ * of the base-table PK. Always required — the workspace partition
792
+ * is tenant-scoped per ADR-011.
793
+ */
794
+ tenantId: {
795
+ type: "string",
796
+ required: true
797
+ },
798
+ /**
799
+ * Workspace partition discriminator. Renders as the trailing
800
+ * segment of the base-table PK
801
+ * (`TID#<tenantId>#WORKSPACE#ID#<workspaceId>`). Always required —
802
+ * the projection has no meaning outside a workspace partition.
803
+ */
804
+ workspaceId: {
805
+ type: "string",
806
+ required: true
807
+ },
808
+ /**
809
+ * Pre-composed sort key — built by the operations-layer projection
810
+ * writer via `buildMembershipWorkspaceProjectionSk`. The entity
811
+ * stores the value verbatim so the SK grammar (pattern #2) is
812
+ * owned by the operations layer, not duplicated here.
813
+ */
814
+ sk: {
815
+ type: "string",
816
+ required: true
817
+ },
818
+ /**
819
+ * User the membership links. Stored as a discriminating field so
820
+ * consumers can hydrate the canonical User row via
821
+ * `UserEntity.get({ id: userId, sk: "CURRENT" })` when the
822
+ * projection's `summary` is insufficient.
823
+ */
824
+ userId: {
825
+ type: "string",
826
+ required: true
827
+ },
828
+ /**
829
+ * Membership canonical-record id. Stored as a discriminating field
830
+ * so consumers can hydrate the canonical row via
831
+ * `MembershipEntity.get({ tenantId, id: membershipId })` when the
832
+ * projection's `summary` is insufficient.
833
+ */
834
+ membershipId: {
835
+ type: "string",
836
+ required: true
837
+ },
838
+ /**
839
+ * Summary projection (key display fields as JSON string: id,
840
+ * displayName, status) — mirrored from the canonical Membership row
841
+ * so workspace-partition queries do not need a BatchGet hop.
842
+ */
843
+ summary: {
844
+ type: "string",
845
+ required: true
846
+ },
847
+ /** Version id mirrored from the canonical Membership row. */
848
+ vid: {
849
+ type: "string",
850
+ required: true
851
+ },
852
+ /** Last-updated timestamp mirrored from the canonical Membership row. */
853
+ lastUpdated: {
854
+ type: "string",
855
+ required: true
856
+ },
857
+ /**
858
+ * Denormalized User display name — required to compose the
859
+ * pattern-#2 SK (`MEMBERSHIP#<normalizedUserName>#…`). Optional on
860
+ * the schema because pre-TR-024 rows may not carry a display name;
861
+ * the operations layer falls back to a sentinel when missing so
862
+ * the SK still has a valid shape. The TR-023 rename-cascade
863
+ * pipeline rewrites the SK on a User rename.
864
+ */
865
+ denormalizedUserName: {
866
+ type: "string",
867
+ required: false
868
+ }
869
+ },
870
+ indexes: {
871
+ /**
872
+ * Base table: PK = TID#<tenantId>#WORKSPACE#ID#<workspaceId>,
873
+ * SK = operation-supplied. Pattern #2 uses this index — the SK
874
+ * encodes the entity-type prefix (`MEMBERSHIP#…`) so a
875
+ * `Query(PK = TID#<tenantId>#WORKSPACE#ID#<workspaceId>, SK begins_with 'MEMBERSHIP#')`
876
+ * returns every member projection for the workspace in normalized-
877
+ * user-name sort order.
878
+ */
879
+ record: {
880
+ pk: {
881
+ field: "PK",
882
+ composite: ["tenantId", "workspaceId"],
883
+ template: "TID#${tenantId}#WORKSPACE#ID#${workspaceId}"
884
+ },
885
+ sk: {
886
+ field: "SK",
887
+ casing: "none",
888
+ composite: ["sk"],
889
+ template: "${sk}"
890
+ }
891
+ }
892
+ }
893
+ });
894
+
895
+ // src/data/dynamo/entities/control/role-entity.ts
896
+ var import_electrodb7 = require("electrodb");
897
+ var RoleEntity = new import_electrodb7.Entity({
898
+ model: {
899
+ entity: "role",
900
+ service: "control",
901
+ version: "01"
902
+ },
903
+ attributes: {
904
+ /** Sort key sentinel. Always "CURRENT". */
905
+ sk: {
906
+ type: "string",
907
+ required: true,
908
+ default: "CURRENT"
909
+ },
910
+ /** FHIR Resource.id; role id. */
911
+ id: {
912
+ type: "string",
913
+ required: true
914
+ },
915
+ /** Full Role resource serialized as JSON string. */
916
+ resource: {
917
+ type: "string",
918
+ required: true
919
+ },
920
+ /**
921
+ * Summary projection (key display fields as JSON string: id, displayName, status).
922
+ * Populated on every write via extractSummary(resource); GSI1 INCLUDE surfaces it on lists.
923
+ */
924
+ summary: {
925
+ type: "string",
926
+ required: true
927
+ },
928
+ /** Version id (e.g. ULID). */
929
+ vid: {
930
+ type: "string",
931
+ required: true
932
+ },
933
+ lastUpdated: {
934
+ type: "string",
935
+ required: true
936
+ },
937
+ gsi1Shard: gsi1ShardAttribute,
938
+ /** Derived GSI1 sort key — name-based when extractable; else `<lastUpdated>#<id>`. */
939
+ gsi1sk: gsi1skAttribute,
940
+ deleted: {
941
+ type: "boolean",
942
+ required: false
943
+ },
944
+ bundleId: {
945
+ type: "string",
946
+ required: false
947
+ },
948
+ msgId: {
949
+ type: "string",
950
+ required: false
951
+ }
952
+ },
953
+ indexes: {
954
+ /** Base table: PK = ROLE#ID#<id>, SK = CURRENT. Do not supply PK or SK from outside. */
955
+ record: {
956
+ pk: {
957
+ field: "PK",
958
+ composite: ["id"],
959
+ template: "ROLE#ID#${id}"
960
+ },
961
+ sk: {
962
+ field: "SK",
963
+ composite: ["sk"],
964
+ template: "${sk}"
965
+ }
966
+ },
967
+ /**
968
+ * GSI1 — Unified Sharded List per ADR-011: list all Roles across the four shards.
969
+ * Non-tenant-isolated, so `TID#-#WID#-` sentinels precede `RT#Role#SHARD#<n>`.
970
+ * SK is derived via `gsi1skAttribute` — uses the resource's natural label when
971
+ * extractable, else `<lastUpdated>#<id>` (DR-004). `casing: "none"` preserves the
972
+ * normalized label and ISO-8601 `T`/`Z`.
973
+ */
974
+ gsi1: {
975
+ index: "GSI1",
976
+ pk: {
977
+ field: "GSI1PK",
978
+ composite: ["gsi1Shard"],
979
+ template: "TID#-#WID#-#RT#Role#SHARD#${gsi1Shard}"
980
+ },
981
+ sk: {
982
+ field: "GSI1SK",
983
+ casing: "none",
984
+ composite: ["gsi1sk"],
985
+ template: "${gsi1sk}"
986
+ }
987
+ }
988
+ }
989
+ });
990
+
991
+ // src/data/dynamo/entities/control/roleassignment-entity.ts
992
+ var import_electrodb8 = require("electrodb");
993
+ var RoleAssignmentEntity = new import_electrodb8.Entity({
994
+ model: {
995
+ entity: "roleassignment",
996
+ service: "control",
997
+ version: "01"
998
+ },
999
+ attributes: {
1000
+ /** Sort key sentinel. Always "CURRENT". */
1001
+ sk: {
1002
+ type: "string",
1003
+ required: true,
1004
+ default: "CURRENT"
1005
+ },
1006
+ /** Tenant in which the role assignment applies (required). */
1007
+ tenantId: {
1008
+ type: "string",
1009
+ required: true
1010
+ },
1011
+ /** FHIR Resource.id; role assignment id. */
1012
+ id: {
1013
+ type: "string",
1014
+ required: true
1015
+ },
1016
+ /** Full RoleAssignment resource serialized as JSON string. */
1017
+ resource: {
1018
+ type: "string",
1019
+ required: true
1020
+ },
1021
+ /**
1022
+ * Summary projection (key display fields as JSON string: id, displayName, status).
1023
+ * Populated on every write via extractSummary(resource); GSI1 INCLUDE surfaces it on lists.
1024
+ */
1025
+ summary: {
1026
+ type: "string",
1027
+ required: true
1028
+ },
1029
+ /** Version id (e.g. ULID). */
1030
+ vid: {
1031
+ type: "string",
1032
+ required: true
1033
+ },
1034
+ lastUpdated: {
1035
+ type: "string",
1036
+ required: true
1037
+ },
1038
+ gsi1Shard: gsi1ShardAttribute,
1039
+ /**
1040
+ * Derived GSI1 sort key — discriminator-first
1041
+ * `<roleId>#<normalizedUserName>#<id>` per ADR-018 pattern #8 so a
1042
+ * GSI1 query partitioned on the tenant can `begins_with('<roleId>#')`
1043
+ * to enumerate every user assigned to a given role, sorted by user
1044
+ * name. Falls back to `<lastUpdated>#<id>` when either component is
1045
+ * missing.
1046
+ */
1047
+ gsi1sk: roleAssignmentGsi1skAttribute,
1048
+ deleted: {
1049
+ type: "boolean",
1050
+ required: false
1051
+ },
1052
+ bundleId: {
1053
+ type: "string",
1054
+ required: false
1055
+ },
1056
+ msgId: {
1057
+ type: "string",
1058
+ required: false
1059
+ },
1060
+ /**
1061
+ * Denormalized display name of the linked Tenant, captured at row
1062
+ * last-write time. Promoted to a top-level attribute so the ADR-018
1063
+ * adjacency-list user-projection SK (pattern #5 —
1064
+ * `ROLEASSIGNMENT#TENANT#<normalizedRoleName>#<roleId>#TID#<tenantId>#<id>`)
1065
+ * can be composed from a top-level field instead of digging into the
1066
+ * `resource` JSON. Optional on the schema so pre-TR-024 rows do not
1067
+ * break; the operations-layer multi-write helper (#1010) makes the
1068
+ * field load-bearing at write time per TR-024 rule 2 (write-time
1069
+ * source = canonical Tenant.displayName).
1070
+ * @see TR-024 — Denormalized display-name attributes
1071
+ */
1072
+ denormalizedTenantName: {
1073
+ type: "string",
1074
+ required: false
1075
+ },
1076
+ /**
1077
+ * Denormalized display name of the linked User, captured at row
1078
+ * last-write time. Promoted to a top-level attribute so the ADR-018
1079
+ * adjacency-list canonical-record GSI1SK (pattern #8 —
1080
+ * `<roleId>#<normalizedUserName>#<id>`) and workspace-projection SK
1081
+ * (pattern #9) can be composed from a top-level field. Optional on
1082
+ * the schema so pre-TR-024 rows do not break; the operations-layer
1083
+ * multi-write helper (#1010) makes the field load-bearing at write
1084
+ * time per TR-024 rule 2 (write-time source = canonical
1085
+ * User.displayName).
1086
+ * @see TR-024 — Denormalized display-name attributes
1087
+ */
1088
+ denormalizedUserName: {
1089
+ type: "string",
1090
+ required: false
1091
+ },
1092
+ /**
1093
+ * Denormalized display name of the linked Role, captured at row
1094
+ * last-write time. Promoted to a top-level attribute so the ADR-018
1095
+ * adjacency-list user-projection SK (pattern #5 —
1096
+ * `ROLEASSIGNMENT#TENANT#<normalizedRoleName>#…`) can be composed from
1097
+ * a top-level field. Optional on the schema so pre-TR-024 rows do not
1098
+ * break; the operations-layer multi-write helper (#1010) makes the
1099
+ * field load-bearing at write time per TR-024 rule 2 (write-time
1100
+ * source = canonical Role.displayName).
1101
+ * @see TR-024 — Denormalized display-name attributes
1102
+ */
1103
+ denormalizedRoleName: {
1104
+ type: "string",
1105
+ required: false
1106
+ }
1107
+ },
1108
+ indexes: {
1109
+ /** Base table: PK = TID#<tenantId>#ROLEASSIGNMENT#ID#<id>, SK = CURRENT. Do not supply PK or SK from outside. */
1110
+ record: {
1111
+ pk: {
1112
+ field: "PK",
1113
+ composite: ["tenantId", "id"],
1114
+ template: "TID#${tenantId}#ROLEASSIGNMENT#ID#${id}"
1115
+ },
1116
+ sk: {
1117
+ field: "SK",
1118
+ composite: ["sk"],
1119
+ template: "${sk}"
1120
+ }
1121
+ },
1122
+ /**
1123
+ * GSI1 — Unified Sharded List per ADR-011: list all RoleAssignments for a tenant across the
1124
+ * four shards. Tenant-scoped only, so `WID#-` is a sentinel.
1125
+ * SK is derived via `roleAssignmentGsi1skAttribute` — composes the
1126
+ * discriminator-first `<roleId>#<normalizedUserName>#<id>` shape per
1127
+ * ADR-018 pattern #8 (users with a specific role in a tenant, sorted
1128
+ * by user name); falls back to `<lastUpdated>#<id>` when either
1129
+ * component is missing. `casing: "none"` preserves the normalized
1130
+ * label and ISO-8601 `T`/`Z`.
1131
+ */
1132
+ gsi1: {
1133
+ index: "GSI1",
1134
+ pk: {
1135
+ field: "GSI1PK",
1136
+ composite: ["tenantId", "gsi1Shard"],
1137
+ template: "TID#${tenantId}#WID#-#RT#RoleAssignment#SHARD#${gsi1Shard}"
1138
+ },
1139
+ sk: {
1140
+ field: "GSI1SK",
1141
+ casing: "none",
1142
+ composite: ["gsi1sk"],
1143
+ template: "${gsi1sk}"
1144
+ }
1145
+ }
1146
+ }
1147
+ });
1148
+
1149
+ // src/data/dynamo/entities/control/roleassignment-user-projection-entity.ts
1150
+ var import_electrodb9 = require("electrodb");
1151
+ var RoleAssignmentUserProjectionEntity = new import_electrodb9.Entity({
1152
+ model: {
1153
+ entity: "roleAssignmentUserProjection",
1154
+ service: "control",
1155
+ version: "01"
1156
+ },
1157
+ attributes: {
1158
+ /**
1159
+ * User partition discriminator. Renders as `USER#ID#<userId>` on the
1160
+ * base-table PK. Always required — the projection has no meaning
1161
+ * outside a user partition.
1162
+ */
1163
+ userId: {
1164
+ type: "string",
1165
+ required: true
1166
+ },
1167
+ /**
1168
+ * Pre-composed sort key — built by the operations-layer projection
1169
+ * writer via `buildRoleAssignmentUserProjectionSk*` helpers. The
1170
+ * entity stores the value verbatim so the SK grammar (tenant-lane
1171
+ * vs workspace-lane) is owned by the operations layer, not
1172
+ * duplicated here.
1173
+ */
1174
+ sk: {
1175
+ type: "string",
1176
+ required: true
1177
+ },
1178
+ /** Tenant in which the role assignment applies. Always required. */
1179
+ tenantId: {
1180
+ type: "string",
1181
+ required: true
1182
+ },
1183
+ /**
1184
+ * Workspace the role assignment scopes to. Present iff the
1185
+ * projection row is the workspace-level sub-lane; absent for
1186
+ * tenant-level sub-lane rows.
1187
+ */
1188
+ workspaceId: {
1189
+ type: "string",
1190
+ required: false
1191
+ },
1192
+ /**
1193
+ * Role the assignment grants. Stored as a discriminating field so
1194
+ * `Query(PK = USER#ID#<userId>, SK begins_with 'ROLEASSIGNMENT#…')`
1195
+ * results carry the role id without a hop to the canonical row.
1196
+ */
1197
+ roleId: {
1198
+ type: "string",
1199
+ required: true
1200
+ },
1201
+ /**
1202
+ * RoleAssignment canonical-record id. Stored as a discriminating
1203
+ * field so consumers can hydrate the canonical row via
1204
+ * `RoleAssignmentEntity.get({ tenantId, id: roleAssignmentId })`
1205
+ * when the projection's `summary` is insufficient.
1206
+ */
1207
+ roleAssignmentId: {
1208
+ type: "string",
1209
+ required: true
1210
+ },
1211
+ /**
1212
+ * Summary projection (key display fields as JSON string: id,
1213
+ * displayName, status) — mirrored from the canonical RoleAssignment
1214
+ * row so user-partition queries do not need a BatchGet hop.
1215
+ */
1216
+ summary: {
1217
+ type: "string",
1218
+ required: true
1219
+ },
1220
+ /** Version id mirrored from the canonical RoleAssignment row. */
1221
+ vid: {
1222
+ type: "string",
1223
+ required: true
1224
+ },
1225
+ /** Last-updated timestamp mirrored from the canonical RoleAssignment row. */
1226
+ lastUpdated: {
1227
+ type: "string",
1228
+ required: true
1229
+ },
1230
+ /**
1231
+ * Denormalized Tenant display name — mirrored from the canonical
1232
+ * RoleAssignment row per TR-024 rule 3 (canonical-record symmetry).
1233
+ * Optional on the schema because pre-TR-024 rows may not carry a
1234
+ * display name; the operations layer falls back gracefully when
1235
+ * missing.
1236
+ */
1237
+ denormalizedTenantName: {
1238
+ type: "string",
1239
+ required: false
1240
+ },
1241
+ /**
1242
+ * Denormalized User display name — mirrored from the canonical
1243
+ * RoleAssignment row per TR-024 rule 3 (canonical-record symmetry).
1244
+ * Carried on the projection so consumers can render the user's
1245
+ * display name without a hop to the User record.
1246
+ */
1247
+ denormalizedUserName: {
1248
+ type: "string",
1249
+ required: false
1250
+ },
1251
+ /**
1252
+ * Denormalized Role display name — required to compose the SK's
1253
+ * `<normalizedRoleName>` segment. Optional on the schema (pre-TR-024
1254
+ * rows fall back to a sentinel) but expected to be present at write
1255
+ * time per TR-024 rule 2 (write-time source =
1256
+ * canonical Role.displayName).
1257
+ */
1258
+ denormalizedRoleName: {
1259
+ type: "string",
1260
+ required: false
1261
+ }
1262
+ },
1263
+ indexes: {
1264
+ /**
1265
+ * Base table: PK = USER#ID#<userId>, SK = operation-supplied. Both
1266
+ * sub-lanes (tenant-level and workspace-level) use this same index —
1267
+ * the SK string encodes the lane discriminator
1268
+ * (`ROLEASSIGNMENT#TENANT#…` vs `ROLEASSIGNMENT#WORKSPACE#…`) so a
1269
+ * single `Query(PK = USER#ID#<userId>, SK begins_with
1270
+ * 'ROLEASSIGNMENT#')` returns both lanes interleaved.
1271
+ */
391
1272
  record: {
392
1273
  pk: {
393
1274
  field: "PK",
394
- composite: ["id"],
395
- template: "ROLE#ID#${id}"
1275
+ composite: ["userId"],
1276
+ template: "USER#ID#${userId}"
396
1277
  },
397
1278
  sk: {
398
1279
  field: "SK",
1280
+ casing: "none",
399
1281
  composite: ["sk"],
400
1282
  template: "${sk}"
401
1283
  }
402
- },
403
- /**
404
- * GSI1 — Unified Sharded List per ADR-011: list all Roles across the four shards.
405
- * Non-tenant-isolated, so `TID#-#WID#-` sentinels precede `RT#Role#SHARD#<n>`.
406
- * SK is derived via `gsi1skAttribute` — uses the resource's natural label when
407
- * extractable, else `<lastUpdated>#<id>` (DR-004). `casing: "none"` preserves the
408
- * normalized label and ISO-8601 `T`/`Z`.
409
- */
410
- gsi1: {
411
- index: "GSI1",
412
- pk: {
413
- field: "GSI1PK",
414
- composite: ["gsi1Shard"],
415
- template: "TID#-#WID#-#RT#Role#SHARD#${gsi1Shard}"
416
- },
417
- sk: {
418
- field: "GSI1SK",
419
- casing: "none",
420
- composite: ["gsi1sk"],
421
- template: "${gsi1sk}"
422
- }
423
1284
  }
424
1285
  }
425
1286
  });
426
1287
 
427
- // src/data/dynamo/entities/control/roleassignment-entity.ts
428
- var import_electrodb4 = require("electrodb");
429
- var RoleAssignmentEntity = new import_electrodb4.Entity({
1288
+ // src/data/dynamo/entities/control/roleassignment-workspace-projection-entity.ts
1289
+ var import_electrodb10 = require("electrodb");
1290
+ var RoleAssignmentWorkspaceProjectionEntity = new import_electrodb10.Entity({
430
1291
  model: {
431
- entity: "roleassignment",
1292
+ entity: "roleAssignmentWorkspaceProjection",
432
1293
  service: "control",
433
1294
  version: "01"
434
1295
  },
435
1296
  attributes: {
436
- /** Sort key sentinel. Always "CURRENT". */
1297
+ /**
1298
+ * Tenant the workspace belongs to. Renders as the leading segment
1299
+ * of the base-table PK. Always required — the workspace partition
1300
+ * is tenant-scoped per ADR-011.
1301
+ */
1302
+ tenantId: {
1303
+ type: "string",
1304
+ required: true
1305
+ },
1306
+ /**
1307
+ * Workspace partition discriminator. Renders as the trailing
1308
+ * segment of the base-table PK
1309
+ * (`TID#<tenantId>#WORKSPACE#ID#<workspaceId>`). Always required —
1310
+ * the projection has no meaning outside a workspace partition.
1311
+ */
1312
+ workspaceId: {
1313
+ type: "string",
1314
+ required: true
1315
+ },
1316
+ /**
1317
+ * Pre-composed sort key — built by the operations-layer projection
1318
+ * writer via `buildRoleAssignmentWorkspaceProjectionSk`. The entity
1319
+ * stores the value verbatim so the SK grammar (pattern #9) is
1320
+ * owned by the operations layer, not duplicated here.
1321
+ */
437
1322
  sk: {
438
1323
  type: "string",
439
- required: true,
440
- default: "CURRENT"
1324
+ required: true
441
1325
  },
442
- /** Tenant in which the role assignment applies (required). */
443
- tenantId: {
1326
+ /**
1327
+ * User the role assignment grants the role to. Stored as a
1328
+ * discriminating field so consumers can hydrate the canonical User
1329
+ * row via `UserEntity.get({ id: userId, sk: "CURRENT" })` when the
1330
+ * projection's `summary` is insufficient.
1331
+ */
1332
+ userId: {
444
1333
  type: "string",
445
1334
  required: true
446
1335
  },
447
- /** FHIR Resource.id; role assignment id. */
448
- id: {
1336
+ /**
1337
+ * Role the assignment grants. Stored as a discriminating field —
1338
+ * also rendered into the SK as the discriminator-first segment so
1339
+ * `begins_with('ROLEASSIGNMENT#<roleId>#')` filters one role.
1340
+ */
1341
+ roleId: {
449
1342
  type: "string",
450
1343
  required: true
451
1344
  },
452
- /** Full RoleAssignment resource serialized as JSON string. */
453
- resource: {
1345
+ /**
1346
+ * RoleAssignment canonical-record id. Stored as a discriminating
1347
+ * field so consumers can hydrate the canonical row via
1348
+ * `RoleAssignmentEntity.get({ tenantId, id: roleAssignmentId })`
1349
+ * when the projection's `summary` is insufficient.
1350
+ */
1351
+ roleAssignmentId: {
454
1352
  type: "string",
455
1353
  required: true
456
1354
  },
457
1355
  /**
458
- * Summary projection (key display fields as JSON string: id, displayName, status).
459
- * Populated on every write via extractSummary(resource); GSI1 INCLUDE surfaces it on lists.
1356
+ * Summary projection (key display fields as JSON string: id,
1357
+ * displayName, status) mirrored from the canonical RoleAssignment
1358
+ * row so workspace-partition queries do not need a BatchGet hop.
460
1359
  */
461
1360
  summary: {
462
1361
  type: "string",
463
1362
  required: true
464
1363
  },
465
- /** Version id (e.g. ULID). */
1364
+ /** Version id mirrored from the canonical RoleAssignment row. */
466
1365
  vid: {
467
1366
  type: "string",
468
1367
  required: true
469
1368
  },
1369
+ /** Last-updated timestamp mirrored from the canonical RoleAssignment row. */
470
1370
  lastUpdated: {
471
1371
  type: "string",
472
1372
  required: true
473
1373
  },
474
- gsi1Shard: gsi1ShardAttribute,
475
- /** Derived GSI1 sort keyname-based when extractable; else `<lastUpdated>#<id>`. */
476
- gsi1sk: gsi1skAttribute,
477
- deleted: {
478
- type: "boolean",
479
- required: false
480
- },
481
- bundleId: {
1374
+ /**
1375
+ * Denormalized User display namerequired to compose the
1376
+ * pattern-#9 SK (`ROLEASSIGNMENT#<roleId>#<normalizedUserName>#…`).
1377
+ * Optional on the schema because pre-TR-024 rows may not carry a
1378
+ * display name; the operations layer falls back to a sentinel when
1379
+ * missing so the SK still has a valid shape. The TR-023 rename-
1380
+ * cascade pipeline rewrites the SK on a User rename.
1381
+ */
1382
+ denormalizedUserName: {
482
1383
  type: "string",
483
1384
  required: false
484
1385
  },
485
- msgId: {
1386
+ /**
1387
+ * Denormalized Role display name — mirrored from the canonical
1388
+ * RoleAssignment row per TR-024 rule 3 (canonical-record symmetry).
1389
+ * Carried on the projection so consumers can render the role's
1390
+ * display name without a hop to the Role record. Not part of the
1391
+ * SK (pattern #9 sorts on `<normalizedUserName>`, not role name) —
1392
+ * a Role rename does NOT rewrite this SK.
1393
+ */
1394
+ denormalizedRoleName: {
486
1395
  type: "string",
487
1396
  required: false
488
1397
  }
489
1398
  },
490
1399
  indexes: {
491
- /** Base table: PK = TID#<tenantId>#ROLEASSIGNMENT#ID#<id>, SK = CURRENT. Do not supply PK or SK from outside. */
1400
+ /**
1401
+ * Base table: PK = TID#<tenantId>#WORKSPACE#ID#<workspaceId>,
1402
+ * SK = operation-supplied. Pattern #9 uses this index — the SK
1403
+ * encodes the entity-type prefix and discriminator-first roleId
1404
+ * (`ROLEASSIGNMENT#<roleId>#…`) so
1405
+ * `Query(PK = TID#<tenantId>#WORKSPACE#ID#<workspaceId>, SK begins_with 'ROLEASSIGNMENT#<roleId>#')`
1406
+ * returns every user-assignment for that role in the workspace, sorted
1407
+ * by normalized user name.
1408
+ */
492
1409
  record: {
493
1410
  pk: {
494
1411
  field: "PK",
495
- composite: ["tenantId", "id"],
496
- template: "TID#${tenantId}#ROLEASSIGNMENT#ID#${id}"
1412
+ composite: ["tenantId", "workspaceId"],
1413
+ template: "TID#${tenantId}#WORKSPACE#ID#${workspaceId}"
497
1414
  },
498
1415
  sk: {
499
1416
  field: "SK",
1417
+ casing: "none",
500
1418
  composite: ["sk"],
501
1419
  template: "${sk}"
502
1420
  }
503
- },
504
- /**
505
- * GSI1 — Unified Sharded List per ADR-011: list all RoleAssignments for a tenant across the
506
- * four shards. Tenant-scoped only, so `WID#-` is a sentinel.
507
- * SK is derived via `gsi1skAttribute` — uses the resource's natural label when
508
- * extractable, else `<lastUpdated>#<id>` (DR-004). `casing: "none"` preserves the
509
- * normalized label and ISO-8601 `T`/`Z`.
510
- */
511
- gsi1: {
512
- index: "GSI1",
513
- pk: {
514
- field: "GSI1PK",
515
- composite: ["tenantId", "gsi1Shard"],
516
- template: "TID#${tenantId}#WID#-#RT#RoleAssignment#SHARD#${gsi1Shard}"
517
- },
518
- sk: {
519
- field: "GSI1SK",
520
- casing: "none",
521
- composite: ["gsi1sk"],
522
- template: "${gsi1sk}"
523
- }
524
1421
  }
525
1422
  }
526
1423
  });
527
1424
 
528
1425
  // src/data/dynamo/entities/control/tenant-entity.ts
529
- var import_electrodb5 = require("electrodb");
530
- var TenantEntity = new import_electrodb5.Entity({
1426
+ var import_electrodb11 = require("electrodb");
1427
+ var TenantEntity = new import_electrodb11.Entity({
531
1428
  model: {
532
1429
  entity: "tenant",
533
1430
  service: "control",
@@ -627,8 +1524,8 @@ var TenantEntity = new import_electrodb5.Entity({
627
1524
  });
628
1525
 
629
1526
  // src/data/dynamo/entities/control/user-entity.ts
630
- var import_electrodb6 = require("electrodb");
631
- var UserEntity = new import_electrodb6.Entity({
1527
+ var import_electrodb12 = require("electrodb");
1528
+ var UserEntity = new import_electrodb12.Entity({
632
1529
  model: {
633
1530
  entity: "user",
634
1531
  service: "control",
@@ -683,6 +1580,28 @@ var UserEntity = new import_electrodb6.Entity({
683
1580
  type: "boolean",
684
1581
  required: false
685
1582
  },
1583
+ /**
1584
+ * TR-022 / ADR-018 lifecycle state for the cascade pipeline.
1585
+ *
1586
+ * - `active` (or undefined) — normal, readable state.
1587
+ * - `deleting` — intermediate state set synchronously by the
1588
+ * hard-delete API entry point. The owning-delete cascade state
1589
+ * machine fans out from this transition (DynamoDB stream →
1590
+ * `control-plane.owning-delete.v1` → Step Functions). Readers MUST
1591
+ * short-circuit on `deleting` so partial cascades stay invisible.
1592
+ * - `deleted-failed` — terminal failure state set by the cascade
1593
+ * finalize Lambda when the cascade run fails irrecoverably.
1594
+ * Operators recover by re-running the cascade or by direct
1595
+ * intervention.
1596
+ *
1597
+ * The cascade finalize step deletes the canonical record conditional
1598
+ * on `lifecycleState = "deleting"`; on replay the conditional check
1599
+ * fails and the finalize step treats that as a no-op success.
1600
+ */
1601
+ lifecycleState: {
1602
+ type: ["active", "deleting", "deleted-failed"],
1603
+ required: false
1604
+ },
686
1605
  bundleId: {
687
1606
  type: "string",
688
1607
  required: false
@@ -752,8 +1671,8 @@ var UserEntity = new import_electrodb6.Entity({
752
1671
  });
753
1672
 
754
1673
  // src/data/dynamo/entities/control/workspace-entity.ts
755
- var import_electrodb7 = require("electrodb");
756
- var WorkspaceEntity = new import_electrodb7.Entity({
1674
+ var import_electrodb13 = require("electrodb");
1675
+ var WorkspaceEntity = new import_electrodb13.Entity({
757
1676
  model: {
758
1677
  entity: "workspace",
759
1678
  service: "control",
@@ -805,6 +1724,28 @@ var WorkspaceEntity = new import_electrodb7.Entity({
805
1724
  type: "boolean",
806
1725
  required: false
807
1726
  },
1727
+ /**
1728
+ * TR-022 / ADR-018 lifecycle state for the cascade pipeline.
1729
+ *
1730
+ * - `active` (or undefined) — normal, readable state.
1731
+ * - `deleting` — intermediate state set synchronously by the
1732
+ * hard-delete API entry point. The owning-delete cascade state
1733
+ * machine fans out from this transition (DynamoDB stream →
1734
+ * `control-plane.owning-delete.v1` → Step Functions). Readers MUST
1735
+ * short-circuit on `deleting` so partial cascades stay invisible.
1736
+ * - `deleted-failed` — terminal failure state set by the cascade
1737
+ * finalize Lambda when the cascade run fails irrecoverably.
1738
+ * Operators recover by re-running the cascade or by direct
1739
+ * intervention.
1740
+ *
1741
+ * The cascade finalize step deletes the canonical record conditional
1742
+ * on `lifecycleState = "deleting"`; on replay the conditional check
1743
+ * fails and the finalize step treats that as a no-op success.
1744
+ */
1745
+ lifecycleState: {
1746
+ type: ["active", "deleting", "deleted-failed"],
1747
+ required: false
1748
+ },
808
1749
  bundleId: {
809
1750
  type: "string",
810
1751
  required: false
@@ -855,33 +1796,127 @@ var WorkspaceEntity = new import_electrodb7.Entity({
855
1796
  // src/data/dynamo/dynamo-control-service.ts
856
1797
  var controlPlaneEntities = {
857
1798
  configuration: ConfigurationEntity,
1799
+ configurationUserProjection: ConfigurationUserProjectionEntity,
1800
+ configurationWorkspaceProjection: ConfigurationWorkspaceProjectionEntity,
858
1801
  membership: MembershipEntity,
1802
+ membershipUserProjection: MembershipUserProjectionEntity,
1803
+ membershipWorkspaceProjection: MembershipWorkspaceProjectionEntity,
859
1804
  role: RoleEntity,
860
1805
  roleAssignment: RoleAssignmentEntity,
1806
+ roleAssignmentUserProjection: RoleAssignmentUserProjectionEntity,
1807
+ roleAssignmentWorkspaceProjection: RoleAssignmentWorkspaceProjectionEntity,
861
1808
  tenant: TenantEntity,
862
1809
  user: UserEntity,
863
1810
  workspace: WorkspaceEntity
864
1811
  };
865
- var controlPlaneService = new import_electrodb8.Service(controlPlaneEntities, {
1812
+ var controlPlaneService = new import_electrodb14.Service(controlPlaneEntities, {
866
1813
  table: defaultTableName,
867
1814
  client: dynamoClient
868
1815
  });
869
1816
  var DynamoControlService = {
870
- entities: controlPlaneService.entities
1817
+ entities: controlPlaneService.entities,
1818
+ transaction: controlPlaneService.transaction
871
1819
  };
872
1820
  function getDynamoControlService(tableName) {
873
1821
  const resolved = tableName ?? defaultTableName;
874
- const service = new import_electrodb8.Service(controlPlaneEntities, {
1822
+ const service = new import_electrodb14.Service(controlPlaneEntities, {
875
1823
  table: resolved,
876
1824
  client: dynamoClient
877
1825
  });
878
1826
  return {
879
- entities: service.entities
1827
+ entities: service.entities,
1828
+ transaction: service.transaction
880
1829
  };
881
1830
  }
882
1831
 
883
1832
  // src/data/operations/control/membership/membership-create-operation.ts
1833
+ var import_types4 = require("@openhi/types");
1834
+
1835
+ // src/data/operations/control/membership/membership-user-projection.ts
884
1836
  var import_types2 = require("@openhi/types");
1837
+ var MISSING_NAME_SENTINEL = "-";
1838
+ function buildMembershipUserProjectionSkTenantLane(params) {
1839
+ const normalizedTenantName = typeof params.denormalizedTenantName === "string" && params.denormalizedTenantName.length > 0 ? (0, import_types2.normalizeLabel)(params.denormalizedTenantName) : MISSING_NAME_SENTINEL;
1840
+ return `MEMBERSHIP#TENANT#${normalizedTenantName}#TID#${params.tenantId}#${params.membershipId}`;
1841
+ }
1842
+ function buildMembershipUserProjectionSkWorkspaceLane(params) {
1843
+ const normalizedWorkspaceName = typeof params.denormalizedWorkspaceName === "string" && params.denormalizedWorkspaceName.length > 0 ? (0, import_types2.normalizeLabel)(params.denormalizedWorkspaceName) : MISSING_NAME_SENTINEL;
1844
+ return `MEMBERSHIP#WORKSPACE#TID#${params.tenantId}#${normalizedWorkspaceName}#WID#${params.workspaceId}#${params.membershipId}`;
1845
+ }
1846
+ function buildMembershipUserProjectionItem(input) {
1847
+ if (!input.userId || input.userId.length === 0) {
1848
+ return void 0;
1849
+ }
1850
+ const hasWorkspace = typeof input.workspaceId === "string" && input.workspaceId.length > 0;
1851
+ const sk = hasWorkspace ? buildMembershipUserProjectionSkWorkspaceLane({
1852
+ tenantId: input.tenantId,
1853
+ workspaceId: input.workspaceId,
1854
+ membershipId: input.membershipId,
1855
+ denormalizedWorkspaceName: input.denormalizedWorkspaceName
1856
+ }) : buildMembershipUserProjectionSkTenantLane({
1857
+ tenantId: input.tenantId,
1858
+ membershipId: input.membershipId,
1859
+ denormalizedTenantName: input.denormalizedTenantName
1860
+ });
1861
+ return {
1862
+ userId: input.userId,
1863
+ sk,
1864
+ tenantId: input.tenantId,
1865
+ workspaceId: hasWorkspace ? input.workspaceId : void 0,
1866
+ membershipId: input.membershipId,
1867
+ summary: input.summary,
1868
+ vid: input.vid,
1869
+ lastUpdated: input.lastUpdated,
1870
+ denormalizedTenantName: input.denormalizedTenantName,
1871
+ denormalizedUserName: input.denormalizedUserName,
1872
+ denormalizedWorkspaceName: hasWorkspace ? input.denormalizedWorkspaceName : void 0
1873
+ };
1874
+ }
1875
+ function extractReferenceSlug(resource, fieldName) {
1876
+ const field = resource[fieldName];
1877
+ if (!field || typeof field !== "object") {
1878
+ return void 0;
1879
+ }
1880
+ const reference = field.reference;
1881
+ if (typeof reference !== "string" || reference.length === 0) {
1882
+ return void 0;
1883
+ }
1884
+ const slash = reference.lastIndexOf("/");
1885
+ const tail = slash >= 0 ? reference.slice(slash + 1) : reference;
1886
+ return tail.length > 0 ? tail : void 0;
1887
+ }
1888
+
1889
+ // src/data/operations/control/membership/membership-workspace-projection.ts
1890
+ var import_types3 = require("@openhi/types");
1891
+ var MISSING_NAME_SENTINEL2 = "-";
1892
+ function buildMembershipWorkspaceProjectionSk(params) {
1893
+ const normalizedUserName = typeof params.denormalizedUserName === "string" && params.denormalizedUserName.length > 0 ? (0, import_types3.normalizeLabel)(params.denormalizedUserName) : MISSING_NAME_SENTINEL2;
1894
+ return `MEMBERSHIP#${normalizedUserName}#USER#${params.userId}#${params.membershipId}`;
1895
+ }
1896
+ function buildMembershipWorkspaceProjectionItem(input) {
1897
+ if (!input.workspaceId || input.workspaceId.length === 0) {
1898
+ return void 0;
1899
+ }
1900
+ if (!input.userId || input.userId.length === 0) {
1901
+ return void 0;
1902
+ }
1903
+ const sk = buildMembershipWorkspaceProjectionSk({
1904
+ userId: input.userId,
1905
+ membershipId: input.membershipId,
1906
+ denormalizedUserName: input.denormalizedUserName
1907
+ });
1908
+ return {
1909
+ tenantId: input.tenantId,
1910
+ workspaceId: input.workspaceId,
1911
+ sk,
1912
+ userId: input.userId,
1913
+ membershipId: input.membershipId,
1914
+ summary: input.summary,
1915
+ vid: input.vid,
1916
+ lastUpdated: input.lastUpdated,
1917
+ denormalizedUserName: input.denormalizedUserName
1918
+ };
1919
+ }
885
1920
 
886
1921
  // src/data/errors/domain-errors.ts
887
1922
  var DomainError = class extends Error {
@@ -898,6 +1933,131 @@ var ValidationError = class extends DomainError {
898
1933
  super(message, "VALIDATION", options);
899
1934
  }
900
1935
  };
1936
+ var ConflictError = class extends DomainError {
1937
+ constructor(message, options) {
1938
+ super(message, "CONFLICT", options);
1939
+ }
1940
+ };
1941
+
1942
+ // src/data/operations/control/denormalized-display-names.ts
1943
+ function extractDenormalizedReferenceDisplay(resource, fieldName) {
1944
+ const field = resource[fieldName];
1945
+ if (!field || typeof field !== "object") {
1946
+ return void 0;
1947
+ }
1948
+ const display = field.display;
1949
+ if (typeof display !== "string") {
1950
+ return void 0;
1951
+ }
1952
+ const trimmed = display.trim();
1953
+ return trimmed.length > 0 ? trimmed : void 0;
1954
+ }
1955
+
1956
+ // src/data/operations/control/multi-write-operation.ts
1957
+ var TRANSACT_WRITE_ITEM_LIMIT = 100;
1958
+ async function executeMultiWrite(params) {
1959
+ const { service, triples, token } = params;
1960
+ if (triples.length === 0) {
1961
+ throw new ValidationError(
1962
+ "executeMultiWrite called with zero triples; at least one triple is required"
1963
+ );
1964
+ }
1965
+ if (triples.length > TRANSACT_WRITE_ITEM_LIMIT) {
1966
+ throw new ValidationError(
1967
+ `executeMultiWrite received ${triples.length} triples; DynamoDB TransactWriteItems is limited to ${TRANSACT_WRITE_ITEM_LIMIT} items per call`,
1968
+ {
1969
+ details: {
1970
+ itemsRequested: triples.length,
1971
+ limit: TRANSACT_WRITE_ITEM_LIMIT
1972
+ }
1973
+ }
1974
+ );
1975
+ }
1976
+ for (const [index, triple] of triples.entries()) {
1977
+ if (!triple || typeof triple !== "object") {
1978
+ throw new ValidationError(
1979
+ `executeMultiWrite triple at index ${index} is not an object`
1980
+ );
1981
+ }
1982
+ if (typeof triple.entity !== "string" || triple.entity.length === 0) {
1983
+ throw new ValidationError(
1984
+ `executeMultiWrite triple at index ${index} is missing a non-empty 'entity' key`
1985
+ );
1986
+ }
1987
+ if (!isSupportedAction(triple.action)) {
1988
+ throw new ValidationError(
1989
+ `executeMultiWrite triple at index ${index} has unsupported action '${String(
1990
+ triple.action
1991
+ )}'; supported: 'put' | 'create' | 'delete'`
1992
+ );
1993
+ }
1994
+ if (!triple.item || typeof triple.item !== "object") {
1995
+ throw new ValidationError(
1996
+ `executeMultiWrite triple at index ${index} is missing an 'item' payload`
1997
+ );
1998
+ }
1999
+ }
2000
+ let result;
2001
+ try {
2002
+ result = await service.transaction.write(
2003
+ (entities) => triples.map((triple, index) => {
2004
+ const transactEntity = entities[triple.entity];
2005
+ if (transactEntity === void 0) {
2006
+ throw new ValidationError(
2007
+ `executeMultiWrite triple at index ${index} references unknown entity '${triple.entity}'; ensure the service exposes it`
2008
+ );
2009
+ }
2010
+ switch (triple.action) {
2011
+ case "put":
2012
+ return transactEntity.put(triple.item).commit();
2013
+ case "create":
2014
+ return transactEntity.create(triple.item).commit();
2015
+ case "delete":
2016
+ return transactEntity.delete(triple.item).commit();
2017
+ default:
2018
+ throw new ValidationError(
2019
+ `executeMultiWrite triple at index ${index} has unsupported action '${String(
2020
+ triple.action
2021
+ )}'`
2022
+ );
2023
+ }
2024
+ })
2025
+ ).go(token === void 0 ? void 0 : { token });
2026
+ } catch (err) {
2027
+ if (err instanceof DomainError) {
2028
+ throw err;
2029
+ }
2030
+ throw new ConflictError(buildCancellationMessage(err), {
2031
+ cause: err,
2032
+ details: extractCancellationReasons(err)
2033
+ });
2034
+ }
2035
+ if (result.canceled) {
2036
+ throw new ConflictError(
2037
+ "TransactWriteItems was canceled by DynamoDB (check CancellationReasons on the cause for details)",
2038
+ { details: { canceled: true, data: result.data } }
2039
+ );
2040
+ }
2041
+ return { itemsWritten: triples.length, canceled: false };
2042
+ }
2043
+ function isSupportedAction(value) {
2044
+ return value === "put" || value === "create" || value === "delete";
2045
+ }
2046
+ function buildCancellationMessage(err) {
2047
+ if (err instanceof Error && err.message) {
2048
+ return `TransactWriteItems failed: ${err.message}`;
2049
+ }
2050
+ return "TransactWriteItems failed (no error message available)";
2051
+ }
2052
+ function extractCancellationReasons(err) {
2053
+ if (err && typeof err === "object") {
2054
+ const cancellationReasons = err.CancellationReasons;
2055
+ if (cancellationReasons !== void 0) {
2056
+ return { CancellationReasons: cancellationReasons };
2057
+ }
2058
+ }
2059
+ return void 0;
2060
+ }
901
2061
 
902
2062
  // src/data/operations/control/membership/membership-create-operation.ts
903
2063
  async function createMembershipOperation(params) {
@@ -910,26 +2070,86 @@ async function createMembershipOperation(params) {
910
2070
  const resource = { resourceType: "Membership", id, ...parsedResource };
911
2071
  let linkedDataIdentityRef;
912
2072
  try {
913
- const ext = (0, import_types2.assertLinkedDataIdentityCardinality)(
2073
+ const ext = (0, import_types4.assertLinkedDataIdentityCardinality)(
914
2074
  resource
915
2075
  );
916
2076
  linkedDataIdentityRef = ext?.valueReference?.reference;
917
2077
  } catch (e) {
918
- if (e instanceof import_types2.LinkedDataIdentityCardinalityError) {
2078
+ if (e instanceof import_types4.LinkedDataIdentityCardinalityError) {
919
2079
  throw new ValidationError(e.message, { cause: e });
920
2080
  }
921
2081
  throw e;
922
2082
  }
923
- const summary = JSON.stringify((0, import_types2.extractSummary)(resource));
924
- await service.entities.membership.put({
2083
+ const resourceRecord = resource;
2084
+ const denormalizedTenantName = extractDenormalizedReferenceDisplay(
2085
+ resourceRecord,
2086
+ "tenant"
2087
+ );
2088
+ const denormalizedUserName = extractDenormalizedReferenceDisplay(
2089
+ resourceRecord,
2090
+ "user"
2091
+ );
2092
+ const denormalizedWorkspaceName = extractDenormalizedReferenceDisplay(
2093
+ resourceRecord,
2094
+ "workspace"
2095
+ );
2096
+ const summary = JSON.stringify((0, import_types4.extractSummary)(resource));
2097
+ const userIdFromResource = extractReferenceSlug(resourceRecord, "user");
2098
+ const workspaceIdFromResource = extractReferenceSlug(
2099
+ resourceRecord,
2100
+ "workspace"
2101
+ );
2102
+ const userProjectionItem = userIdFromResource !== void 0 ? buildMembershipUserProjectionItem({
2103
+ tenantId: context.tenantId,
2104
+ userId: userIdFromResource,
2105
+ workspaceId: workspaceIdFromResource,
2106
+ membershipId: id,
2107
+ summary,
2108
+ vid,
2109
+ lastUpdated,
2110
+ denormalizedTenantName,
2111
+ denormalizedUserName,
2112
+ denormalizedWorkspaceName
2113
+ }) : void 0;
2114
+ const workspaceProjectionItem = userIdFromResource !== void 0 && workspaceIdFromResource !== void 0 ? buildMembershipWorkspaceProjectionItem({
2115
+ tenantId: context.tenantId,
2116
+ workspaceId: workspaceIdFromResource,
2117
+ userId: userIdFromResource,
2118
+ membershipId: id,
2119
+ summary,
2120
+ vid,
2121
+ lastUpdated,
2122
+ denormalizedUserName
2123
+ }) : void 0;
2124
+ const canonicalItem = {
925
2125
  tenantId: context.tenantId,
926
2126
  id,
927
2127
  resource: JSON.stringify(resource),
928
2128
  summary,
929
2129
  vid,
930
2130
  lastUpdated,
931
- linkedDataIdentityRef
932
- }).go();
2131
+ linkedDataIdentityRef,
2132
+ denormalizedTenantName,
2133
+ denormalizedUserName
2134
+ };
2135
+ const triples = [
2136
+ { entity: "membership", action: "put", item: canonicalItem }
2137
+ ];
2138
+ if (userProjectionItem) {
2139
+ triples.push({
2140
+ entity: "membershipUserProjection",
2141
+ action: "put",
2142
+ item: userProjectionItem
2143
+ });
2144
+ }
2145
+ if (workspaceProjectionItem) {
2146
+ triples.push({
2147
+ entity: "membershipWorkspaceProjection",
2148
+ action: "put",
2149
+ item: workspaceProjectionItem
2150
+ });
2151
+ }
2152
+ await executeMultiWrite({ service, triples });
933
2153
  return {
934
2154
  id,
935
2155
  resource,
@@ -938,7 +2158,107 @@ async function createMembershipOperation(params) {
938
2158
  }
939
2159
 
940
2160
  // src/data/operations/control/roleassignment/roleassignment-create-operation.ts
941
- var import_types3 = require("@openhi/types");
2161
+ var import_types7 = require("@openhi/types");
2162
+
2163
+ // src/data/operations/control/roleassignment/roleassignment-user-projection.ts
2164
+ var import_types5 = require("@openhi/types");
2165
+ var MISSING_NAME_SENTINEL3 = "-";
2166
+ function buildRoleAssignmentUserProjectionSkTenantLane(params) {
2167
+ const normalizedRoleName = typeof params.denormalizedRoleName === "string" && params.denormalizedRoleName.length > 0 ? (0, import_types5.normalizeLabel)(params.denormalizedRoleName) : MISSING_NAME_SENTINEL3;
2168
+ return `ROLEASSIGNMENT#TENANT#${normalizedRoleName}#${params.roleId}#TID#${params.tenantId}#${params.roleAssignmentId}`;
2169
+ }
2170
+ function buildRoleAssignmentUserProjectionSkWorkspaceLane(params) {
2171
+ const normalizedRoleName = typeof params.denormalizedRoleName === "string" && params.denormalizedRoleName.length > 0 ? (0, import_types5.normalizeLabel)(params.denormalizedRoleName) : MISSING_NAME_SENTINEL3;
2172
+ return `ROLEASSIGNMENT#WORKSPACE#${normalizedRoleName}#${params.roleId}#TID#${params.tenantId}#WID#${params.workspaceId}#${params.roleAssignmentId}`;
2173
+ }
2174
+ function buildRoleAssignmentUserProjectionItem(input) {
2175
+ if (!input.userId || input.userId.length === 0) {
2176
+ return void 0;
2177
+ }
2178
+ if (!input.roleId || input.roleId.length === 0) {
2179
+ return void 0;
2180
+ }
2181
+ const hasWorkspace = typeof input.workspaceId === "string" && input.workspaceId.length > 0;
2182
+ const sk = hasWorkspace ? buildRoleAssignmentUserProjectionSkWorkspaceLane({
2183
+ tenantId: input.tenantId,
2184
+ workspaceId: input.workspaceId,
2185
+ roleId: input.roleId,
2186
+ roleAssignmentId: input.roleAssignmentId,
2187
+ denormalizedRoleName: input.denormalizedRoleName
2188
+ }) : buildRoleAssignmentUserProjectionSkTenantLane({
2189
+ tenantId: input.tenantId,
2190
+ roleId: input.roleId,
2191
+ roleAssignmentId: input.roleAssignmentId,
2192
+ denormalizedRoleName: input.denormalizedRoleName
2193
+ });
2194
+ return {
2195
+ userId: input.userId,
2196
+ sk,
2197
+ tenantId: input.tenantId,
2198
+ workspaceId: hasWorkspace ? input.workspaceId : void 0,
2199
+ roleId: input.roleId,
2200
+ roleAssignmentId: input.roleAssignmentId,
2201
+ summary: input.summary,
2202
+ vid: input.vid,
2203
+ lastUpdated: input.lastUpdated,
2204
+ denormalizedTenantName: input.denormalizedTenantName,
2205
+ denormalizedUserName: input.denormalizedUserName,
2206
+ denormalizedRoleName: input.denormalizedRoleName
2207
+ };
2208
+ }
2209
+ function extractReferenceSlug2(resource, fieldName) {
2210
+ const field = resource[fieldName];
2211
+ if (!field || typeof field !== "object") {
2212
+ return void 0;
2213
+ }
2214
+ const reference = field.reference;
2215
+ if (typeof reference !== "string" || reference.length === 0) {
2216
+ return void 0;
2217
+ }
2218
+ const slash = reference.lastIndexOf("/");
2219
+ const tail = slash >= 0 ? reference.slice(slash + 1) : reference;
2220
+ return tail.length > 0 ? tail : void 0;
2221
+ }
2222
+
2223
+ // src/data/operations/control/roleassignment/roleassignment-workspace-projection.ts
2224
+ var import_types6 = require("@openhi/types");
2225
+ var MISSING_NAME_SENTINEL4 = "-";
2226
+ function buildRoleAssignmentWorkspaceProjectionSk(params) {
2227
+ const normalizedUserName = typeof params.denormalizedUserName === "string" && params.denormalizedUserName.length > 0 ? (0, import_types6.normalizeLabel)(params.denormalizedUserName) : MISSING_NAME_SENTINEL4;
2228
+ return `ROLEASSIGNMENT#${params.roleId}#${normalizedUserName}#USER#${params.userId}#${params.roleAssignmentId}`;
2229
+ }
2230
+ function buildRoleAssignmentWorkspaceProjectionItem(input) {
2231
+ if (!input.workspaceId || input.workspaceId.length === 0) {
2232
+ return void 0;
2233
+ }
2234
+ if (!input.userId || input.userId.length === 0) {
2235
+ return void 0;
2236
+ }
2237
+ if (!input.roleId || input.roleId.length === 0) {
2238
+ return void 0;
2239
+ }
2240
+ const sk = buildRoleAssignmentWorkspaceProjectionSk({
2241
+ roleId: input.roleId,
2242
+ userId: input.userId,
2243
+ roleAssignmentId: input.roleAssignmentId,
2244
+ denormalizedUserName: input.denormalizedUserName
2245
+ });
2246
+ return {
2247
+ tenantId: input.tenantId,
2248
+ workspaceId: input.workspaceId,
2249
+ sk,
2250
+ userId: input.userId,
2251
+ roleId: input.roleId,
2252
+ roleAssignmentId: input.roleAssignmentId,
2253
+ summary: input.summary,
2254
+ vid: input.vid,
2255
+ lastUpdated: input.lastUpdated,
2256
+ denormalizedUserName: input.denormalizedUserName,
2257
+ denormalizedRoleName: input.denormalizedRoleName
2258
+ };
2259
+ }
2260
+
2261
+ // src/data/operations/control/roleassignment/roleassignment-create-operation.ts
942
2262
  async function createRoleAssignmentOperation(params) {
943
2263
  const { context, body, tableName } = params;
944
2264
  const service = getDynamoControlService(tableName);
@@ -947,15 +2267,80 @@ async function createRoleAssignmentOperation(params) {
947
2267
  const lastUpdated = context.date ?? (/* @__PURE__ */ new Date()).toISOString();
948
2268
  const vid = `1`;
949
2269
  const resource = { resourceType: "RoleAssignment", id, ...parsedResource };
950
- const summary = JSON.stringify((0, import_types3.extractSummary)(resource));
951
- await service.entities.roleAssignment.put({
2270
+ const resourceRecord = resource;
2271
+ const denormalizedTenantName = extractDenormalizedReferenceDisplay(
2272
+ resourceRecord,
2273
+ "tenant"
2274
+ );
2275
+ const denormalizedUserName = extractDenormalizedReferenceDisplay(
2276
+ resourceRecord,
2277
+ "user"
2278
+ );
2279
+ const denormalizedRoleName = extractDenormalizedReferenceDisplay(
2280
+ resourceRecord,
2281
+ "role"
2282
+ );
2283
+ const summary = JSON.stringify((0, import_types7.extractSummary)(resource));
2284
+ const userIdFromResource = extractReferenceSlug2(resourceRecord, "user");
2285
+ const roleIdFromResource = extractReferenceSlug2(resourceRecord, "role");
2286
+ const workspaceIdFromResource = extractReferenceSlug2(
2287
+ resourceRecord,
2288
+ "workspace"
2289
+ );
2290
+ const userProjectionItem = userIdFromResource !== void 0 && roleIdFromResource !== void 0 ? buildRoleAssignmentUserProjectionItem({
2291
+ tenantId: context.tenantId,
2292
+ userId: userIdFromResource,
2293
+ workspaceId: workspaceIdFromResource,
2294
+ roleId: roleIdFromResource,
2295
+ roleAssignmentId: id,
2296
+ summary,
2297
+ vid,
2298
+ lastUpdated,
2299
+ denormalizedTenantName,
2300
+ denormalizedUserName,
2301
+ denormalizedRoleName
2302
+ }) : void 0;
2303
+ const workspaceProjectionItem = userIdFromResource !== void 0 && roleIdFromResource !== void 0 && workspaceIdFromResource !== void 0 ? buildRoleAssignmentWorkspaceProjectionItem({
2304
+ tenantId: context.tenantId,
2305
+ workspaceId: workspaceIdFromResource,
2306
+ userId: userIdFromResource,
2307
+ roleId: roleIdFromResource,
2308
+ roleAssignmentId: id,
2309
+ summary,
2310
+ vid,
2311
+ lastUpdated,
2312
+ denormalizedUserName,
2313
+ denormalizedRoleName
2314
+ }) : void 0;
2315
+ const canonicalItem = {
952
2316
  tenantId: context.tenantId,
953
2317
  id,
954
2318
  resource: JSON.stringify(resource),
955
2319
  summary,
956
2320
  vid,
957
- lastUpdated
958
- }).go();
2321
+ lastUpdated,
2322
+ denormalizedTenantName,
2323
+ denormalizedUserName,
2324
+ denormalizedRoleName
2325
+ };
2326
+ const triples = [
2327
+ { entity: "roleAssignment", action: "put", item: canonicalItem }
2328
+ ];
2329
+ if (userProjectionItem) {
2330
+ triples.push({
2331
+ entity: "roleAssignmentUserProjection",
2332
+ action: "put",
2333
+ item: userProjectionItem
2334
+ });
2335
+ }
2336
+ if (workspaceProjectionItem) {
2337
+ triples.push({
2338
+ entity: "roleAssignmentWorkspaceProjection",
2339
+ action: "put",
2340
+ item: workspaceProjectionItem
2341
+ });
2342
+ }
2343
+ await executeMultiWrite({ service, triples });
959
2344
  return {
960
2345
  id,
961
2346
  resource,
@@ -964,7 +2349,7 @@ async function createRoleAssignmentOperation(params) {
964
2349
  }
965
2350
 
966
2351
  // src/data/operations/control/tenant/tenant-create-operation.ts
967
- var import_types4 = require("@openhi/types");
2352
+ var import_types8 = require("@openhi/types");
968
2353
  async function createTenantOperation(params) {
969
2354
  const { context, body, tableName } = params;
970
2355
  const service = getDynamoControlService(tableName);
@@ -973,7 +2358,7 @@ async function createTenantOperation(params) {
973
2358
  const vid = lastUpdated.replace(/[-:T.Z]/g, "").slice(0, 12) || Date.now().toString(36);
974
2359
  const parsedResource = typeof body.resource === "string" ? JSON.parse(body.resource) : body.resource ?? {};
975
2360
  const resource = { resourceType: "Tenant", id, ...parsedResource };
976
- const summary = JSON.stringify((0, import_types4.extractSummary)(resource));
2361
+ const summary = JSON.stringify((0, import_types8.extractSummary)(resource));
977
2362
  await service.entities.tenant.put({
978
2363
  tenantId: id,
979
2364
  id,
@@ -986,7 +2371,7 @@ async function createTenantOperation(params) {
986
2371
  }
987
2372
 
988
2373
  // src/data/operations/control/user/user-create-operation.ts
989
- var import_types5 = require("@openhi/types");
2374
+ var import_types9 = require("@openhi/types");
990
2375
 
991
2376
  // src/data/operations/control/user/user-find-by-sub-operation.ts
992
2377
  async function findUserBySubOperation(params) {
@@ -1006,7 +2391,7 @@ async function findUserBySubOperation(params) {
1006
2391
  }
1007
2392
 
1008
2393
  // src/data/operations/data-operations-common.ts
1009
- var import_types6 = require("@openhi/types");
2394
+ var import_types10 = require("@openhi/types");
1010
2395
 
1011
2396
  // src/lib/compression.ts
1012
2397
  var import_node_zlib = require("zlib");
@@ -1043,8 +2428,8 @@ async function createDataEntityRecord(entity, tenantId, workspaceId, id, resourc
1043
2428
  const lastUpdated = resourceWithAudit.meta?.lastUpdated ?? fallbackDate ?? (/* @__PURE__ */ new Date()).toISOString();
1044
2429
  const vid = lastUpdated.replace(/[-:T.Z]/g, "").slice(0, 12) || Date.now().toString(36);
1045
2430
  const resourceLike = resourceWithAudit;
1046
- const summary = JSON.stringify((0, import_types6.extractSummary)(resourceLike));
1047
- const gsi1sk = (0, import_types6.extractSortKey)(resourceLike);
2431
+ const summary = JSON.stringify((0, import_types10.extractSummary)(resourceLike));
2432
+ const gsi1sk = (0, import_types10.extractSortKey)(resourceLike);
1048
2433
  await entity.put({
1049
2434
  sk: DATA_ENTITY_SK,
1050
2435
  tenantId,
@@ -1063,7 +2448,7 @@ async function createDataEntityRecord(entity, tenantId, workspaceId, id, resourc
1063
2448
  }
1064
2449
 
1065
2450
  // src/data/operations/control/user/user-switch-tenant-workspace-operation.ts
1066
- var import_types7 = require("@openhi/types");
2451
+ var import_types11 = require("@openhi/types");
1067
2452
 
1068
2453
  // src/data/operations/control/user/user-resource-helpers.ts
1069
2454
  function parseUserResource(resource) {
@@ -1084,16 +2469,16 @@ function idFromReference(reference, prefix) {
1084
2469
  }
1085
2470
 
1086
2471
  // src/data/operations/control/user/user-update-operation.ts
1087
- var import_types8 = require("@openhi/types");
2472
+ var import_types12 = require("@openhi/types");
1088
2473
 
1089
2474
  // src/data/operations/control/workspace/workspace-create-operation.ts
1090
- var import_types9 = require("@openhi/types");
2475
+ var import_types13 = require("@openhi/types");
1091
2476
 
1092
2477
  // src/data/dynamo/dynamo-data-service.ts
1093
- var import_electrodb10 = require("electrodb");
2478
+ var import_electrodb16 = require("electrodb");
1094
2479
 
1095
2480
  // src/data/dynamo/entities/data-entity-common.ts
1096
- var import_electrodb9 = require("electrodb");
2481
+ var import_electrodb15 = require("electrodb");
1097
2482
  var dataEntityAttributes = {
1098
2483
  /** Sort key. "CURRENT" for current version; version history in S3. */
1099
2484
  sk: {
@@ -1189,7 +2574,7 @@ var dataEntityAttributes = {
1189
2574
  }
1190
2575
  };
1191
2576
  function createDataEntity(entity, resourceTypeLabel) {
1192
- return new import_electrodb9.Entity({
2577
+ return new import_electrodb15.Entity({
1193
2578
  model: {
1194
2579
  entity,
1195
2580
  service: "data",
@@ -2103,21 +3488,23 @@ var dataPlaneEntities = {
2103
3488
  visionprescription: VisionPrescriptionEntity,
2104
3489
  verificationresult: VerificationResultEntity
2105
3490
  };
2106
- var dataPlaneService = new import_electrodb10.Service(dataPlaneEntities, {
3491
+ var dataPlaneService = new import_electrodb16.Service(dataPlaneEntities, {
2107
3492
  table: defaultTableName,
2108
3493
  client: dynamoClient
2109
3494
  });
2110
3495
  var DynamoDataService = {
2111
- entities: dataPlaneService.entities
3496
+ entities: dataPlaneService.entities,
3497
+ transaction: dataPlaneService.transaction
2112
3498
  };
2113
3499
  function getDynamoDataService(tableName) {
2114
3500
  const resolved = tableName ?? defaultTableName;
2115
- const service = new import_electrodb10.Service(dataPlaneEntities, {
3501
+ const service = new import_electrodb16.Service(dataPlaneEntities, {
2116
3502
  table: resolved,
2117
3503
  client: dynamoClient
2118
3504
  });
2119
3505
  return {
2120
- entities: service.entities
3506
+ entities: service.entities,
3507
+ transaction: service.transaction
2121
3508
  };
2122
3509
  }
2123
3510
 
@@ -2169,7 +3556,7 @@ async function createWorkspaceOperation(params) {
2169
3556
  const vid = lastUpdated.replace(/[-:T.Z]/g, "").slice(0, 12) || Date.now().toString(36);
2170
3557
  const parsedResource = typeof body.resource === "string" ? JSON.parse(body.resource) : body.resource ?? {};
2171
3558
  const resource = { resourceType: "Workspace", id, ...parsedResource };
2172
- const summary = JSON.stringify((0, import_types9.extractSummary)(resource));
3559
+ const summary = JSON.stringify((0, import_types13.extractSummary)(resource));
2173
3560
  await service.entities.workspace.put({
2174
3561
  tenantId,
2175
3562
  id,
@@ -2192,7 +3579,7 @@ async function createWorkspaceOperation(params) {
2192
3579
  var CURRENT_SK = "CURRENT";
2193
3580
  var VID = "1";
2194
3581
  var summaryFor = (resource) => {
2195
- return JSON.stringify((0, import_types10.extractSummary)(resource));
3582
+ return JSON.stringify((0, import_types14.extractSummary)(resource));
2196
3583
  };
2197
3584
  var stableOnboardingId = (kind, cognitoSub) => {
2198
3585
  return (0, import_node_crypto.createHash)("sha256").update(kind).update("\0").update(cognitoSub).digest("hex").slice(0, 26).toUpperCase();