@openhi/constructs 0.0.169 → 0.0.170

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 (51) hide show
  1. package/lib/{chunk-DWSWCUZR.mjs → chunk-6HGSR3TG.mjs} +2 -2
  2. package/lib/chunk-APVVG7BO.mjs +61 -0
  3. package/lib/chunk-APVVG7BO.mjs.map +1 -0
  4. package/lib/{chunk-ZODGX37H.mjs → chunk-E2OWEBBH.mjs} +3 -3
  5. package/lib/{chunk-GJTPXJKD.mjs → chunk-EBB4RNUG.mjs} +2 -2
  6. package/lib/{chunk-O5VQWB6U.mjs → chunk-FDBBTNCI.mjs} +5 -61
  7. package/lib/chunk-FDBBTNCI.mjs.map +1 -0
  8. package/lib/{chunk-P3CTZWC2.mjs → chunk-GG2WD6TA.mjs} +2 -2
  9. package/lib/{chunk-Q64MOYJ7.mjs → chunk-JUSVETWK.mjs} +3 -3
  10. package/lib/{chunk-KA3OMP3X.mjs → chunk-USNOOCSZ.mjs} +9 -3
  11. package/lib/chunk-USNOOCSZ.mjs.map +1 -0
  12. package/lib/{chunk-2O3CXY2C.mjs → chunk-XJ5SRUGN.mjs} +2 -2
  13. package/lib/{chunk-2O3CXY2C.mjs.map → chunk-XJ5SRUGN.mjs.map} +1 -1
  14. package/lib/{chunk-P3NFCKTZ.mjs → chunk-XNUCKVSE.mjs} +2 -2
  15. package/lib/{chunk-WFTDH2NM.mjs → chunk-Y4RGUAM2.mjs} +2 -2
  16. package/lib/{chunk-XHG4SODS.mjs → chunk-Z4PZSLYY.mjs} +2 -2
  17. package/lib/counter-reconciliation.handler.mjs +5 -4
  18. package/lib/counter-reconciliation.handler.mjs.map +1 -1
  19. package/lib/data-store-postgres-replication.handler.js +42 -4
  20. package/lib/data-store-postgres-replication.handler.js.map +1 -1
  21. package/lib/data-store-postgres-replication.handler.mjs +9 -4
  22. package/lib/data-store-postgres-replication.handler.mjs.map +1 -1
  23. package/lib/firehose-archive-transform.handler.d.mts +2 -1
  24. package/lib/firehose-archive-transform.handler.d.ts +2 -1
  25. package/lib/firehose-archive-transform.handler.js +42 -2
  26. package/lib/firehose-archive-transform.handler.js.map +1 -1
  27. package/lib/firehose-archive-transform.handler.mjs +4 -1
  28. package/lib/index.js +21 -2
  29. package/lib/index.js.map +1 -1
  30. package/lib/index.mjs +26 -6
  31. package/lib/index.mjs.map +1 -1
  32. package/lib/pre-token-generation.handler.mjs +6 -5
  33. package/lib/pre-token-generation.handler.mjs.map +1 -1
  34. package/lib/provision-default-workspace.handler.mjs +5 -4
  35. package/lib/provision-default-workspace.handler.mjs.map +1 -1
  36. package/lib/rest-api-lambda.handler.js +1 -1
  37. package/lib/rest-api-lambda.handler.js.map +1 -1
  38. package/lib/rest-api-lambda.handler.mjs +14 -12
  39. package/lib/rest-api-lambda.handler.mjs.map +1 -1
  40. package/lib/seed-demo-data.handler.mjs +5 -4
  41. package/package.json +1 -1
  42. package/lib/chunk-KA3OMP3X.mjs.map +0 -1
  43. package/lib/chunk-O5VQWB6U.mjs.map +0 -1
  44. /package/lib/{chunk-DWSWCUZR.mjs.map → chunk-6HGSR3TG.mjs.map} +0 -0
  45. /package/lib/{chunk-ZODGX37H.mjs.map → chunk-E2OWEBBH.mjs.map} +0 -0
  46. /package/lib/{chunk-GJTPXJKD.mjs.map → chunk-EBB4RNUG.mjs.map} +0 -0
  47. /package/lib/{chunk-P3CTZWC2.mjs.map → chunk-GG2WD6TA.mjs.map} +0 -0
  48. /package/lib/{chunk-Q64MOYJ7.mjs.map → chunk-JUSVETWK.mjs.map} +0 -0
  49. /package/lib/{chunk-P3NFCKTZ.mjs.map → chunk-XNUCKVSE.mjs.map} +0 -0
  50. /package/lib/{chunk-WFTDH2NM.mjs.map → chunk-Y4RGUAM2.mjs.map} +0 -0
  51. /package/lib/{chunk-XHG4SODS.mjs.map → chunk-Z4PZSLYY.mjs.map} +0 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/data/operations/control/counters/counter-reconcile-operation.ts","../src/data/operations/control/counters/counter-reconcile-driver.ts","../src/workflows/control-plane/counter-reconciliation/counter-reconciliation.handler.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","import {\n reconcileAllCountersOperation,\n type CounterReconcileReport,\n} from \"../../../data/operations/control/counters/counter-reconcile-driver\";\n\n/**\n * @see sites/www-docs/content/packages/@openhi/constructs/workflows/control-plane/counter-reconciliation/counter-reconciliation-handler.md\n *\n * ADR-028 counter-reconciliation job handler. Invoked on demand (manual\n * `aws lambda invoke`, an operator runbook, or a scheduled trigger) — not\n * an EventBridge consumer. Walks every canonical Tenant / Workspace /\n * User, recomputes the denormalized counters from canonical data, repairs\n * any drift with a `SET` write, and logs the per-counter old → new drift\n * report.\n *\n * Idempotent by construction: each per-target recompute writes the\n * absolute recomputed value, so a second run over unchanged data corrects\n * nothing and reports zero drift. That is why the job needs no dedup\n * circuit-breaker (unlike the event-driven counter-maintenance consumer,\n * whose atomic ADDs are not idempotent).\n */\n\n/** Dependency seam for tests; production wires the real driver. */\nexport interface CounterReconciliationDependencies {\n /**\n * Run the full reconciliation sweep. Defaults to\n * {@link reconcileAllCountersOperation}; tests inject a fake.\n */\n readonly reconcileAll: () => Promise<CounterReconcileReport>;\n}\n\n/**\n * Test-visible orchestrator. The production `handler` calls this with the\n * real driver; unit tests inject a fake. Returns the drift report so an\n * invoker (or test) can assert on what was corrected.\n */\nexport const runCounterReconciliation = async (\n deps: CounterReconciliationDependencies,\n): Promise<CounterReconcileReport> => {\n const report = await deps.reconcileAll();\n\n // Structured, loggable summary. One JSON line so a log query can pluck\n // the totals; the full per-counter drift list rides along for an audit.\n console.log(\n JSON.stringify({\n message: \"counter-reconciliation complete\",\n scanned: report.scanned,\n countersCorrected: report.countersCorrected,\n drift: report.drift,\n }),\n );\n\n return report;\n};\n\nconst productionDependencies = (): CounterReconciliationDependencies => ({\n reconcileAll: () => reconcileAllCountersOperation(),\n});\n\nexport const handler = async (): Promise<CounterReconcileReport> =>\n runCounterReconciliation(productionDependencies());\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/data/operations/control/counters/counter-reconcile-operation.ts","../src/data/operations/control/counters/counter-reconcile-driver.ts","../src/workflows/control-plane/counter-reconciliation/counter-reconciliation.handler.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","import {\n reconcileAllCountersOperation,\n type CounterReconcileReport,\n} from \"../../../data/operations/control/counters/counter-reconcile-driver\";\n\n/**\n * @see sites/www-docs/content/packages/@openhi/constructs/workflows/control-plane/counter-reconciliation/counter-reconciliation-handler.md\n *\n * ADR-028 counter-reconciliation job handler. Invoked on demand (manual\n * `aws lambda invoke`, an operator runbook, or a scheduled trigger) — not\n * an EventBridge consumer. Walks every canonical Tenant / Workspace /\n * User, recomputes the denormalized counters from canonical data, repairs\n * any drift with a `SET` write, and logs the per-counter old → new drift\n * report.\n *\n * Idempotent by construction: each per-target recompute writes the\n * absolute recomputed value, so a second run over unchanged data corrects\n * nothing and reports zero drift. That is why the job needs no dedup\n * circuit-breaker (unlike the event-driven counter-maintenance consumer,\n * whose atomic ADDs are not idempotent).\n */\n\n/** Dependency seam for tests; production wires the real driver. */\nexport interface CounterReconciliationDependencies {\n /**\n * Run the full reconciliation sweep. Defaults to\n * {@link reconcileAllCountersOperation}; tests inject a fake.\n */\n readonly reconcileAll: () => Promise<CounterReconcileReport>;\n}\n\n/**\n * Test-visible orchestrator. The production `handler` calls this with the\n * real driver; unit tests inject a fake. Returns the drift report so an\n * invoker (or test) can assert on what was corrected.\n */\nexport const runCounterReconciliation = async (\n deps: CounterReconciliationDependencies,\n): Promise<CounterReconcileReport> => {\n const report = await deps.reconcileAll();\n\n // Structured, loggable summary. One JSON line so a log query can pluck\n // the totals; the full per-counter drift list rides along for an audit.\n console.log(\n JSON.stringify({\n message: \"counter-reconciliation complete\",\n scanned: report.scanned,\n countersCorrected: report.countersCorrected,\n drift: report.drift,\n }),\n );\n\n return report;\n};\n\nconst productionDependencies = (): CounterReconciliationDependencies => ({\n reconcileAll: () => reconcileAllCountersOperation(),\n});\n\nexport const handler = async (): Promise<CounterReconcileReport> =>\n runCounterReconciliation(productionDependencies());\n"],"mappings":"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","names":[]}
@@ -826,7 +826,7 @@ function buildResourceSoftDeleteSql(schemaName) {
826
826
  ` version = $5`,
827
827
  `WHERE tenant_id = $1`,
828
828
  ` AND workspace_id = $2`,
829
- ` AND resource_type = $3`,
829
+ ` AND LOWER(resource_type) = LOWER($3)`,
830
830
  ` AND resource_id = $4`,
831
831
  ` AND $5 > version;`
832
832
  ].join("\n");
@@ -835,6 +835,42 @@ async function ensureSchemaBootstrap(client, schemaName) {
835
835
  await client.query(buildSchemaBootstrapSql(schemaName));
836
836
  }
837
837
 
838
+ // src/lib/compression.ts
839
+ var import_node_zlib = require("zlib");
840
+ var COMPRESSION_ALGOS = {
841
+ NONE: "none",
842
+ GZIP: "gzip",
843
+ BROTLI: "brotli",
844
+ DEFLATE: "deflate"
845
+ };
846
+ function isEnvelope(obj) {
847
+ return typeof obj === "object" && obj !== null && "v" in obj && "algo" in obj && "payload" in obj && typeof obj.payload === "string";
848
+ }
849
+ function decompressResource(compressedOrRaw) {
850
+ try {
851
+ const parsed = JSON.parse(compressedOrRaw);
852
+ if (isEnvelope(parsed)) {
853
+ if (parsed.algo === COMPRESSION_ALGOS.GZIP) {
854
+ const buf = Buffer.from(parsed.payload, "base64");
855
+ return (0, import_node_zlib.gunzipSync)(buf).toString("utf-8");
856
+ }
857
+ if (parsed.algo === COMPRESSION_ALGOS.NONE) {
858
+ return parsed.payload;
859
+ }
860
+ return parsed.payload;
861
+ }
862
+ } catch {
863
+ }
864
+ try {
865
+ const buf = Buffer.from(compressedOrRaw, "base64");
866
+ if (buf.length >= 2 && buf[0] === 31 && buf[1] === 139) {
867
+ return (0, import_node_zlib.gunzipSync)(buf).toString("utf-8");
868
+ }
869
+ } catch {
870
+ }
871
+ return compressedOrRaw;
872
+ }
873
+
838
874
  // src/components/dynamodb/dynamodb-stream-record.ts
839
875
  function dynamodbValueToJs(av) {
840
876
  if (av.S !== void 0) {
@@ -966,7 +1002,7 @@ function extractFhirResourceTypeFromImage(plain) {
966
1002
  return void 0;
967
1003
  }
968
1004
  try {
969
- const parsed = JSON.parse(resourceStr);
1005
+ const parsed = JSON.parse(decompressResource(resourceStr));
970
1006
  if (typeof parsed.resourceType === "string" && parsed.resourceType !== "") {
971
1007
  return parsed.resourceType;
972
1008
  }
@@ -1128,9 +1164,11 @@ function buildWriteIntent(change, awsRegion) {
1128
1164
  if (typeof plain.resource !== "string") {
1129
1165
  return { kind: "drop" };
1130
1166
  }
1167
+ let resourceJson;
1131
1168
  let resourceObj;
1132
1169
  try {
1133
- resourceObj = JSON.parse(plain.resource);
1170
+ resourceJson = decompressResource(plain.resource);
1171
+ resourceObj = JSON.parse(resourceJson);
1134
1172
  } catch {
1135
1173
  return { kind: "drop" };
1136
1174
  }
@@ -1143,7 +1181,7 @@ function buildWriteIntent(change, awsRegion) {
1143
1181
  resourceId: keys.resourceId,
1144
1182
  version: keys.version,
1145
1183
  lastUpdated: deriveLastUpdated(resourceObj, approxEpochSec),
1146
- resourceJson: plain.resource
1184
+ resourceJson
1147
1185
  }
1148
1186
  };
1149
1187
  }