@openhi/constructs 0.0.177 → 0.0.179

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 (54) hide show
  1. package/lib/{chunk-Z4PZSLYY.mjs → chunk-3M4QTQH6.mjs} +2 -2
  2. package/lib/{chunk-JUSVETWK.mjs → chunk-4LQR32D2.mjs} +38 -40
  3. package/lib/{chunk-JUSVETWK.mjs.map → chunk-4LQR32D2.mjs.map} +1 -1
  4. package/lib/{chunk-XNUCKVSE.mjs → chunk-7GMTHOYF.mjs} +2 -2
  5. package/lib/{chunk-E2OWEBBH.mjs → chunk-DIVYB6GD.mjs} +18 -4
  6. package/lib/chunk-DIVYB6GD.mjs.map +1 -0
  7. package/lib/chunk-F2LY4TEI.mjs +272 -0
  8. package/lib/chunk-F2LY4TEI.mjs.map +1 -0
  9. package/lib/{chunk-GG2WD6TA.mjs → chunk-JJ3AQ6G5.mjs} +9 -3
  10. package/lib/{chunk-GG2WD6TA.mjs.map → chunk-JJ3AQ6G5.mjs.map} +1 -1
  11. package/lib/{chunk-EBB4RNUG.mjs → chunk-PIQISEGW.mjs} +2 -2
  12. package/lib/{chunk-FDBBTNCI.mjs → chunk-Q4KQD2NB.mjs} +117 -5
  13. package/lib/chunk-Q4KQD2NB.mjs.map +1 -0
  14. package/lib/{chunk-Y4RGUAM2.mjs → chunk-V6KLFEHC.mjs} +105 -34
  15. package/lib/chunk-V6KLFEHC.mjs.map +1 -0
  16. package/lib/chunk-VQY57NOV.mjs +60 -0
  17. package/lib/chunk-VQY57NOV.mjs.map +1 -0
  18. package/lib/counter-maintenance.handler.mjs +4 -4
  19. package/lib/counter-reconciliation.handler.js +2 -2
  20. package/lib/counter-reconciliation.handler.js.map +1 -1
  21. package/lib/counter-reconciliation.handler.mjs +9 -267
  22. package/lib/counter-reconciliation.handler.mjs.map +1 -1
  23. package/lib/index.d.mts +117 -2
  24. package/lib/index.d.ts +117 -2
  25. package/lib/index.js +6454 -6243
  26. package/lib/index.js.map +1 -1
  27. package/lib/index.mjs +106 -4
  28. package/lib/index.mjs.map +1 -1
  29. package/lib/pre-token-generation.handler.js +28 -19
  30. package/lib/pre-token-generation.handler.js.map +1 -1
  31. package/lib/pre-token-generation.handler.mjs +4 -5
  32. package/lib/pre-token-generation.handler.mjs.map +1 -1
  33. package/lib/provision-default-workspace.handler.js +22 -19
  34. package/lib/provision-default-workspace.handler.js.map +1 -1
  35. package/lib/provision-default-workspace.handler.mjs +3 -4
  36. package/lib/provision-default-workspace.handler.mjs.map +1 -1
  37. package/lib/rest-api-lambda.handler.js +400 -214
  38. package/lib/rest-api-lambda.handler.js.map +1 -1
  39. package/lib/rest-api-lambda.handler.mjs +243 -171
  40. package/lib/rest-api-lambda.handler.mjs.map +1 -1
  41. package/lib/seed-demo-data.handler.d.mts +19 -0
  42. package/lib/seed-demo-data.handler.d.ts +19 -0
  43. package/lib/seed-demo-data.handler.js +805 -159
  44. package/lib/seed-demo-data.handler.js.map +1 -1
  45. package/lib/seed-demo-data.handler.mjs +8 -4
  46. package/package.json +3 -3
  47. package/lib/chunk-6HGSR3TG.mjs +0 -123
  48. package/lib/chunk-6HGSR3TG.mjs.map +0 -1
  49. package/lib/chunk-E2OWEBBH.mjs.map +0 -1
  50. package/lib/chunk-FDBBTNCI.mjs.map +0 -1
  51. package/lib/chunk-Y4RGUAM2.mjs.map +0 -1
  52. /package/lib/{chunk-Z4PZSLYY.mjs.map → chunk-3M4QTQH6.mjs.map} +0 -0
  53. /package/lib/{chunk-XNUCKVSE.mjs.map → chunk-7GMTHOYF.mjs.map} +0 -0
  54. /package/lib/{chunk-EBB4RNUG.mjs.map → chunk-PIQISEGW.mjs.map} +0 -0
@@ -0,0 +1,272 @@
1
+ import {
2
+ COUNTER_TARGET,
3
+ isAdminRoleAssignment
4
+ } from "./chunk-RQKJNMX5.mjs";
5
+ import {
6
+ countMembershipsByUserOperation,
7
+ listTenantsOperation,
8
+ listWorkspacesOperation,
9
+ membershipListByWorkspaceOperation,
10
+ roleAssignmentListByWorkspaceOperation
11
+ } from "./chunk-4LQR32D2.mjs";
12
+ import {
13
+ listMembershipsOperation
14
+ } from "./chunk-PIQISEGW.mjs";
15
+ import {
16
+ extractRoleLevel
17
+ } from "./chunk-BUAYVN3C.mjs";
18
+ import {
19
+ extractReferenceSlug
20
+ } from "./chunk-I6LUPJUY.mjs";
21
+ import {
22
+ listUsersOperation
23
+ } from "./chunk-Q4KQD2NB.mjs";
24
+ import {
25
+ getDynamoControlService
26
+ } from "./chunk-EUIP2U5F.mjs";
27
+
28
+ // src/data/operations/control/counters/counter-reconcile-operation.ts
29
+ function counterValue(value) {
30
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
31
+ }
32
+ function reconcileContext(tenantId) {
33
+ return {
34
+ tenantId,
35
+ workspaceId: "",
36
+ date: (/* @__PURE__ */ new Date()).toISOString(),
37
+ actorId: "counter-reconciliation",
38
+ actorName: "Counter Reconciliation Job",
39
+ actorType: "internal-system",
40
+ source: "step-function"
41
+ };
42
+ }
43
+ async function reconcileTenantCountersOperation(params) {
44
+ const { tenantId, tableName } = params;
45
+ const service = getDynamoControlService(tableName);
46
+ const context = reconcileContext(tenantId);
47
+ const workspacesResult = await listWorkspacesOperation({
48
+ context,
49
+ tableName,
50
+ mode: "count"
51
+ });
52
+ const workspacesInTenant = workspacesResult.total;
53
+ const memberships = await listMembershipsOperation({
54
+ context,
55
+ tableName,
56
+ mode: "full"
57
+ });
58
+ let usersInTenant = 0;
59
+ for (const entry of memberships.entries) {
60
+ const workspaceSlug = extractReferenceSlug(entry.resource, "workspace");
61
+ if (workspaceSlug === void 0) {
62
+ usersInTenant += 1;
63
+ }
64
+ }
65
+ const current = await service.entities.tenant.get({ tenantId, sk: "CURRENT" }).go();
66
+ const drift = [];
67
+ const recomputed = {
68
+ usersInTenant,
69
+ workspacesInTenant
70
+ };
71
+ for (const counter of Object.keys(recomputed)) {
72
+ const oldValue = counterValue(current.data?.[counter]);
73
+ const newValue = recomputed[counter];
74
+ if (oldValue !== newValue) {
75
+ drift.push({
76
+ target: COUNTER_TARGET.Tenant,
77
+ id: tenantId,
78
+ counter,
79
+ old: oldValue,
80
+ new: newValue
81
+ });
82
+ }
83
+ }
84
+ if (drift.length > 0) {
85
+ await service.entities.tenant.patch({ tenantId, sk: "CURRENT" }).set(recomputed).go();
86
+ }
87
+ return { drift };
88
+ }
89
+ async function reconcileWorkspaceCountersOperation(params) {
90
+ const { tenantId, workspaceId, tableName } = params;
91
+ const service = getDynamoControlService(tableName);
92
+ let usersInWorkspace = 0;
93
+ let membershipCursor = null;
94
+ do {
95
+ const page = await membershipListByWorkspaceOperation({
96
+ tenantId,
97
+ workspaceId,
98
+ cursor: membershipCursor,
99
+ tableName
100
+ });
101
+ usersInWorkspace += page.items.length;
102
+ membershipCursor = page.cursor;
103
+ } while (membershipCursor !== null);
104
+ let adminUsersInWorkspace = 0;
105
+ let normalUsersInWorkspace = 0;
106
+ let roleAssignmentCursor = null;
107
+ do {
108
+ const page = await roleAssignmentListByWorkspaceOperation({
109
+ tenantId,
110
+ workspaceId,
111
+ cursor: roleAssignmentCursor,
112
+ tableName
113
+ });
114
+ for (const item of page.items) {
115
+ const roleLevel = await readRoleLevel(
116
+ service,
117
+ tenantId,
118
+ item.roleAssignmentId
119
+ );
120
+ if (isAdminRoleAssignment({ roleLevel, roleId: item.roleId })) {
121
+ adminUsersInWorkspace += 1;
122
+ } else {
123
+ normalUsersInWorkspace += 1;
124
+ }
125
+ }
126
+ roleAssignmentCursor = page.cursor;
127
+ } while (roleAssignmentCursor !== null);
128
+ const current = await service.entities.workspace.get({ tenantId, id: workspaceId, sk: "CURRENT" }).go();
129
+ const drift = [];
130
+ const recomputed = {
131
+ usersInWorkspace,
132
+ adminUsersInWorkspace,
133
+ normalUsersInWorkspace
134
+ };
135
+ for (const counter of Object.keys(recomputed)) {
136
+ const oldValue = counterValue(current.data?.[counter]);
137
+ const newValue = recomputed[counter];
138
+ if (oldValue !== newValue) {
139
+ drift.push({
140
+ target: COUNTER_TARGET.Workspace,
141
+ id: workspaceId,
142
+ tenantId,
143
+ counter,
144
+ old: oldValue,
145
+ new: newValue
146
+ });
147
+ }
148
+ }
149
+ if (drift.length > 0) {
150
+ await service.entities.workspace.patch({ tenantId, id: workspaceId, sk: "CURRENT" }).set(recomputed).go();
151
+ }
152
+ return { drift };
153
+ }
154
+ async function reconcileUserCountersOperation(params) {
155
+ const { userId, tableName } = params;
156
+ const service = getDynamoControlService(tableName);
157
+ const tenantsForUser = await countMembershipsByUserOperation({
158
+ userId,
159
+ mode: "tenant",
160
+ tableName
161
+ });
162
+ const workspacesForUser = await countMembershipsByUserOperation({
163
+ userId,
164
+ mode: "workspace",
165
+ tableName
166
+ });
167
+ const current = await service.entities.user.get({ id: userId, sk: "CURRENT" }).go();
168
+ const drift = [];
169
+ const recomputed = {
170
+ tenantsForUser,
171
+ workspacesForUser
172
+ };
173
+ for (const counter of Object.keys(recomputed)) {
174
+ const oldValue = counterValue(current.data?.[counter]);
175
+ const newValue = recomputed[counter];
176
+ if (oldValue !== newValue) {
177
+ drift.push({
178
+ target: COUNTER_TARGET.User,
179
+ id: userId,
180
+ counter,
181
+ old: oldValue,
182
+ new: newValue
183
+ });
184
+ }
185
+ }
186
+ if (drift.length > 0) {
187
+ await service.entities.user.patch({ id: userId, sk: "CURRENT" }).set(recomputed).go();
188
+ }
189
+ return { drift };
190
+ }
191
+ async function readRoleLevel(service, tenantId, roleAssignmentId) {
192
+ const response = await service.entities.roleAssignment.get({ tenantId, id: roleAssignmentId, sk: "CURRENT" }).go();
193
+ if (!response.data) {
194
+ return void 0;
195
+ }
196
+ const resource = JSON.parse(response.data.resource);
197
+ return extractRoleLevel(resource);
198
+ }
199
+
200
+ // src/data/operations/control/counters/counter-reconcile-driver.ts
201
+ function driverContext(tenantId) {
202
+ return {
203
+ tenantId,
204
+ workspaceId: "",
205
+ date: (/* @__PURE__ */ new Date()).toISOString(),
206
+ actorId: "counter-reconciliation",
207
+ actorName: "Counter Reconciliation Job",
208
+ actorType: "internal-system",
209
+ source: "step-function"
210
+ };
211
+ }
212
+ async function reconcileAllCountersOperation(params = {}) {
213
+ const { tableName } = params;
214
+ const drift = [];
215
+ let tenantsScanned = 0;
216
+ let workspacesScanned = 0;
217
+ let usersScanned = 0;
218
+ const tenants = await listTenantsOperation({
219
+ context: driverContext(""),
220
+ tableName,
221
+ mode: "summary"
222
+ });
223
+ for (const tenant of tenants.entries) {
224
+ tenantsScanned += 1;
225
+ const tenantResult = await reconcileTenantCountersOperation({
226
+ tenantId: tenant.id,
227
+ tableName
228
+ });
229
+ drift.push(...tenantResult.drift);
230
+ const workspaces = await listWorkspacesOperation({
231
+ context: driverContext(tenant.id),
232
+ tableName,
233
+ mode: "summary"
234
+ });
235
+ for (const workspace of workspaces.entries) {
236
+ workspacesScanned += 1;
237
+ const workspaceResult = await reconcileWorkspaceCountersOperation({
238
+ tenantId: tenant.id,
239
+ workspaceId: workspace.id,
240
+ tableName
241
+ });
242
+ drift.push(...workspaceResult.drift);
243
+ }
244
+ }
245
+ const users = await listUsersOperation({
246
+ context: driverContext(""),
247
+ tableName,
248
+ mode: "summary"
249
+ });
250
+ for (const user of users.entries) {
251
+ usersScanned += 1;
252
+ const userResult = await reconcileUserCountersOperation({
253
+ userId: user.id,
254
+ tableName
255
+ });
256
+ drift.push(...userResult.drift);
257
+ }
258
+ return {
259
+ drift,
260
+ scanned: {
261
+ tenants: tenantsScanned,
262
+ workspaces: workspacesScanned,
263
+ users: usersScanned
264
+ },
265
+ countersCorrected: drift.length
266
+ };
267
+ }
268
+
269
+ export {
270
+ reconcileAllCountersOperation
271
+ };
272
+ //# sourceMappingURL=chunk-F2LY4TEI.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/data/operations/control/counters/counter-reconcile-operation.ts","../src/data/operations/control/counters/counter-reconcile-driver.ts"],"sourcesContent":["import {\n COUNTER_TARGET,\n type CounterTarget,\n type TenantCounter,\n type UserCounter,\n type WorkspaceCounter,\n} from \"./counter-apply-operation\";\nimport { isAdminRoleAssignment } from \"./role-admin-classification\";\nimport { getDynamoControlService } from \"../../../dynamo/dynamo-control-service\";\nimport type { OpenHiContext } from \"../../../openhi-context\";\nimport { extractRoleLevel } from \"../control-event-publisher\";\nimport { countMembershipsByUserOperation } from \"../membership/membership-count-by-user-operation\";\nimport { membershipListByWorkspaceOperation } from \"../membership/membership-list-by-workspace-operation\";\nimport { listMembershipsOperation } from \"../membership/membership-list-operation\";\nimport { extractReferenceSlug } from \"../membership/membership-user-projection\";\nimport { roleAssignmentListByWorkspaceOperation } from \"../roleassignment/roleassignment-list-by-workspace-operation\";\nimport { listWorkspacesOperation } from \"../workspace/workspace-list-operation\";\n\n/**\n * ADR-028 counter reconciliation — recompute the denormalized\n * control-plane counters from canonical data and repair drift.\n *\n * The atomic-ADD path ({@link applyCounterDeltaOperation}) maintains the\n * counters incrementally off domain events, but events can be missed,\n * replayed, or arrive after a record was created without one (rows that\n * predate the counter work). This operation is the correctness backstop\n * ADR-028 names: it ignores the current counter value, recomputes the\n * true value from canonical records, and writes the absolute recomputed\n * value back with a DynamoDB `SET` (not `ADD`). A `SET` repairs both\n * directions of drift and backfills an absent / `0` attribute to its\n * correct value in one write.\n *\n * Counter semantics recomputed here MUST match {@link counterEventRouter}:\n *\n * - `Tenant.usersInTenant` = # tenant-scoped Memberships in the tenant\n * (membership with NO workspace reference).\n * - `Tenant.workspacesInTenant` = # Workspaces in the tenant.\n * - `Workspace.usersInWorkspace` = # workspace-scoped Memberships for the workspace.\n * - `Workspace.adminUsersInWorkspace` / `normalUsersInWorkspace` =\n * # workspace-scoped RoleAssignments for the workspace, bucketed by\n * {@link isAdminRoleAssignment} on the assignment's role level / role id.\n * - `User.tenantsForUser` = # tenant-scoped Memberships for the user.\n * - `User.workspacesForUser` = # workspace-scoped Memberships for the user.\n *\n * @see counter-apply-operation.ts — the incremental ADD path this reconciles against.\n * @see counter-event-router.ts — the event → counter semantics this mirrors.\n */\n\n/** One counter's old → new transition, recorded only when `old !== new`. */\nexport interface CounterDriftEntry {\n /** Which canonical entity the counter lives on. */\n readonly target: CounterTarget;\n /** Identity of the canonical record (tenantId for Tenant, workspaceId for Workspace, userId for User). */\n readonly id: string;\n /** Tenant the record belongs to (Workspace only; omitted for Tenant / User). */\n readonly tenantId?: string;\n /** The counter attribute name. */\n readonly counter: string;\n /** The value found on the record before reconciliation (0 when the attribute was absent). */\n readonly old: number;\n /** The recomputed-from-canonical value written back. */\n readonly new: number;\n}\n\n/** Result of reconciling one target record. */\nexport interface CounterReconcileResult {\n /** Every counter whose value changed (empty when the record was already correct). */\n readonly drift: Array<CounterDriftEntry>;\n}\n\n/** Coerce a possibly-absent counter attribute to a non-negative number (default 0). */\nfunction counterValue(value: unknown): number {\n return typeof value === \"number\" && Number.isFinite(value) ? value : 0;\n}\n\n/** Minimal actor context the underlying list operations need (they read only `tenantId`). */\nfunction reconcileContext(tenantId: string): OpenHiContext {\n return {\n tenantId,\n workspaceId: \"\",\n date: new Date().toISOString(),\n actorId: \"counter-reconciliation\",\n actorName: \"Counter Reconciliation Job\",\n actorType: \"internal-system\",\n source: \"step-function\",\n };\n}\n\n/**\n * Recompute and repair the two counters on one Tenant record.\n *\n * - `workspacesInTenant` is the workspace count from\n * {@link listWorkspacesOperation} (`mode: \"count\"`, GSI1 fan-out).\n * - `usersInTenant` is the number of *tenant-scoped* Memberships in the\n * tenant. Memberships have no tenant-partition projection, so this\n * enumerates the tenant's canonical Memberships via\n * {@link listMembershipsOperation} (`mode: \"full\"`) and counts the rows\n * whose `resource` carries no `workspace` reference — the same\n * tenant-vs-workspace discriminator the create path uses\n * ({@link extractReferenceSlug} on the `workspace` field).\n */\nexport async function reconcileTenantCountersOperation(params: {\n readonly tenantId: string;\n readonly tableName?: string;\n}): Promise<CounterReconcileResult> {\n const { tenantId, tableName } = params;\n const service = getDynamoControlService(tableName);\n const context = reconcileContext(tenantId);\n\n const workspacesResult = await listWorkspacesOperation({\n context,\n tableName,\n mode: \"count\",\n });\n const workspacesInTenant = workspacesResult.total;\n\n // Full enumeration of the tenant's canonical Memberships; discriminate\n // tenant-scoped (no workspace reference) from workspace-scoped by the\n // resource's `workspace` reference, mirroring the create path.\n const memberships = await listMembershipsOperation({\n context,\n tableName,\n mode: \"full\",\n });\n let usersInTenant = 0;\n for (const entry of memberships.entries) {\n const workspaceSlug = extractReferenceSlug(entry.resource, \"workspace\");\n if (workspaceSlug === undefined) {\n usersInTenant += 1;\n }\n }\n\n const current = await service.entities.tenant\n .get({ tenantId, sk: \"CURRENT\" })\n .go();\n\n const drift: Array<CounterDriftEntry> = [];\n const recomputed: Record<TenantCounter, number> = {\n usersInTenant,\n workspacesInTenant,\n };\n\n for (const counter of Object.keys(recomputed) as Array<TenantCounter>) {\n const oldValue = counterValue(current.data?.[counter]);\n const newValue = recomputed[counter];\n if (oldValue !== newValue) {\n drift.push({\n target: COUNTER_TARGET.Tenant,\n id: tenantId,\n counter,\n old: oldValue,\n new: newValue,\n });\n }\n }\n\n if (drift.length > 0) {\n await service.entities.tenant\n .patch({ tenantId, sk: \"CURRENT\" })\n .set(recomputed)\n .go();\n }\n\n return { drift };\n}\n\n/**\n * Recompute and repair the three counters on one Workspace record.\n *\n * - `usersInWorkspace` pages every workspace-scoped Membership via\n * {@link membershipListByWorkspaceOperation} (ADR-018 pattern #2).\n * - `adminUsersInWorkspace` / `normalUsersInWorkspace` page every\n * workspace-scoped RoleAssignment via\n * {@link roleAssignmentListByWorkspaceOperation} (pattern #9), then\n * classify each with {@link isAdminRoleAssignment}. The projection row\n * does not carry the ADR-019 role level (its `summary` is the\n * id/displayName/status projection), so each assignment's canonical\n * RoleAssignment resource is read to extract the role level via\n * {@link extractRoleLevel} — the same signal the create path publishes.\n * The projection's `roleId` is passed alongside as the fallback signal.\n */\nexport async function reconcileWorkspaceCountersOperation(params: {\n readonly tenantId: string;\n readonly workspaceId: string;\n readonly tableName?: string;\n}): Promise<CounterReconcileResult> {\n const { tenantId, workspaceId, tableName } = params;\n const service = getDynamoControlService(tableName);\n\n // usersInWorkspace — page every workspace-scoped membership.\n let usersInWorkspace = 0;\n let membershipCursor: string | null = null;\n do {\n const page = await membershipListByWorkspaceOperation({\n tenantId,\n workspaceId,\n cursor: membershipCursor,\n tableName,\n });\n usersInWorkspace += page.items.length;\n membershipCursor = page.cursor;\n } while (membershipCursor !== null);\n\n // admin/normal — page every workspace-scoped role assignment, then read\n // each canonical RoleAssignment to recover the role level for the\n // admin/normal split (the projection summary does not carry it).\n let adminUsersInWorkspace = 0;\n let normalUsersInWorkspace = 0;\n let roleAssignmentCursor: string | null = null;\n do {\n const page = await roleAssignmentListByWorkspaceOperation({\n tenantId,\n workspaceId,\n cursor: roleAssignmentCursor,\n tableName,\n });\n for (const item of page.items) {\n const roleLevel = await readRoleLevel(\n service,\n tenantId,\n item.roleAssignmentId,\n );\n if (isAdminRoleAssignment({ roleLevel, roleId: item.roleId })) {\n adminUsersInWorkspace += 1;\n } else {\n normalUsersInWorkspace += 1;\n }\n }\n roleAssignmentCursor = page.cursor;\n } while (roleAssignmentCursor !== null);\n\n const current = await service.entities.workspace\n .get({ tenantId, id: workspaceId, sk: \"CURRENT\" })\n .go();\n\n const drift: Array<CounterDriftEntry> = [];\n const recomputed: Record<WorkspaceCounter, number> = {\n usersInWorkspace,\n adminUsersInWorkspace,\n normalUsersInWorkspace,\n };\n\n for (const counter of Object.keys(recomputed) as Array<WorkspaceCounter>) {\n const oldValue = counterValue(current.data?.[counter]);\n const newValue = recomputed[counter];\n if (oldValue !== newValue) {\n drift.push({\n target: COUNTER_TARGET.Workspace,\n id: workspaceId,\n tenantId,\n counter,\n old: oldValue,\n new: newValue,\n });\n }\n }\n\n if (drift.length > 0) {\n await service.entities.workspace\n .patch({ tenantId, id: workspaceId, sk: \"CURRENT\" })\n .set(recomputed)\n .go();\n }\n\n return { drift };\n}\n\n/**\n * Recompute and repair the two counters on one User record.\n *\n * Both derive from {@link countMembershipsByUserOperation} over the\n * ADR-018 user-partition projection lanes: `tenantsForUser` from the\n * `tenant` lane (pattern #3), `workspacesForUser` from the `workspace`\n * lane (pattern #4).\n */\nexport async function reconcileUserCountersOperation(params: {\n readonly userId: string;\n readonly tableName?: string;\n}): Promise<CounterReconcileResult> {\n const { userId, tableName } = params;\n const service = getDynamoControlService(tableName);\n\n const tenantsForUser = await countMembershipsByUserOperation({\n userId,\n mode: \"tenant\",\n tableName,\n });\n const workspacesForUser = await countMembershipsByUserOperation({\n userId,\n mode: \"workspace\",\n tableName,\n });\n\n const current = await service.entities.user\n .get({ id: userId, sk: \"CURRENT\" })\n .go();\n\n const drift: Array<CounterDriftEntry> = [];\n const recomputed: Record<UserCounter, number> = {\n tenantsForUser,\n workspacesForUser,\n };\n\n for (const counter of Object.keys(recomputed) as Array<UserCounter>) {\n const oldValue = counterValue(current.data?.[counter]);\n const newValue = recomputed[counter];\n if (oldValue !== newValue) {\n drift.push({\n target: COUNTER_TARGET.User,\n id: userId,\n counter,\n old: oldValue,\n new: newValue,\n });\n }\n }\n\n if (drift.length > 0) {\n await service.entities.user\n .patch({ id: userId, sk: \"CURRENT\" })\n .set(recomputed)\n .go();\n }\n\n return { drift };\n}\n\n/**\n * Read the ADR-019 role level off a canonical RoleAssignment so the\n * admin/normal split classifies the same way the create path published\n * it. Returns `undefined` when the record or its code is missing — the\n * classifier then falls back to the `roleId` signal.\n */\nasync function readRoleLevel(\n service: ReturnType<typeof getDynamoControlService>,\n tenantId: string,\n roleAssignmentId: string,\n): Promise<string | undefined> {\n const response = await service.entities.roleAssignment\n .get({ tenantId, id: roleAssignmentId, sk: \"CURRENT\" })\n .go();\n if (!response.data) {\n return undefined;\n }\n const resource = JSON.parse(response.data.resource) as Record<\n string,\n unknown\n >;\n return extractRoleLevel(resource);\n}\n","import {\n type CounterDriftEntry,\n reconcileTenantCountersOperation,\n reconcileUserCountersOperation,\n reconcileWorkspaceCountersOperation,\n} from \"./counter-reconcile-operation\";\nimport type { OpenHiContext } from \"../../../openhi-context\";\nimport { listTenantsOperation } from \"../tenant/tenant-list-operation\";\nimport { listUsersOperation } from \"../user/user-list-operation\";\nimport { listWorkspacesOperation } from \"../workspace/workspace-list-operation\";\n\n/**\n * ADR-028 counter-reconciliation driver — walks every canonical Tenant,\n * Workspace, and User, reconciles each record's denormalized counters\n * against canonical data, and accumulates a single drift report.\n *\n * Enumeration reuses the existing GSI1-sharded list operations\n * (`summary` mode — ids only, no per-record BatchGet hydration):\n *\n * - All Tenants via {@link listTenantsOperation}.\n * - Per tenant, all Workspaces in that tenant via\n * {@link listWorkspacesOperation} (the workspace GSI1 partition is\n * tenant-scoped).\n * - All Users via {@link listUsersOperation}.\n *\n * Each record is then handed to the matching per-target recompute\n * ({@link reconcileTenantCountersOperation},\n * {@link reconcileWorkspaceCountersOperation},\n * {@link reconcileUserCountersOperation}), which owns the SET-back repair\n * and returns the per-counter old → new drift it corrected.\n *\n * @see counter-reconcile-operation.ts — the per-target recompute + repair.\n */\n\n/** Totals summarizing one reconciliation run, alongside the per-counter drift list. */\nexport interface CounterReconcileReport {\n /** Every counter that changed across every record, in walk order. */\n readonly drift: Array<CounterDriftEntry>;\n /** How many canonical records of each kind were scanned. */\n readonly scanned: {\n readonly tenants: number;\n readonly workspaces: number;\n readonly users: number;\n };\n /** Total number of individual counters corrected (== `drift.length`). */\n readonly countersCorrected: number;\n}\n\n/** Minimal actor context the list operations need (they read only `tenantId`). */\nfunction driverContext(tenantId: string): OpenHiContext {\n return {\n tenantId,\n workspaceId: \"\",\n date: new Date().toISOString(),\n actorId: \"counter-reconciliation\",\n actorName: \"Counter Reconciliation Job\",\n actorType: \"internal-system\",\n source: \"step-function\",\n };\n}\n\n/**\n * Run a full reconciliation sweep across every Tenant, Workspace, and\n * User. Returns the accumulated drift report; the per-target operations\n * have already written the repairs by the time this resolves.\n */\nexport async function reconcileAllCountersOperation(\n params: {\n readonly tableName?: string;\n } = {},\n): Promise<CounterReconcileReport> {\n const { tableName } = params;\n const drift: Array<CounterDriftEntry> = [];\n let tenantsScanned = 0;\n let workspacesScanned = 0;\n let usersScanned = 0;\n\n // Tenants (and, per tenant, the tenant's workspaces).\n const tenants = await listTenantsOperation({\n context: driverContext(\"\"),\n tableName,\n mode: \"summary\",\n });\n for (const tenant of tenants.entries) {\n tenantsScanned += 1;\n const tenantResult = await reconcileTenantCountersOperation({\n tenantId: tenant.id,\n tableName,\n });\n drift.push(...tenantResult.drift);\n\n const workspaces = await listWorkspacesOperation({\n context: driverContext(tenant.id),\n tableName,\n mode: \"summary\",\n });\n for (const workspace of workspaces.entries) {\n workspacesScanned += 1;\n const workspaceResult = await reconcileWorkspaceCountersOperation({\n tenantId: tenant.id,\n workspaceId: workspace.id,\n tableName,\n });\n drift.push(...workspaceResult.drift);\n }\n }\n\n // Users (platform-wide, no tenant scope).\n const users = await listUsersOperation({\n context: driverContext(\"\"),\n tableName,\n mode: \"summary\",\n });\n for (const user of users.entries) {\n usersScanned += 1;\n const userResult = await reconcileUserCountersOperation({\n userId: user.id,\n tableName,\n });\n drift.push(...userResult.drift);\n }\n\n return {\n drift,\n scanned: {\n tenants: tenantsScanned,\n workspaces: workspacesScanned,\n users: usersScanned,\n },\n countersCorrected: drift.length,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuEA,SAAS,aAAa,OAAwB;AAC5C,SAAO,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,IAAI,QAAQ;AACvE;AAGA,SAAS,iBAAiB,UAAiC;AACzD,SAAO;AAAA,IACL;AAAA,IACA,aAAa;AAAA,IACb,OAAM,oBAAI,KAAK,GAAE,YAAY;AAAA,IAC7B,SAAS;AAAA,IACT,WAAW;AAAA,IACX,WAAW;AAAA,IACX,QAAQ;AAAA,EACV;AACF;AAeA,eAAsB,iCAAiC,QAGnB;AAClC,QAAM,EAAE,UAAU,UAAU,IAAI;AAChC,QAAM,UAAU,wBAAwB,SAAS;AACjD,QAAM,UAAU,iBAAiB,QAAQ;AAEzC,QAAM,mBAAmB,MAAM,wBAAwB;AAAA,IACrD;AAAA,IACA;AAAA,IACA,MAAM;AAAA,EACR,CAAC;AACD,QAAM,qBAAqB,iBAAiB;AAK5C,QAAM,cAAc,MAAM,yBAAyB;AAAA,IACjD;AAAA,IACA;AAAA,IACA,MAAM;AAAA,EACR,CAAC;AACD,MAAI,gBAAgB;AACpB,aAAW,SAAS,YAAY,SAAS;AACvC,UAAM,gBAAgB,qBAAqB,MAAM,UAAU,WAAW;AACtE,QAAI,kBAAkB,QAAW;AAC/B,uBAAiB;AAAA,IACnB;AAAA,EACF;AAEA,QAAM,UAAU,MAAM,QAAQ,SAAS,OACpC,IAAI,EAAE,UAAU,IAAI,UAAU,CAAC,EAC/B,GAAG;AAEN,QAAM,QAAkC,CAAC;AACzC,QAAM,aAA4C;AAAA,IAChD;AAAA,IACA;AAAA,EACF;AAEA,aAAW,WAAW,OAAO,KAAK,UAAU,GAA2B;AACrE,UAAM,WAAW,aAAa,QAAQ,OAAO,OAAO,CAAC;AACrD,UAAM,WAAW,WAAW,OAAO;AACnC,QAAI,aAAa,UAAU;AACzB,YAAM,KAAK;AAAA,QACT,QAAQ,eAAe;AAAA,QACvB,IAAI;AAAA,QACJ;AAAA,QACA,KAAK;AAAA,QACL,KAAK;AAAA,MACP,CAAC;AAAA,IACH;AAAA,EACF;AAEA,MAAI,MAAM,SAAS,GAAG;AACpB,UAAM,QAAQ,SAAS,OACpB,MAAM,EAAE,UAAU,IAAI,UAAU,CAAC,EACjC,IAAI,UAAU,EACd,GAAG;AAAA,EACR;AAEA,SAAO,EAAE,MAAM;AACjB;AAiBA,eAAsB,oCAAoC,QAItB;AAClC,QAAM,EAAE,UAAU,aAAa,UAAU,IAAI;AAC7C,QAAM,UAAU,wBAAwB,SAAS;AAGjD,MAAI,mBAAmB;AACvB,MAAI,mBAAkC;AACtC,KAAG;AACD,UAAM,OAAO,MAAM,mCAAmC;AAAA,MACpD;AAAA,MACA;AAAA,MACA,QAAQ;AAAA,MACR;AAAA,IACF,CAAC;AACD,wBAAoB,KAAK,MAAM;AAC/B,uBAAmB,KAAK;AAAA,EAC1B,SAAS,qBAAqB;AAK9B,MAAI,wBAAwB;AAC5B,MAAI,yBAAyB;AAC7B,MAAI,uBAAsC;AAC1C,KAAG;AACD,UAAM,OAAO,MAAM,uCAAuC;AAAA,MACxD;AAAA,MACA;AAAA,MACA,QAAQ;AAAA,MACR;AAAA,IACF,CAAC;AACD,eAAW,QAAQ,KAAK,OAAO;AAC7B,YAAM,YAAY,MAAM;AAAA,QACtB;AAAA,QACA;AAAA,QACA,KAAK;AAAA,MACP;AACA,UAAI,sBAAsB,EAAE,WAAW,QAAQ,KAAK,OAAO,CAAC,GAAG;AAC7D,iCAAyB;AAAA,MAC3B,OAAO;AACL,kCAA0B;AAAA,MAC5B;AAAA,IACF;AACA,2BAAuB,KAAK;AAAA,EAC9B,SAAS,yBAAyB;AAElC,QAAM,UAAU,MAAM,QAAQ,SAAS,UACpC,IAAI,EAAE,UAAU,IAAI,aAAa,IAAI,UAAU,CAAC,EAChD,GAAG;AAEN,QAAM,QAAkC,CAAC;AACzC,QAAM,aAA+C;AAAA,IACnD;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAEA,aAAW,WAAW,OAAO,KAAK,UAAU,GAA8B;AACxE,UAAM,WAAW,aAAa,QAAQ,OAAO,OAAO,CAAC;AACrD,UAAM,WAAW,WAAW,OAAO;AACnC,QAAI,aAAa,UAAU;AACzB,YAAM,KAAK;AAAA,QACT,QAAQ,eAAe;AAAA,QACvB,IAAI;AAAA,QACJ;AAAA,QACA;AAAA,QACA,KAAK;AAAA,QACL,KAAK;AAAA,MACP,CAAC;AAAA,IACH;AAAA,EACF;AAEA,MAAI,MAAM,SAAS,GAAG;AACpB,UAAM,QAAQ,SAAS,UACpB,MAAM,EAAE,UAAU,IAAI,aAAa,IAAI,UAAU,CAAC,EAClD,IAAI,UAAU,EACd,GAAG;AAAA,EACR;AAEA,SAAO,EAAE,MAAM;AACjB;AAUA,eAAsB,+BAA+B,QAGjB;AAClC,QAAM,EAAE,QAAQ,UAAU,IAAI;AAC9B,QAAM,UAAU,wBAAwB,SAAS;AAEjD,QAAM,iBAAiB,MAAM,gCAAgC;AAAA,IAC3D;AAAA,IACA,MAAM;AAAA,IACN;AAAA,EACF,CAAC;AACD,QAAM,oBAAoB,MAAM,gCAAgC;AAAA,IAC9D;AAAA,IACA,MAAM;AAAA,IACN;AAAA,EACF,CAAC;AAED,QAAM,UAAU,MAAM,QAAQ,SAAS,KACpC,IAAI,EAAE,IAAI,QAAQ,IAAI,UAAU,CAAC,EACjC,GAAG;AAEN,QAAM,QAAkC,CAAC;AACzC,QAAM,aAA0C;AAAA,IAC9C;AAAA,IACA;AAAA,EACF;AAEA,aAAW,WAAW,OAAO,KAAK,UAAU,GAAyB;AACnE,UAAM,WAAW,aAAa,QAAQ,OAAO,OAAO,CAAC;AACrD,UAAM,WAAW,WAAW,OAAO;AACnC,QAAI,aAAa,UAAU;AACzB,YAAM,KAAK;AAAA,QACT,QAAQ,eAAe;AAAA,QACvB,IAAI;AAAA,QACJ;AAAA,QACA,KAAK;AAAA,QACL,KAAK;AAAA,MACP,CAAC;AAAA,IACH;AAAA,EACF;AAEA,MAAI,MAAM,SAAS,GAAG;AACpB,UAAM,QAAQ,SAAS,KACpB,MAAM,EAAE,IAAI,QAAQ,IAAI,UAAU,CAAC,EACnC,IAAI,UAAU,EACd,GAAG;AAAA,EACR;AAEA,SAAO,EAAE,MAAM;AACjB;AAQA,eAAe,cACb,SACA,UACA,kBAC6B;AAC7B,QAAM,WAAW,MAAM,QAAQ,SAAS,eACrC,IAAI,EAAE,UAAU,IAAI,kBAAkB,IAAI,UAAU,CAAC,EACrD,GAAG;AACN,MAAI,CAAC,SAAS,MAAM;AAClB,WAAO;AAAA,EACT;AACA,QAAM,WAAW,KAAK,MAAM,SAAS,KAAK,QAAQ;AAIlD,SAAO,iBAAiB,QAAQ;AAClC;;;AC5SA,SAAS,cAAc,UAAiC;AACtD,SAAO;AAAA,IACL;AAAA,IACA,aAAa;AAAA,IACb,OAAM,oBAAI,KAAK,GAAE,YAAY;AAAA,IAC7B,SAAS;AAAA,IACT,WAAW;AAAA,IACX,WAAW;AAAA,IACX,QAAQ;AAAA,EACV;AACF;AAOA,eAAsB,8BACpB,SAEI,CAAC,GAC4B;AACjC,QAAM,EAAE,UAAU,IAAI;AACtB,QAAM,QAAkC,CAAC;AACzC,MAAI,iBAAiB;AACrB,MAAI,oBAAoB;AACxB,MAAI,eAAe;AAGnB,QAAM,UAAU,MAAM,qBAAqB;AAAA,IACzC,SAAS,cAAc,EAAE;AAAA,IACzB;AAAA,IACA,MAAM;AAAA,EACR,CAAC;AACD,aAAW,UAAU,QAAQ,SAAS;AACpC,sBAAkB;AAClB,UAAM,eAAe,MAAM,iCAAiC;AAAA,MAC1D,UAAU,OAAO;AAAA,MACjB;AAAA,IACF,CAAC;AACD,UAAM,KAAK,GAAG,aAAa,KAAK;AAEhC,UAAM,aAAa,MAAM,wBAAwB;AAAA,MAC/C,SAAS,cAAc,OAAO,EAAE;AAAA,MAChC;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AACD,eAAW,aAAa,WAAW,SAAS;AAC1C,2BAAqB;AACrB,YAAM,kBAAkB,MAAM,oCAAoC;AAAA,QAChE,UAAU,OAAO;AAAA,QACjB,aAAa,UAAU;AAAA,QACvB;AAAA,MACF,CAAC;AACD,YAAM,KAAK,GAAG,gBAAgB,KAAK;AAAA,IACrC;AAAA,EACF;AAGA,QAAM,QAAQ,MAAM,mBAAmB;AAAA,IACrC,SAAS,cAAc,EAAE;AAAA,IACzB;AAAA,IACA,MAAM;AAAA,EACR,CAAC;AACD,aAAW,QAAQ,MAAM,SAAS;AAChC,oBAAgB;AAChB,UAAM,aAAa,MAAM,+BAA+B;AAAA,MACtD,QAAQ,KAAK;AAAA,MACb;AAAA,IACF,CAAC;AACD,UAAM,KAAK,GAAG,WAAW,KAAK;AAAA,EAChC;AAEA,SAAO;AAAA,IACL;AAAA,IACA,SAAS;AAAA,MACP,SAAS;AAAA,MACT,YAAY;AAAA,MACZ,OAAO;AAAA,IACT;AAAA,IACA,mBAAmB,MAAM;AAAA,EAC3B;AACF;","names":[]}
@@ -5,7 +5,7 @@ import {
5
5
  batchGetWithRetry,
6
6
  dispatchListMode,
7
7
  listDataEntitiesByWorkspace
8
- } from "./chunk-FDBBTNCI.mjs";
8
+ } from "./chunk-Q4KQD2NB.mjs";
9
9
  import {
10
10
  SHARD_COUNT,
11
11
  getDynamoControlService
@@ -37,9 +37,15 @@ async function listRoleAssignmentsOperation(params) {
37
37
  )
38
38
  );
39
39
  return dispatchListMode(mode, shardResults, {
40
+ // Strongly-consistent BatchGet on the base table so a role change is
41
+ // reflected immediately by the read-back the admin-console dropdowns
42
+ // issue right after their PUT/POST. Without it the eventually-consistent
43
+ // hydration can return the *previous* role, making the dropdown appear
44
+ // to revert (#1347).
40
45
  hydrate: (orderedIds) => batchGetWithRetry(
41
46
  service.entities.roleAssignment,
42
- orderedIds.map((id) => ({ tenantId, id, sk: SK }))
47
+ orderedIds.map((id) => ({ tenantId, id, sk: SK })),
48
+ { consistent: true }
43
49
  ),
44
50
  getId: (item) => item.id,
45
51
  buildEntry: (id, item) => ({
@@ -61,4 +67,4 @@ export {
61
67
  listPractitionerRolesOperation,
62
68
  listRoleAssignmentsOperation
63
69
  };
64
- //# sourceMappingURL=chunk-GG2WD6TA.mjs.map
70
+ //# sourceMappingURL=chunk-JJ3AQ6G5.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/data/operations/data/practitionerrole/practitionerrole-list-operation.ts","../src/data/operations/control/roleassignment/roleassignment-list-operation.ts"],"sourcesContent":["import type { PractitionerRole } from \"@openhi/types\";\nimport { getDynamoDataService } from \"../../../dynamo/dynamo-data-service\";\nimport {\n type ListParams,\n listDataEntitiesByWorkspace,\n type ListResult,\n type ListEntry,\n} from \"../../data-operations-common\";\n\n/**\n * List PractitionerRoles in a workspace (GSI1, sharded). Returns domain result for adapters to map to FHIR Bundle or other formats.\n *\n * @see sites/www-docs/content/packages/@openhi/constructs/data/shared-data-layer-layout.md\n */\nexport type ListPractitionerRolesParams = ListParams;\n\nexport type PractitionerRoleListEntry = ListEntry<PractitionerRole>;\n\nexport type ListPractitionerRolesResult = ListResult<PractitionerRole>;\n\n/**\n * Lists all PractitionerRoles in the workspace. Uses GSI1 (Unified Sharded List per ADR-011).\n * Throws on service errors; adapters map to HTTP/GraphQL/Step Function.\n */\nexport async function listPractitionerRolesOperation(\n params: ListPractitionerRolesParams,\n): Promise<ListPractitionerRolesResult> {\n const { context, tableName, mode } = params;\n const { tenantId, workspaceId } = context;\n const service = getDynamoDataService(tableName);\n return listDataEntitiesByWorkspace<PractitionerRole>(\n service.entities.practitionerrole as Parameters<\n typeof listDataEntitiesByWorkspace\n >[0],\n tenantId,\n workspaceId,\n mode,\n );\n}\n","import { getDynamoControlService } from \"../../../dynamo/dynamo-control-service\";\nimport { SHARD_COUNT } from \"../../../dynamo/shard\";\nimport { OpenHiContext } from \"../../../openhi-context\";\nimport {\n batchGetWithRetry,\n dispatchListMode,\n type ListOperationMode,\n} from \"../../data-operations-common\";\n\nconst SK = \"CURRENT\";\n\nexport interface RoleAssignmentListParams {\n context: OpenHiContext;\n tableName?: string;\n /** #853: defaults to `\"full\"`. `\"summary\"` skips BatchGet, `\"count\"` returns total only. */\n mode?: ListOperationMode;\n}\n\nexport interface RoleAssignmentListResult {\n entries: Array<{\n id: string;\n resource: { resourceType: string; id: string; [key: string]: unknown };\n }>;\n total: number;\n}\n\n/**\n * Lists RoleAssignments for the context tenant via GSI1 (sharded). See `dispatchListMode` for\n * the mode contract (#853).\n */\nexport async function listRoleAssignmentsOperation(\n params: RoleAssignmentListParams,\n): Promise<RoleAssignmentListResult> {\n const { context, tableName, mode = \"full\" } = params;\n const tenantId = context.tenantId;\n const service = getDynamoControlService(tableName);\n\n const shardResults = await Promise.all(\n Array.from({ length: SHARD_COUNT }, (_, shard) =>\n service.entities.roleAssignment.query\n .gsi1({ tenantId, gsi1Shard: String(shard) })\n .go(),\n ),\n );\n\n return dispatchListMode<\n { id: string; resource: string },\n RoleAssignmentListResult[\"entries\"][number]\n >(mode, shardResults, {\n hydrate: (orderedIds) =>\n batchGetWithRetry(\n service.entities.roleAssignment,\n orderedIds.map((id) => ({ tenantId, id, sk: SK })),\n ) as Promise<Array<{ id: string; resource: string }>>,\n getId: (item) => item.id,\n buildEntry: (id, item) => ({\n id,\n resource: {\n resourceType: \"RoleAssignment\",\n id,\n ...(JSON.parse(item.resource) as Record<string, unknown>),\n },\n }),\n buildSummaryEntry: (id, parsed) => ({\n id,\n resource: { resourceType: \"RoleAssignment\", id, ...parsed },\n }),\n });\n}\n"],"mappings":";;;;;;;;;;;;;;AAwBA,eAAsB,+BACpB,QACsC;AACtC,QAAM,EAAE,SAAS,WAAW,KAAK,IAAI;AACrC,QAAM,EAAE,UAAU,YAAY,IAAI;AAClC,QAAM,UAAU,qBAAqB,SAAS;AAC9C,SAAO;AAAA,IACL,QAAQ,SAAS;AAAA,IAGjB;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;;;AC7BA,IAAM,KAAK;AAqBX,eAAsB,6BACpB,QACmC;AACnC,QAAM,EAAE,SAAS,WAAW,OAAO,OAAO,IAAI;AAC9C,QAAM,WAAW,QAAQ;AACzB,QAAM,UAAU,wBAAwB,SAAS;AAEjD,QAAM,eAAe,MAAM,QAAQ;AAAA,IACjC,MAAM;AAAA,MAAK,EAAE,QAAQ,YAAY;AAAA,MAAG,CAAC,GAAG,UACtC,QAAQ,SAAS,eAAe,MAC7B,KAAK,EAAE,UAAU,WAAW,OAAO,KAAK,EAAE,CAAC,EAC3C,GAAG;AAAA,IACR;AAAA,EACF;AAEA,SAAO,iBAGL,MAAM,cAAc;AAAA,IACpB,SAAS,CAAC,eACR;AAAA,MACE,QAAQ,SAAS;AAAA,MACjB,WAAW,IAAI,CAAC,QAAQ,EAAE,UAAU,IAAI,IAAI,GAAG,EAAE;AAAA,IACnD;AAAA,IACF,OAAO,CAAC,SAAS,KAAK;AAAA,IACtB,YAAY,CAAC,IAAI,UAAU;AAAA,MACzB;AAAA,MACA,UAAU;AAAA,QACR,cAAc;AAAA,QACd;AAAA,QACA,GAAI,KAAK,MAAM,KAAK,QAAQ;AAAA,MAC9B;AAAA,IACF;AAAA,IACA,mBAAmB,CAAC,IAAI,YAAY;AAAA,MAClC;AAAA,MACA,UAAU,EAAE,cAAc,kBAAkB,IAAI,GAAG,OAAO;AAAA,IAC5D;AAAA,EACF,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../src/data/operations/data/practitionerrole/practitionerrole-list-operation.ts","../src/data/operations/control/roleassignment/roleassignment-list-operation.ts"],"sourcesContent":["import type { PractitionerRole } from \"@openhi/types\";\nimport { getDynamoDataService } from \"../../../dynamo/dynamo-data-service\";\nimport {\n type ListParams,\n listDataEntitiesByWorkspace,\n type ListResult,\n type ListEntry,\n} from \"../../data-operations-common\";\n\n/**\n * List PractitionerRoles in a workspace (GSI1, sharded). Returns domain result for adapters to map to FHIR Bundle or other formats.\n *\n * @see sites/www-docs/content/packages/@openhi/constructs/data/shared-data-layer-layout.md\n */\nexport type ListPractitionerRolesParams = ListParams;\n\nexport type PractitionerRoleListEntry = ListEntry<PractitionerRole>;\n\nexport type ListPractitionerRolesResult = ListResult<PractitionerRole>;\n\n/**\n * Lists all PractitionerRoles in the workspace. Uses GSI1 (Unified Sharded List per ADR-011).\n * Throws on service errors; adapters map to HTTP/GraphQL/Step Function.\n */\nexport async function listPractitionerRolesOperation(\n params: ListPractitionerRolesParams,\n): Promise<ListPractitionerRolesResult> {\n const { context, tableName, mode } = params;\n const { tenantId, workspaceId } = context;\n const service = getDynamoDataService(tableName);\n return listDataEntitiesByWorkspace<PractitionerRole>(\n service.entities.practitionerrole as Parameters<\n typeof listDataEntitiesByWorkspace\n >[0],\n tenantId,\n workspaceId,\n mode,\n );\n}\n","import { getDynamoControlService } from \"../../../dynamo/dynamo-control-service\";\nimport { SHARD_COUNT } from \"../../../dynamo/shard\";\nimport { OpenHiContext } from \"../../../openhi-context\";\nimport {\n batchGetWithRetry,\n dispatchListMode,\n type ListOperationMode,\n} from \"../../data-operations-common\";\n\nconst SK = \"CURRENT\";\n\nexport interface RoleAssignmentListParams {\n context: OpenHiContext;\n tableName?: string;\n /** #853: defaults to `\"full\"`. `\"summary\"` skips BatchGet, `\"count\"` returns total only. */\n mode?: ListOperationMode;\n}\n\nexport interface RoleAssignmentListResult {\n entries: Array<{\n id: string;\n resource: { resourceType: string; id: string; [key: string]: unknown };\n }>;\n total: number;\n}\n\n/**\n * Lists RoleAssignments for the context tenant via GSI1 (sharded). See `dispatchListMode` for\n * the mode contract (#853).\n */\nexport async function listRoleAssignmentsOperation(\n params: RoleAssignmentListParams,\n): Promise<RoleAssignmentListResult> {\n const { context, tableName, mode = \"full\" } = params;\n const tenantId = context.tenantId;\n const service = getDynamoControlService(tableName);\n\n const shardResults = await Promise.all(\n Array.from({ length: SHARD_COUNT }, (_, shard) =>\n service.entities.roleAssignment.query\n .gsi1({ tenantId, gsi1Shard: String(shard) })\n .go(),\n ),\n );\n\n return dispatchListMode<\n { id: string; resource: string },\n RoleAssignmentListResult[\"entries\"][number]\n >(mode, shardResults, {\n // Strongly-consistent BatchGet on the base table so a role change is\n // reflected immediately by the read-back the admin-console dropdowns\n // issue right after their PUT/POST. Without it the eventually-consistent\n // hydration can return the *previous* role, making the dropdown appear\n // to revert (#1347).\n hydrate: (orderedIds) =>\n batchGetWithRetry(\n service.entities.roleAssignment,\n orderedIds.map((id) => ({ tenantId, id, sk: SK })),\n { consistent: true },\n ) as Promise<Array<{ id: string; resource: string }>>,\n getId: (item) => item.id,\n buildEntry: (id, item) => ({\n id,\n resource: {\n resourceType: \"RoleAssignment\",\n id,\n ...(JSON.parse(item.resource) as Record<string, unknown>),\n },\n }),\n buildSummaryEntry: (id, parsed) => ({\n id,\n resource: { resourceType: \"RoleAssignment\", id, ...parsed },\n }),\n });\n}\n"],"mappings":";;;;;;;;;;;;;;AAwBA,eAAsB,+BACpB,QACsC;AACtC,QAAM,EAAE,SAAS,WAAW,KAAK,IAAI;AACrC,QAAM,EAAE,UAAU,YAAY,IAAI;AAClC,QAAM,UAAU,qBAAqB,SAAS;AAC9C,SAAO;AAAA,IACL,QAAQ,SAAS;AAAA,IAGjB;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;;;AC7BA,IAAM,KAAK;AAqBX,eAAsB,6BACpB,QACmC;AACnC,QAAM,EAAE,SAAS,WAAW,OAAO,OAAO,IAAI;AAC9C,QAAM,WAAW,QAAQ;AACzB,QAAM,UAAU,wBAAwB,SAAS;AAEjD,QAAM,eAAe,MAAM,QAAQ;AAAA,IACjC,MAAM;AAAA,MAAK,EAAE,QAAQ,YAAY;AAAA,MAAG,CAAC,GAAG,UACtC,QAAQ,SAAS,eAAe,MAC7B,KAAK,EAAE,UAAU,WAAW,OAAO,KAAK,EAAE,CAAC,EAC3C,GAAG;AAAA,IACR;AAAA,EACF;AAEA,SAAO,iBAGL,MAAM,cAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMpB,SAAS,CAAC,eACR;AAAA,MACE,QAAQ,SAAS;AAAA,MACjB,WAAW,IAAI,CAAC,QAAQ,EAAE,UAAU,IAAI,IAAI,GAAG,EAAE;AAAA,MACjD,EAAE,YAAY,KAAK;AAAA,IACrB;AAAA,IACF,OAAO,CAAC,SAAS,KAAK;AAAA,IACtB,YAAY,CAAC,IAAI,UAAU;AAAA,MACzB;AAAA,MACA,UAAU;AAAA,QACR,cAAc;AAAA,QACd;AAAA,QACA,GAAI,KAAK,MAAM,KAAK,QAAQ;AAAA,MAC9B;AAAA,IACF;AAAA,IACA,mBAAmB,CAAC,IAAI,YAAY;AAAA,MAClC;AAAA,MACA,UAAU,EAAE,cAAc,kBAAkB,IAAI,GAAG,OAAO;AAAA,IAC5D;AAAA,EACF,CAAC;AACH;","names":[]}
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  batchGetWithRetry,
3
3
  dispatchListMode
4
- } from "./chunk-FDBBTNCI.mjs";
4
+ } from "./chunk-Q4KQD2NB.mjs";
5
5
  import {
6
6
  SHARD_COUNT,
7
7
  getDynamoControlService
@@ -43,4 +43,4 @@ async function listMembershipsOperation(params) {
43
43
  export {
44
44
  listMembershipsOperation
45
45
  };
46
- //# sourceMappingURL=chunk-EBB4RNUG.mjs.map
46
+ //# sourceMappingURL=chunk-PIQISEGW.mjs.map
@@ -6,7 +6,8 @@ import {
6
6
  NotFoundError
7
7
  } from "./chunk-FYHBHHWK.mjs";
8
8
  import {
9
- SHARD_COUNT
9
+ SHARD_COUNT,
10
+ getDynamoControlService
10
11
  } from "./chunk-EUIP2U5F.mjs";
11
12
 
12
13
  // src/data/audit-meta.ts
@@ -64,7 +65,7 @@ async function deleteDataEntityById(entity, tenantId, workspaceId, id) {
64
65
  }
65
66
  var BATCH_GET_MAX_ATTEMPTS = 3;
66
67
  var BATCH_GET_BASE_BACKOFF_MS = 50;
67
- async function batchGetWithRetry(entity, keys) {
68
+ async function batchGetWithRetry(entity, keys, options) {
68
69
  if (keys.length === 0) return [];
69
70
  const collected = [];
70
71
  let pending = keys;
@@ -76,7 +77,7 @@ async function batchGetWithRetry(entity, keys) {
76
77
  );
77
78
  }
78
79
  attempt++;
79
- const result = await entity.get(pending).go();
80
+ const result = await entity.get(pending).go(options?.consistent ? { consistent: true } : void 0);
80
81
  collected.push(...result.data);
81
82
  const unprocessed = result.unprocessed ?? [];
82
83
  if (unprocessed.length === 0) break;
@@ -244,6 +245,114 @@ async function updateDataEntityById(entity, tenantId, workspaceId, id, resourceL
244
245
  };
245
246
  }
246
247
 
248
+ // src/data/operations/control/user/user-list-operation.ts
249
+ var SK = "CURRENT";
250
+ function counterValue(value) {
251
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
252
+ }
253
+ async function listUsersOperation(params) {
254
+ const { tableName, mode = "full" } = params;
255
+ const service = getDynamoControlService(tableName);
256
+ const shardResults = await Promise.all(
257
+ Array.from(
258
+ { length: SHARD_COUNT },
259
+ (_, shard) => service.entities.user.query.gsi1({ gsi1Shard: String(shard) }).go()
260
+ )
261
+ );
262
+ return dispatchListMode(mode, shardResults, {
263
+ hydrate: (orderedIds) => batchGetWithRetry(
264
+ service.entities.user,
265
+ orderedIds.map((id) => ({ id, sk: SK }))
266
+ ),
267
+ getId: (item) => item.id,
268
+ // FULL mode (admin list default): read the ADR-028 counters off the
269
+ // canonical record hydrated by BatchGet and expose them as
270
+ // `resource.counts`. Missing counters render as 0.
271
+ buildEntry: (id, item) => ({
272
+ id,
273
+ resource: {
274
+ resourceType: "User",
275
+ id,
276
+ ...JSON.parse(item.resource),
277
+ counts: {
278
+ tenantsForUser: counterValue(item.tenantsForUser),
279
+ workspacesForUser: counterValue(item.workspacesForUser)
280
+ }
281
+ }
282
+ }),
283
+ // SUMMARY mode reads only the GSI1 `summary` projection (no
284
+ // counters); surface zeros so the shape stays uniform.
285
+ buildSummaryEntry: (id, parsed) => ({
286
+ id,
287
+ resource: {
288
+ resourceType: "User",
289
+ id,
290
+ ...parsed,
291
+ counts: { tenantsForUser: 0, workspacesForUser: 0 }
292
+ }
293
+ })
294
+ });
295
+ }
296
+
297
+ // src/data/operations/control/membership/membership-list-by-user-operation.ts
298
+ function buildSkPrefix(mode, tenantId) {
299
+ switch (mode) {
300
+ case "tenant":
301
+ return "MEMBERSHIP#TENANT#";
302
+ case "workspace":
303
+ return "MEMBERSHIP#WORKSPACE#";
304
+ case "workspaceInTenant":
305
+ return `MEMBERSHIP#WORKSPACE#TID#${tenantId}#`;
306
+ case "all":
307
+ default:
308
+ return "MEMBERSHIP#";
309
+ }
310
+ }
311
+ async function membershipListByUserOperation(params) {
312
+ const {
313
+ userId,
314
+ mode = "all",
315
+ tenantId,
316
+ cursor = null,
317
+ limit,
318
+ order,
319
+ tableName
320
+ } = params;
321
+ if (mode === "workspaceInTenant" && !tenantId) {
322
+ throw new Error(
323
+ 'membershipListByUserOperation: tenantId is required when mode === "workspaceInTenant"'
324
+ );
325
+ }
326
+ const service = getDynamoControlService(tableName);
327
+ const skPrefix = buildSkPrefix(mode, tenantId);
328
+ const goOptions = {
329
+ cursor
330
+ };
331
+ if (limit !== void 0) {
332
+ goOptions.limit = limit;
333
+ }
334
+ if (order !== void 0) {
335
+ goOptions.order = order;
336
+ }
337
+ const result = await service.entities.membershipUserProjection.query.record({ userId }).begins({ sk: skPrefix }).go(goOptions);
338
+ const items = (result.data ?? []).map(
339
+ (row) => ({
340
+ userId: row.userId,
341
+ sk: row.sk,
342
+ tenantId: row.tenantId,
343
+ workspaceId: row.workspaceId,
344
+ membershipId: row.membershipId,
345
+ summary: row.summary,
346
+ vid: row.vid,
347
+ lastUpdated: row.lastUpdated,
348
+ denormalizedTenantName: row.denormalizedTenantName,
349
+ denormalizedUserName: row.denormalizedUserName,
350
+ denormalizedWorkspaceName: row.denormalizedWorkspaceName
351
+ })
352
+ );
353
+ return { items, cursor: result.cursor ?? null };
354
+ }
355
+
247
356
  export {
248
357
  mergeAuditIntoMeta,
249
358
  DATA_ENTITY_SK,
@@ -254,6 +363,9 @@ export {
254
363
  listDataEntitiesByWorkspace,
255
364
  createDataEntityRecord,
256
365
  buildUpdatedResourceWithAudit,
257
- updateDataEntityById
366
+ updateDataEntityById,
367
+ buildSkPrefix,
368
+ membershipListByUserOperation,
369
+ listUsersOperation
258
370
  };
259
- //# sourceMappingURL=chunk-FDBBTNCI.mjs.map
371
+ //# sourceMappingURL=chunk-Q4KQD2NB.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/data/audit-meta.ts","../src/data/operations/data-operations-common.ts","../src/data/operations/control/user/user-list-operation.ts","../src/data/operations/control/membership/membership-list-by-user-operation.ts"],"sourcesContent":["import type { Extension, Meta } from \"@openhi/types\";\n\n/**\n * Shared audit/meta helpers for FHIR resources. Used by data operations and import.\n * OpenHI audit is stored in resource meta.extension (per ADR 2026-01-13-06).\n *\n * @see sites/www-docs/content/packages/@openhi/constructs/data/dynamo/entity-standards.md\n */\n\n/** OpenHI extension URLs for audit in resource meta (per ADR 2026-01-13-06). */\nconst OPENHI_EXT = \"http://openhi.org/fhir/StructureDefinition\";\n\n/** Meta with optional OpenHI audit extensions (created/modified by, etc.). */\nexport type MetaWithExtensions = Meta & { extension?: Array<Extension> };\n\n/** Audit fields stored in FHIR resource meta.extension. */\nexport interface AuditFields {\n createdDate?: string;\n createdById?: string;\n createdByName?: string;\n modifiedDate?: string;\n modifiedById?: string;\n modifiedByName?: string;\n deletedDate?: string;\n deletedById?: string;\n deletedByName?: string;\n}\n\n/** Audit extension entry shape (subset of Extension used by OpenHI audit). */\ntype AuditExtensionEntry = Pick<\n Extension,\n \"url\" | \"valueString\" | \"valueDateTime\"\n>;\n\n/** Builds meta.extension entries for audit; merges with existing meta. */\nexport function mergeAuditIntoMeta(\n meta: MetaWithExtensions | Record<string, unknown> | undefined,\n audit: AuditFields,\n): MetaWithExtensions {\n const existing = (meta ?? {}) as MetaWithExtensions;\n const ext: Array<AuditExtensionEntry> = [\n ...(Array.isArray(existing.extension)\n ? (existing.extension as Array<AuditExtensionEntry>)\n : []),\n ];\n const byUrl = new Map(ext.map((e) => [e.url, e]));\n function set(\n url: string,\n value: string | undefined,\n type: \"valueString\" | \"valueDateTime\",\n ) {\n if (value == null) return;\n byUrl.set(url, { url, [type]: value });\n }\n set(`${OPENHI_EXT}/created-date`, audit.createdDate, \"valueDateTime\");\n set(`${OPENHI_EXT}/created-by-id`, audit.createdById, \"valueString\");\n set(`${OPENHI_EXT}/created-by-name`, audit.createdByName, \"valueString\");\n set(`${OPENHI_EXT}/modified-date`, audit.modifiedDate, \"valueDateTime\");\n set(`${OPENHI_EXT}/modified-by-id`, audit.modifiedById, \"valueString\");\n set(`${OPENHI_EXT}/modified-by-name`, audit.modifiedByName, \"valueString\");\n set(`${OPENHI_EXT}/deleted-date`, audit.deletedDate, \"valueDateTime\");\n set(`${OPENHI_EXT}/deleted-by-id`, audit.deletedById, \"valueString\");\n set(`${OPENHI_EXT}/deleted-by-name`, audit.deletedByName, \"valueString\");\n return { ...existing, extension: Array.from(byUrl.values()) };\n}\n","import { extractSortKey, extractSummary } from \"@openhi/types\";\nimport type { Meta, FhirResourceLike } from \"@openhi/types\";\nimport { compressResource, decompressResource } from \"../../lib/compression\";\nimport { mergeAuditIntoMeta, type MetaWithExtensions } from \"../audit-meta\";\nimport { SHARD_COUNT } from \"../dynamo/shard\";\nimport { NotFoundError } from \"../errors\";\nimport type { OpenHiContext } from \"../openhi-context\";\n\n/**\n * Sort key for the current record version. Matches Dynamo record index SK default.\n * Use this in get/update/delete (and create where applicable) for data-plane entities.\n */\nexport const DATA_ENTITY_SK = \"CURRENT\" as const;\n\n/** Base params for data-entity operations: context and optional table override. */\nexport interface BaseDataEntityParams {\n context: OpenHiContext;\n /** Optional table name override; resolved by data service from DYNAMO_TABLE_NAME when omitted. */\n tableName?: string;\n}\n\n/** Params for get-by-id and delete (context + id + optional tableName). */\nexport interface GetByIdParams extends BaseDataEntityParams {\n id: string;\n}\n\n/**\n * Mode for list operations introduced by #853 to back FHIR `_summary` opt-ins.\n *\n * - `full` (default): GSI1 fan-out → BatchGet hydration → full FHIR resource per entry.\n * - `summary`: GSI1 fan-out only; the `summary` JSON projected onto GSI1 is parsed and used\n * as `resource` per entry. Skips BatchGet entirely — that's the cost win the FHIR spec\n * intends `_summary=true` to deliver.\n * - `count`: GSI1 fan-out only; entries are dropped, only `total` is populated. Routes\n * should pass `total` into `buildSearchsetBundle({ mode: \"count\", total })`.\n *\n * `_elements` is implemented at the route layer as `full` mode + post-hydration pruning,\n * since per-element pruning has to happen after decompression and is FHIR-spec-defined\n * (always retain root-level mandatories — see `prune-resource-by-elements.ts`).\n */\nexport type ListOperationMode = \"full\" | \"summary\" | \"count\";\n\n/** Params for list (context + optional tableName + optional mode for #853 `_summary`). */\nexport interface ListParams extends BaseDataEntityParams {\n /** Defaults to `\"full\"` (current behavior); routes pass other modes for `_summary` opt-ins. */\n mode?: ListOperationMode;\n}\n\n/** Result for create / get-by-id / update: single resource. */\nexport interface SingleResourceResult<T> {\n id: string;\n resource: T;\n}\n\n/** Entry shape for list results. */\nexport interface ListEntry<T> {\n id: string;\n resource: T;\n}\n\n/**\n * Result for list: entries array plus total count.\n *\n * - For `mode === \"full\"` and `mode === \"summary\"`, `total === entries.length`.\n * - For `mode === \"count\"`, `entries` is empty and `total` is the GSI1 fan-out count.\n *\n * Splitting `total` from `entries.length` lets count-mode routes report a true count\n * while skipping any per-entry work.\n */\nexport interface ListResult<T> {\n entries: Array<ListEntry<T>>;\n total: number;\n}\n\n/** Minimal entity shape for get (used by getDataEntityById). */\ninterface EntityWithGet {\n get(params: {\n tenantId: string;\n workspaceId: string;\n id: string;\n sk: string;\n }): { go(): Promise<{ data: { id: string; resource: string } | null }> };\n}\n\n/** Minimal entity shape for delete (used by deleteDataEntityById). */\ninterface EntityWithDelete {\n delete(params: {\n tenantId: string;\n workspaceId: string;\n id: string;\n sk: string;\n }): { go(): Promise<unknown> };\n}\n\n/**\n * Minimal entity shape for list via GSI1 + BatchGet hydration (used by listDataEntitiesByWorkspace).\n * GSI1 is sharded per ADR-011, so listing fans out to each shard and concatenates ids; the\n * `resource` attribute is NOT projected onto GSI1 (per the summary projection in\n * `dynamo-db-data-store.ts`), so the second phase BatchGets the base table for full items.\n *\n * GSI1 INCLUDE projection (per `dynamo-db-data-store.ts`) carries `summary`, `vid`, `lastUpdated`,\n * `createdDate`, `modifiedDate`, `createdById`, `modifiedById` alongside the key attributes.\n * `summary` is what `mode: \"summary\"` returns to the caller without hitting the base table.\n */\ninterface DataEntityWithListAndBatchGet {\n query: {\n gsi1(params: {\n tenantId: string;\n workspaceId: string;\n gsi1Shard: string;\n }): {\n go(): Promise<{\n data: Array<{\n id: string;\n summary?: string;\n vid?: string;\n lastUpdated?: string;\n createdDate?: string;\n modifiedDate?: string;\n createdById?: string;\n modifiedById?: string;\n }> | null;\n }>;\n };\n };\n get(\n keys: Array<{\n tenantId: string;\n workspaceId: string;\n id: string;\n sk: string;\n }>,\n ): {\n go(): Promise<{\n data: Array<{ id: string; resource: string }>;\n unprocessed: Array<{\n tenantId: string;\n workspaceId: string;\n id: string;\n sk: string;\n }>;\n }>;\n };\n}\n\n/** Minimal entity shape for put (used by createDataEntityRecord). */\ninterface EntityWithPut {\n put(attrs: {\n sk: string;\n tenantId: string;\n workspaceId: string;\n id: string;\n resource: string;\n summary: string;\n vid: string;\n lastUpdated: string;\n gsi1sk: string;\n }): { go(): Promise<unknown> };\n}\n\n/** Minimal entity shape for patch (used by updateDataEntityById). */\ninterface EntityWithPatch {\n get(params: {\n tenantId: string;\n workspaceId: string;\n id: string;\n sk: string;\n }): { go(): Promise<{ data: { id: string; resource: string } | null }> };\n patch(params: {\n tenantId: string;\n workspaceId: string;\n id: string;\n sk: string;\n }): {\n set(attrs: {\n resource: string;\n summary: string;\n lastUpdated: string;\n gsi1sk: string;\n }): {\n go(): Promise<unknown>;\n };\n };\n}\n\n/**\n * Get a single data-entity record by id. Decompresses and parses resource; throws NotFoundError if missing.\n * Use from get-by-id operations with the appropriate entity and resource type.\n */\nexport async function getDataEntityById<T>(\n entity: EntityWithGet,\n tenantId: string,\n workspaceId: string,\n id: string,\n resourceLabel: string,\n): Promise<SingleResourceResult<T>> {\n const result = await entity\n .get({\n tenantId,\n workspaceId,\n id,\n sk: DATA_ENTITY_SK,\n })\n .go();\n\n if (!result.data) {\n throw new NotFoundError(`${resourceLabel} ${id} not found`, {\n details: { id },\n });\n }\n\n const parsed = JSON.parse(decompressResource(result.data.resource)) as T & {\n id?: string;\n };\n return {\n id: result.data.id,\n resource: { ...parsed, id: result.data.id } as T,\n };\n}\n\n/**\n * Delete a data-entity record by id. Idempotent (no-op if not found).\n * Use from delete operations with the appropriate entity.\n */\nexport async function deleteDataEntityById(\n entity: EntityWithDelete,\n tenantId: string,\n workspaceId: string,\n id: string,\n): Promise<void> {\n await entity\n .delete({\n tenantId,\n workspaceId,\n id,\n sk: DATA_ENTITY_SK,\n })\n .go();\n}\n\n/** Maximum total attempts (initial + retries) when hydrating list ids via BatchGet. */\nconst BATCH_GET_MAX_ATTEMPTS = 3;\n/** Base backoff in milliseconds applied to BatchGet retries; doubles each attempt. */\nconst BATCH_GET_BASE_BACKOFF_MS = 50;\n\n/** Minimal entity shape for BatchGet hydration on the base table; chunks of 100 are handled by ElectroDB. */\ninterface EntityWithBatchGet<TKey, TItem> {\n get(keys: TKey[]): {\n go(options?: {\n consistent?: boolean;\n }): Promise<{ data: TItem[]; unprocessed: TKey[] }>;\n };\n}\n\n/** Options accepted by {@link batchGetWithRetry}. */\nexport interface BatchGetWithRetryOptions {\n /**\n * Forwarded to DynamoDB as `ConsistentRead`. BatchGet reads the **base\n * table**, which supports strongly-consistent reads, so list operations\n * that must reflect a just-committed write (read-after-write) pass\n * `consistent: true`. Defaults to ElectroDB's eventually-consistent read.\n * @see #1347 — role-assignment dropdowns must reflect their own write\n */\n readonly consistent?: boolean;\n}\n\n/**\n * BatchGet wrapper that retries `UnprocessedKeys` with exponential backoff. ElectroDB chunks the\n * input keys into groups of 100 internally, but does not retry unprocessed keys — DynamoDB can\n * return some keys unprocessed under throttling or partial failure, and the caller must reissue\n * them. Throws after `BATCH_GET_MAX_ATTEMPTS` if any keys remain unprocessed; intended for list\n * hydration (#854) where partial results would silently truncate the response.\n *\n * Pass `{ consistent: true }` for read-after-write correctness — the base-table\n * BatchGet then reflects the latest committed write rather than a possibly-stale\n * eventually-consistent replica (#1347).\n */\nexport async function batchGetWithRetry<TKey, TItem>(\n entity: EntityWithBatchGet<TKey, TItem>,\n keys: TKey[],\n options?: BatchGetWithRetryOptions,\n): Promise<TItem[]> {\n if (keys.length === 0) return [];\n\n const collected: TItem[] = [];\n let pending = keys;\n let attempt = 0;\n\n while (pending.length > 0) {\n if (attempt > 0) {\n await new Promise((resolve) =>\n setTimeout(resolve, BATCH_GET_BASE_BACKOFF_MS * 2 ** (attempt - 1)),\n );\n }\n attempt++;\n const result = await entity\n .get(pending)\n .go(options?.consistent ? { consistent: true } : undefined);\n collected.push(...result.data);\n const unprocessed = result.unprocessed ?? [];\n if (unprocessed.length === 0) break;\n if (attempt >= BATCH_GET_MAX_ATTEMPTS) {\n throw new Error(\n `BatchGet exhausted retries: ${unprocessed.length} key(s) still unprocessed after ${BATCH_GET_MAX_ATTEMPTS} attempt(s)`,\n );\n }\n pending = unprocessed;\n }\n\n return collected;\n}\n\n/** GSI1 index item shape — what a sharded `gsi1.query().go()` returns per row. */\nexport interface ShardedListIndexItem {\n id: string;\n summary?: string;\n}\n\n/** Hooks that adapt a generic mode-dispatching list to a specific entity's hydration and entry shape. */\nexport interface DispatchListModeHooks<TItem, TEntry> {\n /** Hydrate the base table for the given ids (typically `batchGetWithRetry(entity, keys)`). */\n hydrate: (orderedIds: string[]) => Promise<TItem[]>;\n /** Extract the canonical id from a hydrated item so it can be matched back to the GSI1 order. */\n getId: (item: TItem) => string;\n /** Build the result entry for `mode === \"full\"` from a hydrated base-table item. */\n buildEntry: (id: string, item: TItem) => TEntry;\n /** Build the result entry for `mode === \"summary\"` from the parsed GSI1 `summary` JSON. */\n buildSummaryEntry: (\n id: string,\n parsedSummary: Record<string, unknown>,\n ) => TEntry;\n}\n\n/**\n * Mode dispatcher shared by data-plane and control-plane list operations (#853).\n *\n * Given pre-fetched `shardResults` from a sharded GSI1 fan-out, returns either:\n * - `mode === \"count\"` — `{ entries: [], total }` where total is the sum of shard row counts.\n * - `mode === \"summary\"` — entries built from each shard row's parsed `summary` JSON; rows with\n * missing or unparseable `summary` are dropped.\n * - `mode === \"full\"` — calls `hydrate(orderedIds)` (typically a BatchGet) and builds entries\n * from hydrated items in per-shard GSI1 sort order; missing items are dropped.\n *\n * Lives here (alongside `listDataEntitiesByWorkspace`) because the same dispatch logic is needed\n * by every list operation that backs a FHIR list/search endpoint, including the seven\n * control-plane peers (User, Role, Tenant, Workspace, Membership, RoleAssignment, Configuration).\n */\nexport async function dispatchListMode<TItem, TEntry>(\n mode: ListOperationMode,\n shardResults: Array<{ data: Array<ShardedListIndexItem> | null }>,\n hooks: DispatchListModeHooks<TItem, TEntry>,\n): Promise<{ entries: TEntry[]; total: number }> {\n if (mode === \"count\") {\n let total = 0;\n for (const shardResult of shardResults) {\n total += (shardResult.data ?? []).length;\n }\n return { entries: [], total };\n }\n\n if (mode === \"summary\") {\n const entries: TEntry[] = [];\n for (const shardResult of shardResults) {\n for (const item of shardResult.data ?? []) {\n if (typeof item.summary !== \"string\") continue;\n let parsed: Record<string, unknown>;\n try {\n parsed = JSON.parse(item.summary) as Record<string, unknown>;\n } catch {\n continue;\n }\n entries.push(hooks.buildSummaryEntry(item.id, parsed));\n }\n }\n return { entries, total: entries.length };\n }\n\n const orderedIds: string[] = [];\n for (const shardResult of shardResults) {\n for (const item of shardResult.data ?? []) {\n orderedIds.push(item.id);\n }\n }\n\n if (orderedIds.length === 0) return { entries: [], total: 0 };\n\n const items = await hooks.hydrate(orderedIds);\n const byId = new Map(items.map((item) => [hooks.getId(item), item]));\n\n const entries: TEntry[] = [];\n for (const id of orderedIds) {\n const item = byId.get(id);\n if (!item) continue;\n entries.push(hooks.buildEntry(id, item));\n }\n\n return { entries, total: entries.length };\n}\n\n/**\n * List data-entity records in a workspace via GSI1.\n *\n * `mode` (default `\"full\"`) selects the read shape — see `dispatchListMode`. The data-plane\n * binding here adds the four-shard fan-out (per ADR-011) and the BatchGet hydration with\n * decompression for `mode === \"full\"`. K-way merge by `gsi1sk` is intentionally NOT done here\n * — full server-side natural sort lands with the FHIR list-endpoint plumbing that adds\n * pagination tokens.\n */\nexport async function listDataEntitiesByWorkspace<T>(\n entity: DataEntityWithListAndBatchGet,\n tenantId: string,\n workspaceId: string,\n mode: ListOperationMode = \"full\",\n): Promise<ListResult<T>> {\n const shardResults = await Promise.all(\n Array.from({ length: SHARD_COUNT }, (_, shard) =>\n entity.query\n .gsi1({ tenantId, workspaceId, gsi1Shard: String(shard) })\n .go(),\n ),\n );\n\n return dispatchListMode<{ id: string; resource: string }, ListEntry<T>>(\n mode,\n shardResults,\n {\n hydrate: (orderedIds) =>\n batchGetWithRetry(\n entity,\n orderedIds.map((id) => ({\n tenantId,\n workspaceId,\n id,\n sk: DATA_ENTITY_SK,\n })),\n ),\n getId: (item) => item.id,\n buildEntry: (id, item) => {\n const parsed = JSON.parse(decompressResource(item.resource)) as T & {\n id?: string;\n };\n return { id, resource: { ...parsed, id } as T };\n },\n buildSummaryEntry: (id, parsed) => ({\n id,\n resource: { ...parsed, id } as T,\n }),\n },\n );\n}\n\n/**\n * Create a data-entity record with put. Computes vid from lastUpdated (from resource meta or fallback).\n * Use from create operations (e.g. Practitioner, Encounter) that build the resource with audit in meta.\n */\nexport async function createDataEntityRecord<T>(\n entity: EntityWithPut,\n tenantId: string,\n workspaceId: string,\n id: string,\n resourceWithAudit: T & { meta?: { lastUpdated?: string } },\n fallbackDate: string,\n): Promise<SingleResourceResult<T>> {\n const lastUpdated =\n resourceWithAudit.meta?.lastUpdated ??\n fallbackDate ??\n new Date().toISOString();\n const vid =\n lastUpdated.replace(/[-:T.Z]/g, \"\").slice(0, 12) || Date.now().toString(36);\n\n const resourceLike = resourceWithAudit as unknown as FhirResourceLike;\n const summary = JSON.stringify(extractSummary(resourceLike));\n const gsi1sk = extractSortKey(resourceLike);\n\n await entity\n .put({\n sk: DATA_ENTITY_SK,\n tenantId,\n workspaceId,\n id,\n resource: compressResource(JSON.stringify(resourceWithAudit)),\n summary,\n vid,\n lastUpdated,\n gsi1sk,\n })\n .go();\n\n return {\n id,\n resource: resourceWithAudit as T,\n };\n}\n\n/**\n * Build an updated resource with audit in meta for use with updateDataEntityById.\n * Parses existing resource string for existing meta, merges body with id/resourceType/meta (versionId \"2\"),\n * then merges modified audit (modifiedDate, modifiedById, modifiedByName) into meta.\n * Use from update operations (Patient, Encounter, Practitioner) to avoid duplicating this logic.\n */\nexport function buildUpdatedResourceWithAudit<T extends { meta?: Meta }>(\n body: T,\n id: string,\n date: string,\n actorId: string,\n actorName: string,\n existingResourceStr: string,\n resourceType: string,\n): {\n resource: T & { id: string; meta: MetaWithExtensions };\n lastUpdated: string;\n} {\n const existingMeta: MetaWithExtensions | undefined = (\n JSON.parse(existingResourceStr) as { meta?: MetaWithExtensions }\n ).meta;\n\n const bodyWithMeta = body as T & { id?: string; meta?: Meta };\n const resourceWithVersion: T & { id: string; meta?: Meta } = {\n ...body,\n resourceType: resourceType as T[\"resourceType\"],\n id,\n meta: {\n ...(bodyWithMeta.meta ?? {}),\n lastUpdated: date,\n versionId: \"2\",\n },\n };\n\n const resourceWithAudit: T & { id: string; meta: MetaWithExtensions } = {\n ...resourceWithVersion,\n meta: mergeAuditIntoMeta(resourceWithVersion.meta ?? existingMeta, {\n modifiedDate: date,\n modifiedById: actorId,\n modifiedByName: actorName,\n }),\n };\n\n return {\n resource: resourceWithAudit,\n lastUpdated: date,\n };\n}\n\n/**\n * Update a data-entity record by id: get existing, throw if not found, then call builder with\n * decompressed existing resource string; builder returns \\{ resource, lastUpdated \\}; then patch.\n * Use from update operations with the appropriate entity and resource type.\n */\nexport async function updateDataEntityById<T>(\n entity: EntityWithPatch,\n tenantId: string,\n workspaceId: string,\n id: string,\n resourceLabel: string,\n context: OpenHiContext,\n buildPatched: (existingResourceStr: string) => {\n resource: unknown;\n lastUpdated: string;\n },\n): Promise<SingleResourceResult<T>> {\n const existing = await entity\n .get({\n tenantId,\n workspaceId,\n id,\n sk: DATA_ENTITY_SK,\n })\n .go();\n\n if (!existing.data) {\n throw new NotFoundError(`${resourceLabel} ${id} not found`, {\n details: { id },\n });\n }\n\n const existingStr = decompressResource(existing.data.resource);\n const { resource, lastUpdated } = buildPatched(existingStr);\n\n const resourceLike = resource as FhirResourceLike;\n const summary = JSON.stringify(extractSummary(resourceLike));\n const gsi1sk = extractSortKey(resourceLike);\n\n await entity\n .patch({\n tenantId,\n workspaceId,\n id,\n sk: DATA_ENTITY_SK,\n })\n .set({\n resource: compressResource(JSON.stringify(resource)),\n summary,\n lastUpdated,\n gsi1sk,\n })\n .go();\n\n return {\n id,\n resource: resource as T,\n };\n}\n","import { getDynamoControlService } from \"../../../dynamo/dynamo-control-service\";\nimport { SHARD_COUNT } from \"../../../dynamo/shard\";\nimport { OpenHiContext } from \"../../../openhi-context\";\nimport {\n batchGetWithRetry,\n dispatchListMode,\n type ListOperationMode,\n} from \"../../data-operations-common\";\n\nconst SK = \"CURRENT\";\n\nexport interface UserListParams {\n context: OpenHiContext;\n tableName?: string;\n /** #853: defaults to `\"full\"`. `\"summary\"` skips BatchGet, `\"count\"` returns total only. */\n mode?: ListOperationMode;\n}\n\n/**\n * ADR-028 denormalized counter shape surfaced on a User list entry's\n * `resource.counts`. Missing counters render as `0`.\n */\nexport interface UserCounts {\n tenantsForUser: number;\n workspacesForUser: number;\n}\n\nexport interface UserListResult {\n entries: Array<{\n id: string;\n resource: {\n resourceType: string;\n id: string;\n counts: UserCounts;\n [key: string]: unknown;\n };\n }>;\n total: number;\n}\n\n/** Coerce a possibly-absent counter attribute to a non-negative number (default 0). */\nfunction counterValue(value: unknown): number {\n return typeof value === \"number\" && Number.isFinite(value) ? value : 0;\n}\n\n/**\n * Lists Users via GSI1 (sharded). `mode` (default `\"full\"`) selects between BatchGet hydration,\n * summary-only (parse `summary` JSON projected on GSI1), or count-only (skip both). See\n * `dispatchListMode` in data-operations-common for the canonical mode contract.\n */\nexport async function listUsersOperation(\n params: UserListParams,\n): Promise<UserListResult> {\n const { tableName, mode = \"full\" } = params;\n const service = getDynamoControlService(tableName);\n\n const shardResults = await Promise.all(\n Array.from({ length: SHARD_COUNT }, (_, shard) =>\n service.entities.user.query.gsi1({ gsi1Shard: String(shard) }).go(),\n ),\n );\n\n return dispatchListMode<\n {\n id: string;\n resource: string;\n tenantsForUser?: number;\n workspacesForUser?: number;\n },\n UserListResult[\"entries\"][number]\n >(mode, shardResults, {\n hydrate: (orderedIds) =>\n batchGetWithRetry(\n service.entities.user,\n orderedIds.map((id) => ({ id, sk: SK })),\n ) as Promise<\n Array<{\n id: string;\n resource: string;\n tenantsForUser?: number;\n workspacesForUser?: number;\n }>\n >,\n getId: (item) => item.id,\n // FULL mode (admin list default): read the ADR-028 counters off the\n // canonical record hydrated by BatchGet and expose them as\n // `resource.counts`. Missing counters render as 0.\n buildEntry: (id, item) => ({\n id,\n resource: {\n resourceType: \"User\",\n id,\n ...(JSON.parse(item.resource) as Record<string, unknown>),\n counts: {\n tenantsForUser: counterValue(item.tenantsForUser),\n workspacesForUser: counterValue(item.workspacesForUser),\n },\n },\n }),\n // SUMMARY mode reads only the GSI1 `summary` projection (no\n // counters); surface zeros so the shape stays uniform.\n buildSummaryEntry: (id, parsed) => ({\n id,\n resource: {\n resourceType: \"User\",\n id,\n ...parsed,\n counts: { tenantsForUser: 0, workspacesForUser: 0 },\n },\n }),\n });\n}\n","import { getDynamoControlService } from \"../../../dynamo/dynamo-control-service\";\n\n/**\n * Filter modes for {@link membershipListByUserOperation}.\n *\n * Maps directly to the ADR-018 sub-lane discriminator in the user-projection\n * SK (`MEMBERSHIP#TENANT#…` vs `MEMBERSHIP#WORKSPACE#…`):\n *\n * - `\"all\"` — `Query(PK = USER#ID#<userId>, SK begins_with 'MEMBERSHIP#')`.\n * Returns both lanes interleaved in raw SK order.\n * - `\"tenant\"` — `SK begins_with 'MEMBERSHIP#TENANT#'`. Pattern #3 only.\n * - `\"workspace\"` — `SK begins_with 'MEMBERSHIP#WORKSPACE#'`. Pattern #4\n * across every tenant.\n * - `\"workspaceInTenant\"` — `SK begins_with 'MEMBERSHIP#WORKSPACE#TID#<tenantId>#'`.\n * Pattern #4 narrowed to one tenant. Requires `tenantId`.\n */\nexport type MembershipListByUserMode =\n | \"all\"\n | \"tenant\"\n | \"workspace\"\n | \"workspaceInTenant\";\n\n/** Inputs accepted by {@link membershipListByUserOperation}. */\nexport interface MembershipListByUserParams {\n readonly userId: string;\n /** Filter mode — see {@link MembershipListByUserMode}. Defaults to `\"all\"`. */\n readonly mode?: MembershipListByUserMode;\n /** Required only when `mode === \"workspaceInTenant\"`. */\n readonly tenantId?: string;\n /** ElectroDB cursor from a prior page. Forwarded to `.go({ cursor })`. */\n readonly cursor?: string | null;\n /** Per-page item limit forwarded to `.go({ limit })`. */\n readonly limit?: number;\n /** Sort order forwarded to `.go({ order })`. Defaults to ElectroDB's `\"asc\"`. */\n readonly order?: \"asc\" | \"desc\";\n /** Optional table-name override; resolved via env when omitted. */\n readonly tableName?: string;\n}\n\n/** One projection-row payload as returned to a consumer. */\nexport interface MembershipUserProjectionEntry {\n readonly userId: string;\n readonly sk: string;\n readonly tenantId: string;\n readonly workspaceId?: string;\n readonly membershipId: string;\n readonly summary: string;\n readonly vid: string;\n readonly lastUpdated: string;\n readonly denormalizedTenantName?: string;\n readonly denormalizedUserName?: string;\n readonly denormalizedWorkspaceName?: string;\n}\n\n/** Page returned by {@link membershipListByUserOperation}. */\nexport interface MembershipListByUserResult {\n readonly items: Array<MembershipUserProjectionEntry>;\n /** ElectroDB cursor for the next page, or `null` when exhausted. */\n readonly cursor: string | null;\n}\n\n/**\n * Compose the SK prefix for a given filter mode. Centralizing the\n * prefix string here keeps the SK grammar (owned by\n * `membership-user-projection.ts`) the single source of truth for the\n * lane discriminators — this function reads them, it does not invent them.\n */\nexport function buildSkPrefix(\n mode: MembershipListByUserMode,\n tenantId: string | undefined,\n): string {\n switch (mode) {\n case \"tenant\":\n return \"MEMBERSHIP#TENANT#\";\n case \"workspace\":\n return \"MEMBERSHIP#WORKSPACE#\";\n case \"workspaceInTenant\":\n // Pattern-#4 SK places `<tenantId>` directly after the\n // `MEMBERSHIP#WORKSPACE#TID#` segment so a `begins_with` filter\n // narrows the workspace lane to a single tenant.\n return `MEMBERSHIP#WORKSPACE#TID#${tenantId}#`;\n case \"all\":\n default:\n return \"MEMBERSHIP#\";\n }\n}\n\n/**\n * List Memberships for a user via the ADR-018 user-partition projection\n * (no GSI hop).\n *\n * Reads `MembershipUserProjectionEntity` rows under `PK = USER#ID#<userId>`\n * with an `SK begins_with` filter selected by `mode`:\n *\n * | Mode | SK begins_with | Covers |\n * |---|---|---|\n * | `all` (default) | `MEMBERSHIP#` | patterns #3 + #4 interleaved |\n * | `tenant` | `MEMBERSHIP#TENANT#` | pattern #3 only |\n * | `workspace` | `MEMBERSHIP#WORKSPACE#` | pattern #4 only, across tenants |\n * | `workspaceInTenant` | `MEMBERSHIP#WORKSPACE#TID#<tenantId>#` | pattern #4 in one tenant |\n *\n * Returns the projection rows verbatim (`summary`, `vid`, `lastUpdated`\n * plus the projection-discriminating fields) — full canonical-resource\n * hydration is opt-in for callers via\n * `MembershipEntity.get({ tenantId, id: membershipId })`. Pagination\n * mirrors ElectroDB's native `.go({ cursor })` shape; the returned\n * `cursor` is opaque to callers.\n *\n * @see ADR-018 § Access Pattern Coverage (patterns #3 and #4)\n * @see .state/adr-018-implementation-guide.md § 1 (SK grammar)\n */\nexport async function membershipListByUserOperation(\n params: MembershipListByUserParams,\n): Promise<MembershipListByUserResult> {\n const {\n userId,\n mode = \"all\",\n tenantId,\n cursor = null,\n limit,\n order,\n tableName,\n } = params;\n\n if (mode === \"workspaceInTenant\" && !tenantId) {\n throw new Error(\n 'membershipListByUserOperation: tenantId is required when mode === \"workspaceInTenant\"',\n );\n }\n\n const service = getDynamoControlService(tableName);\n const skPrefix = buildSkPrefix(mode, tenantId);\n\n const goOptions: {\n cursor?: string | null;\n limit?: number;\n order?: \"asc\" | \"desc\";\n } = {\n cursor,\n };\n if (limit !== undefined) {\n goOptions.limit = limit;\n }\n if (order !== undefined) {\n goOptions.order = order;\n }\n\n const result = await service.entities.membershipUserProjection.query\n .record({ userId })\n .begins({ sk: skPrefix })\n .go(goOptions);\n\n const items: Array<MembershipUserProjectionEntry> = (result.data ?? []).map(\n (row) => ({\n userId: row.userId,\n sk: row.sk,\n tenantId: row.tenantId,\n workspaceId: row.workspaceId,\n membershipId: row.membershipId,\n summary: row.summary,\n vid: row.vid,\n lastUpdated: row.lastUpdated,\n denormalizedTenantName: row.denormalizedTenantName,\n denormalizedUserName: row.denormalizedUserName,\n denormalizedWorkspaceName: row.denormalizedWorkspaceName,\n }),\n );\n\n return { items, cursor: result.cursor ?? null };\n}\n"],"mappings":"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","names":["entries"]}