@tearleads/client-sdk 0.1.38 → 0.1.40

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 (76) hide show
  1. package/client/Tearleads.d.ts +3 -0
  2. package/client/Tearleads.js +20 -12
  3. package/client/Tearleads.js.map +1 -1
  4. package/client/organizations/organizationGroupPresentation.d.ts +1 -12
  5. package/client/session/index.js +2 -2
  6. package/client/session/index.js.map +1 -1
  7. package/client/session/sessionOrganizationCreation.js +1 -0
  8. package/client/session/sessionOrganizationCreation.js.map +1 -1
  9. package/client/session/sessionTypes.d.ts +3 -0
  10. package/client/session/sessionTypes.js.map +1 -1
  11. package/client/workflowRuntime.d.ts +2 -0
  12. package/client/workflowRuntime.js +34 -16
  13. package/client/workflowRuntime.js.map +1 -1
  14. package/data/persistence/documents/documentDiscoveryEvidencePersistence.d.ts +16 -0
  15. package/data/persistence/documents/documentDiscoveryEvidencePersistence.js +23 -0
  16. package/data/persistence/documents/documentDiscoveryEvidencePersistence.js.map +1 -1
  17. package/data/persistence/organizations/organizationReadModelGrantPersistence.js +2 -1
  18. package/data/persistence/organizations/organizationReadModelGrantPersistence.js.map +1 -1
  19. package/data/persistence/organizations/organizationReadModelMemberLoad.d.ts +9 -0
  20. package/data/persistence/organizations/organizationReadModelMemberLoad.js +29 -21
  21. package/data/persistence/organizations/organizationReadModelMemberLoad.js.map +1 -1
  22. package/data/persistence/organizations/organizationReadModelMembershipPersistence.js +91 -46
  23. package/data/persistence/organizations/organizationReadModelMembershipPersistence.js.map +1 -1
  24. package/data/persistence/organizations/organizationReadModelPersistence.js +95 -73
  25. package/data/persistence/organizations/organizationReadModelPersistence.js.map +1 -1
  26. package/data/persistence/organizations/organizationReadModelProjection.js +78 -89
  27. package/data/persistence/organizations/organizationReadModelProjection.js.map +1 -1
  28. package/data/persistence/organizations/organizationReadModelPurge.js +14 -15
  29. package/data/persistence/organizations/organizationReadModelPurge.js.map +1 -1
  30. package/data/sqlite/organizationReadModelSchema.js +23 -28
  31. package/data/sqlite/organizationReadModelSchema.js.map +1 -1
  32. package/data/sqlite/organizationReadModelTableRegistry.d.ts +116 -263
  33. package/data/sqlite/organizationReadModelTableRegistry.js +10 -7
  34. package/data/sqlite/organizationReadModelTableRegistry.js.map +1 -1
  35. package/data/sqlite/schema.d.ts +116 -263
  36. package/data/sqlite/sqliteBindBudget.js +30 -0
  37. package/data/sqlite/sqliteBindBudget.js.map +1 -0
  38. package/data/trustedUserIdentity/index.d.ts +1 -1
  39. package/data/trustedUserIdentity/index.js +1 -1
  40. package/data/trustedUserIdentity/index.js.map +1 -1
  41. package/data/trustedUserIdentity/service.d.ts +12 -1
  42. package/data/trustedUserIdentity/service.js +27 -3
  43. package/data/trustedUserIdentity/service.js.map +1 -1
  44. package/internal/validators/operation/organizations.d.ts +0 -16
  45. package/internal/validators/response/index.d.ts +1 -1
  46. package/internal/validators/response/index.js.map +1 -1
  47. package/internal/validators/response/organizationReadModel.d.ts +10 -48
  48. package/internal/validators/response/organizationReadModel.js +9 -1
  49. package/internal/validators/response/organizationReadModel.js.map +1 -1
  50. package/package.json +1 -1
  51. package/workflows/organizations/createOrganization.d.ts +3 -0
  52. package/workflows/organizations/createOrganization.js +1 -0
  53. package/workflows/organizations/createOrganization.js.map +1 -1
  54. package/workflows/organizations/readModel.d.ts +8 -3
  55. package/workflows/organizations/readModel.js.map +1 -1
  56. package/workflows/organizations/readModelProjection.d.ts +2 -12
  57. package/workflows/organizations/readModelProjection.js +1 -1
  58. package/workflows/organizations/readModelProjection.js.map +1 -1
  59. package/workflows/registration/organizationProvisioningPersistence.d.ts +3 -0
  60. package/workflows/registration/organizationProvisioningPersistence.js +27 -1
  61. package/workflows/registration/organizationProvisioningPersistence.js.map +1 -1
  62. package/workflows/registration/persistRegistrationBootstrap.d.ts +3 -0
  63. package/workflows/registration/persistRegistrationBootstrap.js +7 -1
  64. package/workflows/registration/persistRegistrationBootstrap.js.map +1 -1
  65. package/workflows/registration/provisionedPrincipalHistory.d.ts +45 -0
  66. package/workflows/registration/provisionedPrincipalHistory.js +104 -0
  67. package/workflows/registration/provisionedPrincipalHistory.js.map +1 -0
  68. package/workflows/registration/provisioningAcknowledgement.js +18 -0
  69. package/workflows/registration/provisioningAcknowledgement.js.map +1 -1
  70. package/workflows/registration/registerIdentity.d.ts +3 -0
  71. package/workflows/registration/registerIdentity.js +1 -0
  72. package/workflows/registration/registerIdentity.js.map +1 -1
  73. package/workflows/sync/remoteReset.js +1 -3
  74. package/workflows/sync/remoteReset.js.map +1 -1
  75. package/data/persistence/organizations/organizationReadModelPolicyHeadPersistence.js +0 -64
  76. package/data/persistence/organizations/organizationReadModelPolicyHeadPersistence.js.map +0 -1
@@ -1,13 +1,22 @@
1
- import { and, eq, inArray } from "drizzle-orm";
2
- import { organizationReadModelGroupMembers, organizationReadModelGroupMemberships, organizationReadModelGroups, } from "../../sqlite/organizationReadModelSchema.js";
3
- const GROUP_MEMBERSHIP_BATCH_SIZE = 90;
4
- const GROUP_MEMBER_INSERT_BATCH_SIZE = 30;
1
+ import { and, eq, inArray, sql } from "drizzle-orm";
2
+ import { organizationReadModelGroupMembers, organizationReadModelGroups, } from "../../sqlite/organizationReadModelSchema.js";
3
+ import { sqliteInsertBatchSize, sqliteRowsPerStatement, } from "../../sqlite/sqliteBindBudget.js";
4
+ // Group ID lists bind one value per group; the head-clearing update also
5
+ // binds its null value beside the organization ID.
6
+ const GROUP_MEMBERSHIP_BATCH_SIZE = sqliteRowsPerStatement(1, 2);
7
+ // Each head binds two values in its CASE arm and one in the IN list, plus the
8
+ // organization ID.
9
+ const GROUP_HEAD_UPDATE_BATCH_SIZE = sqliteRowsPerStatement(3, 1);
10
+ const GROUP_MEMBER_INSERT_BATCH_SIZE = sqliteInsertBatchSize(organizationReadModelGroupMembers);
5
11
  export class OrganizationReadModelBindingError extends Error {
6
12
  constructor(message) {
7
13
  super(message);
8
14
  this.name = "OrganizationReadModelBindingError";
9
15
  }
10
16
  }
17
+ function membershipCoverageError() {
18
+ return new OrganizationReadModelBindingError("Organization group membership coverage is inconsistent");
19
+ }
11
20
  function membershipMemberKey(member) {
12
21
  return member.userId;
13
22
  }
@@ -36,8 +45,9 @@ async function clearMembershipRows(input) {
36
45
  .where(eq(organizationReadModelGroupMembers.organizationId, input.organizationId))
37
46
  .run();
38
47
  await input.tx
39
- .delete(organizationReadModelGroupMemberships)
40
- .where(eq(organizationReadModelGroupMemberships.organizationId, input.organizationId))
48
+ .update(organizationReadModelGroups)
49
+ .set({ membersStateHash: null })
50
+ .where(eq(organizationReadModelGroups.organizationId, input.organizationId))
41
51
  .run();
42
52
  }
43
53
  async function deleteMembershipRows(input, groupIds) {
@@ -48,8 +58,9 @@ async function deleteMembershipRows(input, groupIds) {
48
58
  .where(and(eq(organizationReadModelGroupMembers.organizationId, input.organizationId), inArray(organizationReadModelGroupMembers.groupId, groupIdBatch)))
49
59
  .run();
50
60
  await input.tx
51
- .delete(organizationReadModelGroupMemberships)
52
- .where(and(eq(organizationReadModelGroupMemberships.organizationId, input.organizationId), inArray(organizationReadModelGroupMemberships.groupId, groupIdBatch)))
61
+ .update(organizationReadModelGroups)
62
+ .set({ membersStateHash: null })
63
+ .where(and(eq(organizationReadModelGroups.organizationId, input.organizationId), inArray(organizationReadModelGroups.groupId, groupIdBatch)))
53
64
  .run();
54
65
  }
55
66
  }
@@ -66,21 +77,38 @@ async function removeExistingMembershipRows(input) {
66
77
  await deleteMembershipRows(input, removedGroupIds);
67
78
  }
68
79
  }
69
- async function insertMembershipRows(input) {
70
- if (input.lane.groups.length === 0) {
71
- return;
72
- }
73
- const membershipRows = input.lane.groups.map((group) => ({
74
- organizationId: input.organizationId,
75
- groupId: group.groupId,
76
- stateHash: group.stateHash,
77
- }));
78
- for (let index = 0; index < membershipRows.length; index += GROUP_MEMBERSHIP_BATCH_SIZE) {
80
+ /**
81
+ * Record each visible group's membership head on its catalog row. A head for
82
+ * a group the catalog does not list cannot be bound and fails the page. The
83
+ * reserved Members head binds to the state row instead; the catalog normally
84
+ * omits Members, so its row is updated only when one exists.
85
+ */
86
+ async function writeVisibleGroupHeads(input) {
87
+ const memberGroupHead = input.lane.groups.find((group) => group.groupId === input.memberGroupId);
88
+ if (memberGroupHead) {
79
89
  await input.tx
80
- .insert(organizationReadModelGroupMemberships)
81
- .values(membershipRows.slice(index, index + GROUP_MEMBERSHIP_BATCH_SIZE))
90
+ .update(organizationReadModelGroups)
91
+ .set({ membersStateHash: memberGroupHead.stateHash })
92
+ .where(and(eq(organizationReadModelGroups.organizationId, input.organizationId), eq(organizationReadModelGroups.groupId, input.memberGroupId)))
82
93
  .run();
83
94
  }
95
+ const heads = input.lane.groups.filter((group) => group.groupId !== input.memberGroupId);
96
+ for (let index = 0; index < heads.length; index += GROUP_HEAD_UPDATE_BATCH_SIZE) {
97
+ const batch = heads.slice(index, index + GROUP_HEAD_UPDATE_BATCH_SIZE);
98
+ const arms = sql.join(batch.map((head) => sql `WHEN ${head.groupId} THEN ${head.stateHash}`), sql ` `);
99
+ const updated = await input.tx
100
+ .update(organizationReadModelGroups)
101
+ .set({
102
+ membersStateHash: sql `CASE ${organizationReadModelGroups.groupId} ${arms} END`,
103
+ })
104
+ .where(and(eq(organizationReadModelGroups.organizationId, input.organizationId), inArray(organizationReadModelGroups.groupId, batch.map((head) => head.groupId))))
105
+ .returning({ groupId: organizationReadModelGroups.groupId });
106
+ if (updated.length !== batch.length) {
107
+ throw membershipCoverageError();
108
+ }
109
+ }
110
+ }
111
+ async function insertMemberRows(input) {
84
112
  const memberRows = input.lane.groups.flatMap((group) => group.members.map((member, sortOrder) => ({
85
113
  organizationId: input.organizationId,
86
114
  groupId: group.groupId,
@@ -88,10 +116,6 @@ async function insertMembershipRows(input) {
88
116
  sortOrder,
89
117
  stateHash: group.stateHash,
90
118
  role: member.role,
91
- signingKeyFingerprint: member.signingKeyFingerprint,
92
- signingPublicKey: member.signingPublicKey,
93
- encapsulationPublicKey: member.encapsulationPublicKey,
94
- encapsulationKeyFingerprint: member.encapsulationKeyFingerprint,
95
119
  })));
96
120
  for (let index = 0; index < memberRows.length; index += GROUP_MEMBER_INSERT_BATCH_SIZE) {
97
121
  await input.tx
@@ -100,27 +124,59 @@ async function insertMembershipRows(input) {
100
124
  .run();
101
125
  }
102
126
  }
127
+ function nextMemberGroupStateHash(input) {
128
+ const head = input.lane.groups.find((group) => group.groupId === input.memberGroupId);
129
+ if (head) {
130
+ return head.stateHash;
131
+ }
132
+ return input.replaceAll ||
133
+ input.lane.deletedGroupIds.includes(input.memberGroupId)
134
+ ? null
135
+ : input.currentMemberGroupStateHash;
136
+ }
137
+ /**
138
+ * Apply one groupMemberships lane and return the reserved Members head, which
139
+ * the caller persists on the organization state row.
140
+ */
103
141
  export async function applyGroupMembershipsLane(input) {
104
142
  assertUniqueMembershipRows(input.lane);
105
143
  await removeExistingMembershipRows(input);
106
- await insertMembershipRows(input);
144
+ await writeVisibleGroupHeads(input);
145
+ await insertMemberRows(input);
146
+ return nextMemberGroupStateHash(input);
147
+ }
148
+ function membershipHeadsByGroupId(input) {
149
+ if (input.memberGroupStateHash === null) {
150
+ throw membershipCoverageError();
151
+ }
152
+ const heads = new Map([[input.memberGroupId, input.memberGroupStateHash]]);
153
+ for (const group of input.groups) {
154
+ if (group.membersStateHash === null) {
155
+ continue;
156
+ }
157
+ // A head is expected exactly for stateful visible groups; a listed
158
+ // Members row must carry the same head as the state row.
159
+ const isMemberGroup = group.groupId === input.memberGroupId;
160
+ if (group.stateHash === null ||
161
+ (isMemberGroup
162
+ ? group.membersStateHash !== input.memberGroupStateHash
163
+ : heads.has(group.groupId))) {
164
+ throw membershipCoverageError();
165
+ }
166
+ heads.set(group.groupId, group.membersStateHash);
167
+ }
168
+ return heads;
107
169
  }
108
170
  export async function assertStoredGroupMembershipBindings(input) {
109
171
  const groups = await input.tx
110
172
  .select({
111
173
  groupId: organizationReadModelGroups.groupId,
112
174
  memberCount: organizationReadModelGroups.memberCount,
175
+ membersStateHash: organizationReadModelGroups.membersStateHash,
113
176
  stateHash: organizationReadModelGroups.stateHash,
114
177
  })
115
178
  .from(organizationReadModelGroups)
116
179
  .where(eq(organizationReadModelGroups.organizationId, input.organizationId));
117
- const heads = await input.tx
118
- .select({
119
- groupId: organizationReadModelGroupMemberships.groupId,
120
- stateHash: organizationReadModelGroupMemberships.stateHash,
121
- })
122
- .from(organizationReadModelGroupMemberships)
123
- .where(eq(organizationReadModelGroupMemberships.organizationId, input.organizationId));
124
180
  const members = await input.tx
125
181
  .select({
126
182
  groupId: organizationReadModelGroupMembers.groupId,
@@ -128,30 +184,19 @@ export async function assertStoredGroupMembershipBindings(input) {
128
184
  })
129
185
  .from(organizationReadModelGroupMembers)
130
186
  .where(eq(organizationReadModelGroupMembers.organizationId, input.organizationId));
131
- const headsByGroupId = new Map(heads.map((head) => [head.groupId, head]));
132
- const expectedHeadIds = new Set([
133
- input.memberGroupId,
134
- ...groups.flatMap((group) => (group.stateHash ? [group.groupId] : [])),
135
- ]);
136
- if (heads.length !== expectedHeadIds.size ||
137
- heads.some((head) => !expectedHeadIds.has(head.groupId))) {
138
- throw new OrganizationReadModelBindingError("Organization group membership coverage is inconsistent");
139
- }
187
+ const headsByGroupId = membershipHeadsByGroupId({ ...input, groups });
140
188
  const memberCountsByGroupId = new Map();
141
189
  for (const member of members) {
142
- const head = headsByGroupId.get(member.groupId);
143
- if (!head || head.stateHash !== member.stateHash) {
190
+ if (headsByGroupId.get(member.groupId) !== member.stateHash) {
144
191
  throw new OrganizationReadModelBindingError("Organization group membership member state is inconsistent");
145
192
  }
146
193
  memberCountsByGroupId.set(member.groupId, (memberCountsByGroupId.get(member.groupId) ?? 0) + 1);
147
194
  }
148
195
  for (const group of groups) {
149
- const head = headsByGroupId.get(group.groupId);
150
196
  if (group.stateHash === null) {
151
197
  continue;
152
198
  }
153
- if (!head ||
154
- head.stateHash !== group.stateHash ||
199
+ if (group.membersStateHash !== group.stateHash ||
155
200
  group.memberCount === null ||
156
201
  group.memberCount !== (memberCountsByGroupId.get(group.groupId) ?? 0)) {
157
202
  throw new OrganizationReadModelBindingError("Organization group membership state hash is inconsistent");
@@ -1 +1 @@
1
- {"version":3,"file":"organizationReadModelMembershipPersistence.js","sourceRoot":"","sources":["organizationReadModelMembershipPersistence.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n OrganizationGroupMemberResponse,\n OrganizationReadModelGroupMembershipsResponse,\n} from \"@tearleads/validators/response\";\nimport { and, eq, inArray } from \"drizzle-orm\";\nimport {\n organizationReadModelGroupMembers,\n organizationReadModelGroupMemberships,\n organizationReadModelGroups,\n} from \"../../sqlite/organizationReadModelSchema\";\nimport type { ClientSQLiteTransactionScope } from \"../../sqlite/sqlitePersistenceRuntime\";\n\nconst GROUP_MEMBERSHIP_BATCH_SIZE = 90;\nconst GROUP_MEMBER_INSERT_BATCH_SIZE = 30;\n\nexport class OrganizationReadModelBindingError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"OrganizationReadModelBindingError\";\n }\n}\n\nfunction membershipMemberKey(\n member: Pick<OrganizationGroupMemberResponse, \"userId\">,\n): string {\n return member.userId;\n}\n\nfunction assertUniqueMembershipRows(\n lane: OrganizationReadModelGroupMembershipsResponse,\n): void {\n const groupIds = lane.groups.map((group) => group.groupId);\n if (new Set(groupIds).size !== groupIds.length) {\n throw new Error(\n \"Organization read-model group memberships contain duplicate groups\",\n );\n }\n if (new Set(lane.deletedGroupIds).size !== lane.deletedGroupIds.length) {\n throw new Error(\n \"Organization read-model group memberships contain duplicate deletions\",\n );\n }\n const deletedGroupIds = new Set(lane.deletedGroupIds);\n if (groupIds.some((groupId) => deletedGroupIds.has(groupId))) {\n throw new Error(\n \"Organization read-model group membership is both replaced and deleted\",\n );\n }\n for (const group of lane.groups) {\n const memberKeys = group.members.map(membershipMemberKey);\n if (new Set(memberKeys).size !== memberKeys.length) {\n throw new Error(\n \"Organization read-model group membership contains duplicate members\",\n );\n }\n }\n}\n\ninterface ApplyGroupMembershipsLaneInput {\n readonly lane: OrganizationReadModelGroupMembershipsResponse;\n readonly organizationId: string;\n readonly replaceAll: boolean;\n readonly tx: ClientSQLiteTransactionScope;\n}\n\nasync function clearMembershipRows(\n input: ApplyGroupMembershipsLaneInput,\n): Promise<void> {\n await input.tx\n .delete(organizationReadModelGroupMembers)\n .where(\n eq(\n organizationReadModelGroupMembers.organizationId,\n input.organizationId,\n ),\n )\n .run();\n await input.tx\n .delete(organizationReadModelGroupMemberships)\n .where(\n eq(\n organizationReadModelGroupMemberships.organizationId,\n input.organizationId,\n ),\n )\n .run();\n}\n\nasync function deleteMembershipRows(\n input: ApplyGroupMembershipsLaneInput,\n groupIds: string[],\n): Promise<void> {\n for (\n let index = 0;\n index < groupIds.length;\n index += GROUP_MEMBERSHIP_BATCH_SIZE\n ) {\n const groupIdBatch = groupIds.slice(\n index,\n index + GROUP_MEMBERSHIP_BATCH_SIZE,\n );\n await input.tx\n .delete(organizationReadModelGroupMembers)\n .where(\n and(\n eq(\n organizationReadModelGroupMembers.organizationId,\n input.organizationId,\n ),\n inArray(organizationReadModelGroupMembers.groupId, groupIdBatch),\n ),\n )\n .run();\n await input.tx\n .delete(organizationReadModelGroupMemberships)\n .where(\n and(\n eq(\n organizationReadModelGroupMemberships.organizationId,\n input.organizationId,\n ),\n inArray(organizationReadModelGroupMemberships.groupId, groupIdBatch),\n ),\n )\n .run();\n }\n}\n\nasync function removeExistingMembershipRows(\n input: ApplyGroupMembershipsLaneInput,\n): Promise<void> {\n if (input.replaceAll) {\n if (input.lane.deletedGroupIds.length > 0) {\n throw new Error(\"Organization membership snapshot contains deletions\");\n }\n return clearMembershipRows(input);\n }\n const replacedGroupIds = input.lane.groups.map((group) => group.groupId);\n const removedGroupIds = [...replacedGroupIds, ...input.lane.deletedGroupIds];\n if (removedGroupIds.length > 0) {\n await deleteMembershipRows(input, removedGroupIds);\n }\n}\n\nasync function insertMembershipRows(\n input: ApplyGroupMembershipsLaneInput,\n): Promise<void> {\n if (input.lane.groups.length === 0) {\n return;\n }\n const membershipRows = input.lane.groups.map((group) => ({\n organizationId: input.organizationId,\n groupId: group.groupId,\n stateHash: group.stateHash,\n }));\n for (\n let index = 0;\n index < membershipRows.length;\n index += GROUP_MEMBERSHIP_BATCH_SIZE\n ) {\n await input.tx\n .insert(organizationReadModelGroupMemberships)\n .values(membershipRows.slice(index, index + GROUP_MEMBERSHIP_BATCH_SIZE))\n .run();\n }\n\n const memberRows = input.lane.groups.flatMap((group) =>\n group.members.map((member, sortOrder) => ({\n organizationId: input.organizationId,\n groupId: group.groupId,\n userId: member.userId,\n sortOrder,\n stateHash: group.stateHash,\n role: member.role,\n signingKeyFingerprint: member.signingKeyFingerprint,\n signingPublicKey: member.signingPublicKey,\n encapsulationPublicKey: member.encapsulationPublicKey,\n encapsulationKeyFingerprint: member.encapsulationKeyFingerprint,\n })),\n );\n for (\n let index = 0;\n index < memberRows.length;\n index += GROUP_MEMBER_INSERT_BATCH_SIZE\n ) {\n await input.tx\n .insert(organizationReadModelGroupMembers)\n .values(memberRows.slice(index, index + GROUP_MEMBER_INSERT_BATCH_SIZE))\n .run();\n }\n}\n\nexport async function applyGroupMembershipsLane(\n input: ApplyGroupMembershipsLaneInput,\n): Promise<void> {\n assertUniqueMembershipRows(input.lane);\n await removeExistingMembershipRows(input);\n await insertMembershipRows(input);\n}\n\nexport async function assertStoredGroupMembershipBindings(input: {\n readonly memberGroupId: string;\n readonly organizationId: string;\n readonly tx: ClientSQLiteTransactionScope;\n}): Promise<void> {\n const groups = await input.tx\n .select({\n groupId: organizationReadModelGroups.groupId,\n memberCount: organizationReadModelGroups.memberCount,\n stateHash: organizationReadModelGroups.stateHash,\n })\n .from(organizationReadModelGroups)\n .where(\n eq(organizationReadModelGroups.organizationId, input.organizationId),\n );\n const heads = await input.tx\n .select({\n groupId: organizationReadModelGroupMemberships.groupId,\n stateHash: organizationReadModelGroupMemberships.stateHash,\n })\n .from(organizationReadModelGroupMemberships)\n .where(\n eq(\n organizationReadModelGroupMemberships.organizationId,\n input.organizationId,\n ),\n );\n const members = await input.tx\n .select({\n groupId: organizationReadModelGroupMembers.groupId,\n stateHash: organizationReadModelGroupMembers.stateHash,\n })\n .from(organizationReadModelGroupMembers)\n .where(\n eq(\n organizationReadModelGroupMembers.organizationId,\n input.organizationId,\n ),\n );\n const headsByGroupId = new Map(heads.map((head) => [head.groupId, head]));\n const expectedHeadIds = new Set([\n input.memberGroupId,\n ...groups.flatMap((group) => (group.stateHash ? [group.groupId] : [])),\n ]);\n if (\n heads.length !== expectedHeadIds.size ||\n heads.some((head) => !expectedHeadIds.has(head.groupId))\n ) {\n throw new OrganizationReadModelBindingError(\n \"Organization group membership coverage is inconsistent\",\n );\n }\n\n const memberCountsByGroupId = new Map<string, number>();\n for (const member of members) {\n const head = headsByGroupId.get(member.groupId);\n if (!head || head.stateHash !== member.stateHash) {\n throw new OrganizationReadModelBindingError(\n \"Organization group membership member state is inconsistent\",\n );\n }\n memberCountsByGroupId.set(\n member.groupId,\n (memberCountsByGroupId.get(member.groupId) ?? 0) + 1,\n );\n }\n\n for (const group of groups) {\n const head = headsByGroupId.get(group.groupId);\n if (group.stateHash === null) {\n continue;\n }\n if (\n !head ||\n head.stateHash !== group.stateHash ||\n group.memberCount === null ||\n group.memberCount !== (memberCountsByGroupId.get(group.groupId) ?? 0)\n ) {\n throw new OrganizationReadModelBindingError(\n \"Organization group membership state hash is inconsistent\",\n );\n }\n }\n}\n"]}
1
+ {"version":3,"file":"organizationReadModelMembershipPersistence.js","sourceRoot":"","sources":["organizationReadModelMembershipPersistence.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n OrganizationReadModelGroupMemberResponse,\n OrganizationReadModelGroupMembershipsResponse,\n} from \"@tearleads/validators/response\";\nimport { and, eq, inArray, sql } from \"drizzle-orm\";\nimport {\n organizationReadModelGroupMembers,\n organizationReadModelGroups,\n} from \"../../sqlite/organizationReadModelSchema\";\nimport {\n sqliteInsertBatchSize,\n sqliteRowsPerStatement,\n} from \"../../sqlite/sqliteBindBudget\";\nimport type { ClientSQLiteTransactionScope } from \"../../sqlite/sqlitePersistenceRuntime\";\n\n// Group ID lists bind one value per group; the head-clearing update also\n// binds its null value beside the organization ID.\nconst GROUP_MEMBERSHIP_BATCH_SIZE = sqliteRowsPerStatement(1, 2);\n// Each head binds two values in its CASE arm and one in the IN list, plus the\n// organization ID.\nconst GROUP_HEAD_UPDATE_BATCH_SIZE = sqliteRowsPerStatement(3, 1);\nconst GROUP_MEMBER_INSERT_BATCH_SIZE = sqliteInsertBatchSize(\n organizationReadModelGroupMembers,\n);\n\nexport class OrganizationReadModelBindingError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"OrganizationReadModelBindingError\";\n }\n}\n\nfunction membershipCoverageError(): OrganizationReadModelBindingError {\n return new OrganizationReadModelBindingError(\n \"Organization group membership coverage is inconsistent\",\n );\n}\n\nfunction membershipMemberKey(\n member: Pick<OrganizationReadModelGroupMemberResponse, \"userId\">,\n): string {\n return member.userId;\n}\n\nfunction assertUniqueMembershipRows(\n lane: OrganizationReadModelGroupMembershipsResponse,\n): void {\n const groupIds = lane.groups.map((group) => group.groupId);\n if (new Set(groupIds).size !== groupIds.length) {\n throw new Error(\n \"Organization read-model group memberships contain duplicate groups\",\n );\n }\n if (new Set(lane.deletedGroupIds).size !== lane.deletedGroupIds.length) {\n throw new Error(\n \"Organization read-model group memberships contain duplicate deletions\",\n );\n }\n const deletedGroupIds = new Set(lane.deletedGroupIds);\n if (groupIds.some((groupId) => deletedGroupIds.has(groupId))) {\n throw new Error(\n \"Organization read-model group membership is both replaced and deleted\",\n );\n }\n for (const group of lane.groups) {\n const memberKeys = group.members.map(membershipMemberKey);\n if (new Set(memberKeys).size !== memberKeys.length) {\n throw new Error(\n \"Organization read-model group membership contains duplicate members\",\n );\n }\n }\n}\n\ninterface ApplyGroupMembershipsLaneInput {\n /** The reserved Members head before this lane applies. */\n readonly currentMemberGroupStateHash: string | null;\n readonly lane: OrganizationReadModelGroupMembershipsResponse;\n readonly memberGroupId: string;\n readonly organizationId: string;\n readonly replaceAll: boolean;\n readonly tx: ClientSQLiteTransactionScope;\n}\n\nasync function clearMembershipRows(\n input: ApplyGroupMembershipsLaneInput,\n): Promise<void> {\n await input.tx\n .delete(organizationReadModelGroupMembers)\n .where(\n eq(\n organizationReadModelGroupMembers.organizationId,\n input.organizationId,\n ),\n )\n .run();\n await input.tx\n .update(organizationReadModelGroups)\n .set({ membersStateHash: null })\n .where(eq(organizationReadModelGroups.organizationId, input.organizationId))\n .run();\n}\n\nasync function deleteMembershipRows(\n input: ApplyGroupMembershipsLaneInput,\n groupIds: string[],\n): Promise<void> {\n for (\n let index = 0;\n index < groupIds.length;\n index += GROUP_MEMBERSHIP_BATCH_SIZE\n ) {\n const groupIdBatch = groupIds.slice(\n index,\n index + GROUP_MEMBERSHIP_BATCH_SIZE,\n );\n await input.tx\n .delete(organizationReadModelGroupMembers)\n .where(\n and(\n eq(\n organizationReadModelGroupMembers.organizationId,\n input.organizationId,\n ),\n inArray(organizationReadModelGroupMembers.groupId, groupIdBatch),\n ),\n )\n .run();\n await input.tx\n .update(organizationReadModelGroups)\n .set({ membersStateHash: null })\n .where(\n and(\n eq(organizationReadModelGroups.organizationId, input.organizationId),\n inArray(organizationReadModelGroups.groupId, groupIdBatch),\n ),\n )\n .run();\n }\n}\n\nasync function removeExistingMembershipRows(\n input: ApplyGroupMembershipsLaneInput,\n): Promise<void> {\n if (input.replaceAll) {\n if (input.lane.deletedGroupIds.length > 0) {\n throw new Error(\"Organization membership snapshot contains deletions\");\n }\n return clearMembershipRows(input);\n }\n const replacedGroupIds = input.lane.groups.map((group) => group.groupId);\n const removedGroupIds = [...replacedGroupIds, ...input.lane.deletedGroupIds];\n if (removedGroupIds.length > 0) {\n await deleteMembershipRows(input, removedGroupIds);\n }\n}\n\n/**\n * Record each visible group's membership head on its catalog row. A head for\n * a group the catalog does not list cannot be bound and fails the page. The\n * reserved Members head binds to the state row instead; the catalog normally\n * omits Members, so its row is updated only when one exists.\n */\nasync function writeVisibleGroupHeads(\n input: ApplyGroupMembershipsLaneInput,\n): Promise<void> {\n const memberGroupHead = input.lane.groups.find(\n (group) => group.groupId === input.memberGroupId,\n );\n if (memberGroupHead) {\n await input.tx\n .update(organizationReadModelGroups)\n .set({ membersStateHash: memberGroupHead.stateHash })\n .where(\n and(\n eq(organizationReadModelGroups.organizationId, input.organizationId),\n eq(organizationReadModelGroups.groupId, input.memberGroupId),\n ),\n )\n .run();\n }\n const heads = input.lane.groups.filter(\n (group) => group.groupId !== input.memberGroupId,\n );\n for (\n let index = 0;\n index < heads.length;\n index += GROUP_HEAD_UPDATE_BATCH_SIZE\n ) {\n const batch = heads.slice(index, index + GROUP_HEAD_UPDATE_BATCH_SIZE);\n const arms = sql.join(\n batch.map((head) => sql`WHEN ${head.groupId} THEN ${head.stateHash}`),\n sql` `,\n );\n const updated = await input.tx\n .update(organizationReadModelGroups)\n .set({\n membersStateHash: sql`CASE ${organizationReadModelGroups.groupId} ${arms} END`,\n })\n .where(\n and(\n eq(organizationReadModelGroups.organizationId, input.organizationId),\n inArray(\n organizationReadModelGroups.groupId,\n batch.map((head) => head.groupId),\n ),\n ),\n )\n .returning({ groupId: organizationReadModelGroups.groupId });\n if (updated.length !== batch.length) {\n throw membershipCoverageError();\n }\n }\n}\n\nasync function insertMemberRows(\n input: ApplyGroupMembershipsLaneInput,\n): Promise<void> {\n const memberRows = input.lane.groups.flatMap((group) =>\n group.members.map((member, sortOrder) => ({\n organizationId: input.organizationId,\n groupId: group.groupId,\n userId: member.userId,\n sortOrder,\n stateHash: group.stateHash,\n role: member.role,\n })),\n );\n for (\n let index = 0;\n index < memberRows.length;\n index += GROUP_MEMBER_INSERT_BATCH_SIZE\n ) {\n await input.tx\n .insert(organizationReadModelGroupMembers)\n .values(memberRows.slice(index, index + GROUP_MEMBER_INSERT_BATCH_SIZE))\n .run();\n }\n}\n\nfunction nextMemberGroupStateHash(\n input: ApplyGroupMembershipsLaneInput,\n): string | null {\n const head = input.lane.groups.find(\n (group) => group.groupId === input.memberGroupId,\n );\n if (head) {\n return head.stateHash;\n }\n return input.replaceAll ||\n input.lane.deletedGroupIds.includes(input.memberGroupId)\n ? null\n : input.currentMemberGroupStateHash;\n}\n\n/**\n * Apply one groupMemberships lane and return the reserved Members head, which\n * the caller persists on the organization state row.\n */\nexport async function applyGroupMembershipsLane(\n input: ApplyGroupMembershipsLaneInput,\n): Promise<string | null> {\n assertUniqueMembershipRows(input.lane);\n await removeExistingMembershipRows(input);\n await writeVisibleGroupHeads(input);\n await insertMemberRows(input);\n return nextMemberGroupStateHash(input);\n}\n\ninterface StoredGroupHead {\n readonly groupId: string;\n readonly memberCount: number | null;\n readonly membersStateHash: string | null;\n readonly stateHash: string | null;\n}\n\nfunction membershipHeadsByGroupId(input: {\n readonly groups: readonly StoredGroupHead[];\n readonly memberGroupId: string;\n readonly memberGroupStateHash: string | null;\n}): Map<string, string> {\n if (input.memberGroupStateHash === null) {\n throw membershipCoverageError();\n }\n const heads = new Map([[input.memberGroupId, input.memberGroupStateHash]]);\n for (const group of input.groups) {\n if (group.membersStateHash === null) {\n continue;\n }\n // A head is expected exactly for stateful visible groups; a listed\n // Members row must carry the same head as the state row.\n const isMemberGroup = group.groupId === input.memberGroupId;\n if (\n group.stateHash === null ||\n (isMemberGroup\n ? group.membersStateHash !== input.memberGroupStateHash\n : heads.has(group.groupId))\n ) {\n throw membershipCoverageError();\n }\n heads.set(group.groupId, group.membersStateHash);\n }\n return heads;\n}\n\nexport async function assertStoredGroupMembershipBindings(input: {\n readonly memberGroupId: string;\n readonly memberGroupStateHash: string | null;\n readonly organizationId: string;\n readonly tx: ClientSQLiteTransactionScope;\n}): Promise<void> {\n const groups = await input.tx\n .select({\n groupId: organizationReadModelGroups.groupId,\n memberCount: organizationReadModelGroups.memberCount,\n membersStateHash: organizationReadModelGroups.membersStateHash,\n stateHash: organizationReadModelGroups.stateHash,\n })\n .from(organizationReadModelGroups)\n .where(\n eq(organizationReadModelGroups.organizationId, input.organizationId),\n );\n const members = await input.tx\n .select({\n groupId: organizationReadModelGroupMembers.groupId,\n stateHash: organizationReadModelGroupMembers.stateHash,\n })\n .from(organizationReadModelGroupMembers)\n .where(\n eq(\n organizationReadModelGroupMembers.organizationId,\n input.organizationId,\n ),\n );\n const headsByGroupId = membershipHeadsByGroupId({ ...input, groups });\n\n const memberCountsByGroupId = new Map<string, number>();\n for (const member of members) {\n if (headsByGroupId.get(member.groupId) !== member.stateHash) {\n throw new OrganizationReadModelBindingError(\n \"Organization group membership member state is inconsistent\",\n );\n }\n memberCountsByGroupId.set(\n member.groupId,\n (memberCountsByGroupId.get(member.groupId) ?? 0) + 1,\n );\n }\n\n for (const group of groups) {\n if (group.stateHash === null) {\n continue;\n }\n if (\n group.membersStateHash !== group.stateHash ||\n group.memberCount === null ||\n group.memberCount !== (memberCountsByGroupId.get(group.groupId) ?? 0)\n ) {\n throw new OrganizationReadModelBindingError(\n \"Organization group membership state hash is inconsistent\",\n );\n }\n }\n}\n"]}
@@ -3,17 +3,17 @@ import { invalidatePrincipalRecovery } from "../../principals/principalRecoveryE
3
3
  import { organizationDataUsageTables } from "../../sqlite/organizationDataUsageSchema.js";
4
4
  import { organizationReadModelDirectoryUsers, organizationReadModelGroups, organizationReadModelRequesters, organizationReadModelState, } from "../../sqlite/organizationReadModelSchema.js";
5
5
  import { organizationReadModelTables } from "../../sqlite/schema.js";
6
+ import { sqliteInsertBatchSize } from "../../sqlite/sqliteBindBudget.js";
6
7
  import { getClientSQLitePersistenceRuntime, } from "../../sqlite/sqlitePersistenceRuntime.js";
7
8
  import { ensureSqlTables } from "../../sqlite/sqlSchema.js";
8
9
  import { clearOrganizationPresentationDenialInTransaction } from "./organizationPresentationDenialPersistence.js";
9
10
  import { applyOrganizationReadModelGrantsLane } from "./organizationReadModelGrantPersistence.js";
10
- import { applyGroupMembershipsLane, assertStoredGroupMembershipBindings, } from "./organizationReadModelMembershipPersistence.js";
11
- import { applyOrganizationPolicyLane, replaceGroupPolicyHeads, } from "./organizationReadModelPolicyHeadPersistence.js";
11
+ import { applyGroupMembershipsLane, assertStoredGroupMembershipBindings, OrganizationReadModelBindingError, } from "./organizationReadModelMembershipPersistence.js";
12
12
  import { loadOrganizationReadModelProjectionInTransaction, } from "./organizationReadModelProjection.js";
13
13
  import { loadWithOrganizationReadModelIntegrityPurge, pruneInactiveOrganizationRequestersInTransaction, purgeOrganizationReadModelProjectionInTransaction, } from "./organizationReadModelPurge.js";
14
14
  import { assertOrganizationReadModelResponse } from "./organizationReadModelResponseAssertions.js";
15
- const DIRECTORY_INSERT_BATCH_SIZE = 64;
16
- const GROUP_INSERT_BATCH_SIZE = 90;
15
+ const DIRECTORY_INSERT_BATCH_SIZE = sqliteInsertBatchSize(organizationReadModelDirectoryUsers);
16
+ const GROUP_INSERT_BATCH_SIZE = sqliteInsertBatchSize(organizationReadModelGroups);
17
17
  async function replaceDirectoryLane(input) {
18
18
  await input.tx
19
19
  .delete(organizationReadModelDirectoryUsers)
@@ -73,43 +73,35 @@ async function upsertRequester(input) {
73
73
  tx: input.tx,
74
74
  });
75
75
  }
76
- /** The directory lane is the authoritative roster: requester rows for users
77
- * it no longer lists as active must fall with it. */
78
- async function pruneRequestersForDirectoryLane(input) {
79
- await pruneInactiveOrganizationRequestersInTransaction({
80
- activeUserIds: input.directory.users
81
- .filter((user) => user.status === "active")
82
- .map((user) => user.userId),
83
- organizationId: input.organizationId,
84
- retainUserId: input.currentUserId,
85
- tx: input.tx,
86
- });
87
- }
88
76
  async function replaceGroupsLane(input) {
77
+ // Hydrated names and delivered membership heads outlive a catalog
78
+ // replacement; the groupMemberships lane rebinds any head that changed.
89
79
  const previousGroups = await input.tx
90
80
  .select({
91
81
  groupId: organizationReadModelGroups.groupId,
82
+ membersStateHash: organizationReadModelGroups.membersStateHash,
92
83
  name: organizationReadModelGroups.name,
93
84
  })
94
85
  .from(organizationReadModelGroups)
95
86
  .where(eq(organizationReadModelGroups.organizationId, input.organizationId));
96
- const names = new Map(previousGroups.map((group) => [group.groupId, group.name]));
87
+ const previousById = new Map(previousGroups.map((group) => [group.groupId, group]));
97
88
  await input.tx
98
89
  .delete(organizationReadModelGroups)
99
90
  .where(eq(organizationReadModelGroups.organizationId, input.organizationId))
100
91
  .run();
101
- await replaceGroupPolicyHeads(input);
102
92
  const rows = input.groups.groups.map((group, sortOrder) => ({
103
93
  organizationId: input.organizationId,
104
94
  groupId: group.groupId,
105
95
  sortOrder,
106
- name: names.get(group.groupId) ?? "",
96
+ name: previousById.get(group.groupId)?.name ?? "",
107
97
  createdAt: group.createdAt,
108
98
  isBuiltin: group.isBuiltin,
109
99
  stateHash: group.currentState?.stateHash ?? null,
110
100
  stateVersion: group.currentState?.version ?? null,
111
101
  keyEpoch: group.currentState?.keyEpoch ?? null,
102
+ keyFingerprint: group.currentState?.keyFingerprint ?? null,
112
103
  memberCount: group.currentState?.memberCount ?? null,
104
+ membersStateHash: previousById.get(group.groupId)?.membersStateHash ?? null,
113
105
  }));
114
106
  for (let index = 0; index < rows.length; index += GROUP_INSERT_BATCH_SIZE) {
115
107
  await input.tx
@@ -122,13 +114,32 @@ async function loadCurrentState(tx, organizationId) {
122
114
  const [current] = await tx
123
115
  .select({
124
116
  cursor: organizationReadModelState.cursor,
117
+ keyEpoch: organizationReadModelState.organizationKeyEpoch,
118
+ keyFingerprint: organizationReadModelState.organizationKeyFingerprint,
119
+ memberCount: organizationReadModelState.organizationMemberCount,
125
120
  memberGroupId: organizationReadModelState.memberGroupId,
121
+ memberGroupStateHash: organizationReadModelState.memberGroupStateHash,
126
122
  profileDocumentId: organizationReadModelState.profileDocumentId,
123
+ stateHash: organizationReadModelState.organizationStateHash,
124
+ version: organizationReadModelState.organizationStateVersion,
127
125
  })
128
126
  .from(organizationReadModelState)
129
127
  .where(eq(organizationReadModelState.organizationId, organizationId))
130
128
  .limit(1);
131
- return current ?? null;
129
+ if (!current) {
130
+ return null;
131
+ }
132
+ const { keyEpoch, keyFingerprint, memberCount, stateHash, version, ...rest } = current;
133
+ return {
134
+ ...rest,
135
+ organizationHead: {
136
+ keyEpoch,
137
+ keyFingerprint,
138
+ memberCount,
139
+ stateHash,
140
+ version,
141
+ },
142
+ };
132
143
  }
133
144
  function resolveApplyDisposition(input) {
134
145
  const currentCursor = input.current?.cursor ?? null;
@@ -142,67 +153,92 @@ function resolveApplyDisposition(input) {
142
153
  }
143
154
  return null;
144
155
  }
156
+ /** Apply every carried lane and return the reserved Members head. */
145
157
  async function replaceResponseLanes(input) {
158
+ const { organizationId } = input.response;
146
159
  const directory = input.response.lanes.directory;
147
160
  if (directory) {
148
- await replaceDirectoryLane({
149
- directory,
150
- organizationId: input.response.organizationId,
151
- tx: input.tx,
152
- });
161
+ await replaceDirectoryLane({ directory, organizationId, tx: input.tx });
153
162
  }
154
163
  const groups = input.response.lanes.groups;
155
164
  if (groups) {
156
- await replaceGroupsLane({
157
- groups,
158
- organizationId: input.response.organizationId,
159
- tx: input.tx,
160
- });
161
- }
162
- const organizationPolicy = input.response.lanes.organizationPolicy;
163
- if (organizationPolicy) {
164
- await applyOrganizationPolicyLane({
165
- organizationId: input.response.organizationId,
166
- organizationPolicy,
167
- tx: input.tx,
168
- });
165
+ await replaceGroupsLane({ groups, organizationId, tx: input.tx });
169
166
  }
170
167
  const groupMemberships = input.response.lanes.groupMemberships;
171
- if (groupMemberships) {
172
- await applyGroupMembershipsLane({
168
+ const memberGroupStateHash = groupMemberships
169
+ ? await applyGroupMembershipsLane({
170
+ currentMemberGroupStateHash: input.current?.memberGroupStateHash ?? null,
173
171
  lane: groupMemberships,
174
- organizationId: input.response.organizationId,
172
+ memberGroupId: input.memberGroupId,
173
+ organizationId,
175
174
  replaceAll: input.response.mode === "snapshot",
176
175
  tx: input.tx,
177
- });
178
- }
176
+ })
177
+ : (input.current?.memberGroupStateHash ?? null);
179
178
  const grants = input.response.lanes.grants;
180
179
  if (grants) {
181
180
  await applyOrganizationReadModelGrantsLane({
182
181
  lane: grants,
183
- organizationId: input.response.organizationId,
182
+ organizationId,
184
183
  tx: input.tx,
185
184
  });
186
185
  }
186
+ return memberGroupStateHash;
187
187
  }
188
188
  async function applyResponseLanes(input) {
189
- await replaceResponseLanes({ response: input.response, tx: input.tx });
190
- const directoryLane = input.response.lanes.directory;
191
- if (directoryLane) {
192
- await pruneRequestersForDirectoryLane({
193
- currentUserId: input.currentUserId,
194
- directory: directoryLane,
189
+ const memberGroupStateHash = await replaceResponseLanes(input);
190
+ // The directory lane is the authoritative roster, already persisted above:
191
+ // requester rows for users it no longer lists as active fall with it.
192
+ if (input.response.lanes.directory) {
193
+ await pruneInactiveOrganizationRequestersInTransaction({
195
194
  organizationId: input.response.organizationId,
195
+ retainUserId: input.currentUserId,
196
196
  tx: input.tx,
197
197
  });
198
198
  }
199
199
  if (input.response.lanes.groups || input.response.lanes.groupMemberships) {
200
200
  await assertStoredGroupMembershipBindings({
201
201
  memberGroupId: input.memberGroupId,
202
+ memberGroupStateHash,
202
203
  organizationId: input.response.organizationId,
203
204
  tx: input.tx,
204
205
  });
205
206
  }
207
+ return memberGroupStateHash;
208
+ }
209
+ /**
210
+ * Write the cursor with the organization policy head and the reserved Members
211
+ * head in one row, so a failure anywhere in the page rolls them back together.
212
+ */
213
+ async function upsertOrganizationReadModelState(input) {
214
+ const organizationHead = input.response.lanes.organizationPolicy?.currentState ??
215
+ input.current?.organizationHead;
216
+ if (!organizationHead || input.memberGroupStateHash === null) {
217
+ throw new OrganizationReadModelBindingError("Organization read-model policy or Members head is missing");
218
+ }
219
+ const directoryLane = input.response.lanes.directory;
220
+ const values = {
221
+ cursor: input.response.nextCursor,
222
+ profileDocumentId: directoryLane !== undefined
223
+ ? directoryLane.profileDocumentId
224
+ : (input.current?.profileDocumentId ?? null),
225
+ memberGroupId: input.memberGroupId,
226
+ memberGroupStateHash: input.memberGroupStateHash,
227
+ organizationStateHash: organizationHead.stateHash,
228
+ organizationStateVersion: organizationHead.version,
229
+ organizationKeyEpoch: organizationHead.keyEpoch,
230
+ organizationKeyFingerprint: organizationHead.keyFingerprint,
231
+ organizationMemberCount: organizationHead.memberCount,
232
+ updatedAt: input.now,
233
+ };
234
+ await input.tx
235
+ .insert(organizationReadModelState)
236
+ .values({ ...values, organizationId: input.response.organizationId })
237
+ .onConflictDoUpdate({
238
+ target: organizationReadModelState.organizationId,
239
+ set: values,
240
+ })
241
+ .run();
206
242
  }
207
243
  function requireMemberGroupId(input) {
208
244
  const memberGroupId = input.response.lanes.groups?.memberGroupId ?? input.current?.memberGroupId;
@@ -257,35 +293,21 @@ export async function applyOrganizationReadModelResponse(input) {
257
293
  current,
258
294
  response: input.response,
259
295
  });
260
- await applyResponseLanes({
296
+ const memberGroupStateHash = await applyResponseLanes({
297
+ current,
261
298
  currentUserId: input.currentUserId,
262
299
  memberGroupId,
263
300
  response: input.response,
264
301
  tx,
265
302
  });
266
- const directoryLane = input.response.lanes.directory;
267
- const stateRow = {
268
- organizationId: input.response.organizationId,
269
- cursor: input.response.nextCursor,
270
- profileDocumentId: directoryLane !== undefined
271
- ? directoryLane.profileDocumentId
272
- : (current?.profileDocumentId ?? null),
303
+ await upsertOrganizationReadModelState({
304
+ current,
273
305
  memberGroupId,
274
- updatedAt: now,
275
- };
276
- await tx
277
- .insert(organizationReadModelState)
278
- .values(stateRow)
279
- .onConflictDoUpdate({
280
- target: organizationReadModelState.organizationId,
281
- set: {
282
- cursor: stateRow.cursor,
283
- profileDocumentId: stateRow.profileDocumentId,
284
- memberGroupId: stateRow.memberGroupId,
285
- updatedAt: stateRow.updatedAt,
286
- },
287
- })
288
- .run();
306
+ memberGroupStateHash,
307
+ now,
308
+ response: input.response,
309
+ tx,
310
+ });
289
311
  return "applied";
290
312
  });
291
313
  // An applied change follows an organization hint or mutation; in-flight