@kindgi/platform 0.1.3 → 0.1.4-rc.1

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 (40) hide show
  1. package/README.md +31 -0
  2. package/dist/in-memory/index.d.ts +1 -0
  3. package/dist/in-memory/index.d.ts.map +1 -1
  4. package/dist/in-memory/org-binding.d.ts.map +1 -1
  5. package/dist/in-memory/org-binding.js +33 -16
  6. package/dist/in-memory/org-binding.js.map +1 -1
  7. package/dist/in-memory/project-binding.d.ts +26 -1
  8. package/dist/in-memory/project-binding.d.ts.map +1 -1
  9. package/dist/in-memory/project-binding.js +47 -8
  10. package/dist/in-memory/project-binding.js.map +1 -1
  11. package/dist/in-memory/team-binding.d.ts +2 -1
  12. package/dist/in-memory/team-binding.d.ts.map +1 -1
  13. package/dist/in-memory/team-binding.js +31 -7
  14. package/dist/in-memory/team-binding.js.map +1 -1
  15. package/dist/index.d.ts +5 -4
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/org-binding.d.ts +56 -12
  19. package/dist/org-binding.d.ts.map +1 -1
  20. package/dist/project-binding.d.ts +76 -12
  21. package/dist/project-binding.d.ts.map +1 -1
  22. package/dist/team-binding.d.ts +70 -12
  23. package/dist/team-binding.d.ts.map +1 -1
  24. package/dist/tenant-hierarchy-binding.d.ts +31 -5
  25. package/dist/tenant-hierarchy-binding.d.ts.map +1 -1
  26. package/package.json +2 -2
  27. package/src/in-memory/index.ts +1 -0
  28. package/src/in-memory/org-binding.ts +41 -19
  29. package/src/in-memory/project-binding.ts +85 -16
  30. package/src/in-memory/team-binding.ts +48 -12
  31. package/src/index.ts +14 -1
  32. package/src/org-binding.ts +60 -12
  33. package/src/project-binding.ts +88 -12
  34. package/src/team-binding.ts +82 -12
  35. package/src/tenant-hierarchy-binding.ts +21 -6
  36. package/tests/in-memory-project-binding.test.ts +13 -1
  37. package/tests/in-memory-team-binding.test.ts +8 -1
  38. package/tests/org-binding.conformance.ts +93 -18
  39. package/tests/project-binding.conformance.ts +221 -37
  40. package/tests/team-binding.conformance.ts +120 -28
@@ -13,16 +13,22 @@
13
13
  *
14
14
  * `create` enforces the `Project.isDefault = true` uniqueness
15
15
  * guardrail at write time: creating a second Default in the same
16
- * tenant rejects.
16
+ * tenant resolves to `project-default-already-exists`. A project's
17
+ * slug is unique within its org, and a project without an org's among
18
+ * the tenant's projects without one (`slug-conflict`).
17
19
  */
18
20
 
19
- import type { Filter, Page, ProjectId, TeamId, TenantId, UserId } from '@kindgi/types';
21
+ import type { Filter, OrgId, Page, ProjectId, TeamId, TenantId, UserId } from '@kindgi/types';
20
22
 
21
23
  import type {
22
24
  ProjectBinding,
25
+ ProjectCreateOutcome,
23
26
  ProjectListFilter,
24
27
  ProjectMembershipAddInput,
28
+ ProjectMembershipAddOutcome,
25
29
  ProjectMembershipBinding,
30
+ ProjectMembershipUpdateRoleOutcome,
31
+ ProjectUpdateOutcome,
26
32
  } from '../project-binding.js';
27
33
  import type {
28
34
  TeamProjectGrant,
@@ -53,12 +59,21 @@ function grantKey(teamId: TeamId, projectId: ProjectId): string {
53
59
  return `${teamId}::${projectId}`;
54
60
  }
55
61
 
62
+ /**
63
+ * `orgExists`: whether an org is the tenant's (live). Given, a project or
64
+ * team created in or moved to another org is `org-not-found`, as the
65
+ * durable bindings answer; absent, any `orgId` is taken as it is.
66
+ */
67
+ export interface InMemoryHierarchyOptions {
68
+ readonly orgExists?: (tenantId: TenantId, orgId: OrgId) => boolean | Promise<boolean>;
69
+ }
70
+
56
71
  /**
57
72
  * Combined factory — returns the three project-related bindings that
58
73
  * share underlying storage. Any of them can be plucked out and passed
59
74
  * individually to consumers that need just one.
60
75
  */
61
- export function makeInMemoryProjectBinding(): {
76
+ export function makeInMemoryProjectBinding(options: InMemoryHierarchyOptions = {}): {
62
77
  readonly projects: ProjectBinding;
63
78
  readonly memberships: ProjectMembershipBinding;
64
79
  readonly grants: TeamProjectGrantBinding;
@@ -82,13 +97,43 @@ export function makeInMemoryProjectBinding(): {
82
97
  return false;
83
98
  }
84
99
 
100
+ /**
101
+ * Is `slug` held by another project (not `exceptId`) where a project of
102
+ * `orgId` lives: in that org, or, without an org, among the tenant's
103
+ * projects without one?
104
+ */
105
+ function slugTaken(
106
+ tenantId: TenantId,
107
+ orgId: Project['orgId'],
108
+ slug: string,
109
+ exceptId?: ProjectId,
110
+ ): boolean {
111
+ for (const row of projectRows.values()) {
112
+ if (
113
+ row.tenantId === tenantId &&
114
+ row.orgId === orgId &&
115
+ row.slug === slug &&
116
+ row.id !== exceptId
117
+ ) {
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+
85
124
  const projects: ProjectBinding = {
86
- async create(tenantId, spec: ProjectSpec): Promise<ProjectId> {
125
+ async create(tenantId, spec: ProjectSpec): Promise<ProjectCreateOutcome> {
87
126
  const isDefault = spec.isDefault === true;
127
+ // The Default guardrail first: a caller asking for the Default
128
+ // wants to hear that one exists, whatever its slug.
88
129
  if (isDefault && tenantAlreadyHasDefault(tenantId)) {
89
- throw new Error(
90
- `project-default-already-exists: tenant ${tenantId} already has an isDefault=true project`,
91
- );
130
+ return { kind: 'project-default-already-exists' };
131
+ }
132
+ if (spec.orgId !== undefined && (await options.orgExists?.(tenantId, spec.orgId)) === false) {
133
+ return { kind: 'org-not-found', orgId: spec.orgId };
134
+ }
135
+ if (slugTaken(tenantId, spec.orgId, spec.slug)) {
136
+ return { kind: 'slug-conflict', slug: spec.slug };
92
137
  }
93
138
  const id = nextProjectId();
94
139
  const now = nowTimestamp();
@@ -104,7 +149,7 @@ export function makeInMemoryProjectBinding(): {
104
149
  ...(spec.description !== undefined ? { description: spec.description } : {}),
105
150
  };
106
151
  projectRows.set(id, row);
107
- return id;
152
+ return { kind: 'ok', projectId: id };
108
153
  },
109
154
 
110
155
  async get(tenantId, projectId): Promise<Project | undefined> {
@@ -135,15 +180,31 @@ export function makeInMemoryProjectBinding(): {
135
180
  return undefined;
136
181
  },
137
182
 
138
- async update(tenantId, projectId, patch: ProjectPatch): Promise<void> {
183
+ async update(tenantId, projectId, patch: ProjectPatch): Promise<ProjectUpdateOutcome> {
139
184
  const row = findProjectInTenant(tenantId, projectId);
140
185
  if (row === undefined) {
141
- throw new Error(`project-not-found: ${projectId}`);
186
+ return { kind: 'project-not-found' };
142
187
  }
143
188
  let nextOrgId = row.orgId;
144
189
  if (patch.orgId !== undefined) {
145
190
  nextOrgId = patch.orgId === null ? undefined : patch.orgId;
146
191
  }
192
+ if (
193
+ patch.orgId !== undefined &&
194
+ patch.orgId !== null &&
195
+ (await options.orgExists?.(tenantId, patch.orgId)) === false
196
+ ) {
197
+ return { kind: 'org-not-found', orgId: patch.orgId };
198
+ }
199
+ // The slug it ends up with, where it ends up: a move to another org
200
+ // (or out of one) can meet a project with the same slug there.
201
+ const nextSlug = patch.slug ?? row.slug;
202
+ if (
203
+ (patch.slug !== undefined || patch.orgId !== undefined) &&
204
+ slugTaken(tenantId, nextOrgId, nextSlug, projectId)
205
+ ) {
206
+ return { kind: 'slug-conflict', slug: nextSlug };
207
+ }
147
208
  const nextDescription = patch.description ?? row.description;
148
209
  const next: Project = {
149
210
  id: row.id,
@@ -157,6 +218,7 @@ export function makeInMemoryProjectBinding(): {
157
218
  ...(nextDescription !== undefined ? { description: nextDescription } : {}),
158
219
  };
159
220
  projectRows.set(projectId, next);
221
+ return { kind: 'ok' };
160
222
  },
161
223
 
162
224
  async delete(tenantId, projectId): Promise<void> {
@@ -181,13 +243,13 @@ export function makeInMemoryProjectBinding(): {
181
243
  };
182
244
 
183
245
  const memberships: ProjectMembershipBinding = {
184
- async add(tenantId, input: ProjectMembershipAddInput): Promise<void> {
246
+ async add(tenantId, input: ProjectMembershipAddInput): Promise<ProjectMembershipAddOutcome> {
185
247
  const proj = findProjectInTenant(tenantId, input.projectId);
186
248
  if (proj === undefined) {
187
- throw new Error(`project-not-found: ${input.projectId}`);
249
+ return { kind: 'project-not-found' };
188
250
  }
189
251
  const key = projMembershipKey(input.projectId, input.userId);
190
- if (membershipRows.has(key)) return;
252
+ if (membershipRows.has(key)) return { kind: 'ok' };
191
253
  const row: ProjectMembership = {
192
254
  projectId: input.projectId,
193
255
  userId: input.userId,
@@ -195,6 +257,7 @@ export function makeInMemoryProjectBinding(): {
195
257
  joinedAt: nowTimestamp(),
196
258
  };
197
259
  membershipRows.set(key, row);
260
+ return { kind: 'ok' };
198
261
  },
199
262
 
200
263
  async remove(tenantId, projectId, userId): Promise<void> {
@@ -227,17 +290,23 @@ export function makeInMemoryProjectBinding(): {
227
290
  return paginate(all, filter.limit, filter.cursor);
228
291
  },
229
292
 
230
- async updateRole(tenantId, projectId, userId, role: ProjectRole): Promise<void> {
293
+ async updateRole(
294
+ tenantId,
295
+ projectId,
296
+ userId,
297
+ role: ProjectRole,
298
+ ): Promise<ProjectMembershipUpdateRoleOutcome> {
231
299
  const proj = findProjectInTenant(tenantId, projectId);
232
300
  if (proj === undefined) {
233
- throw new Error(`project-not-found: ${projectId}`);
301
+ return { kind: 'project-not-found' };
234
302
  }
235
303
  const key = projMembershipKey(projectId, userId);
236
304
  const row = membershipRows.get(key);
237
305
  if (row === undefined) {
238
- throw new Error(`project-membership-not-found: ${projectId}/${userId}`);
306
+ return { kind: 'project-membership-not-found' };
239
307
  }
240
308
  membershipRows.set(key, { ...row, role });
309
+ return { kind: 'ok' };
241
310
  },
242
311
  };
243
312
 
@@ -15,18 +15,24 @@
15
15
  * Both factories are returned together so the two adapters share the
16
16
  * same team map (a membership add must fail cleanly for a
17
17
  * non-existent team; splitting into two disjoint factories would
18
- * require an extra cross-binding contract).
18
+ * require an extra cross-binding contract). Team slugs are unique
19
+ * within a tenant.
19
20
  */
20
21
 
21
22
  import type { Filter, Page, TeamId, TenantId, UserId } from '@kindgi/types';
22
23
 
23
24
  import type {
24
25
  TeamBinding,
26
+ TeamCreateOutcome,
25
27
  TeamListFilter,
26
28
  TeamMembershipAddInput,
29
+ TeamMembershipAddOutcome,
27
30
  TeamMembershipBinding,
31
+ TeamMembershipUpdateRoleOutcome,
32
+ TeamUpdateOutcome,
28
33
  } from '../team-binding.js';
29
34
  import type { Team, TeamMembership, TeamPatch, TeamRole, TeamSpec } from '../types.js';
35
+ import type { InMemoryHierarchyOptions } from './project-binding.js';
30
36
 
31
37
  import { nowTimestamp, paginate } from './util.js';
32
38
 
@@ -46,7 +52,7 @@ function membershipKey(teamId: TeamId, userId: UserId): string {
46
52
  * (memberships reference teams; the two must agree on tenant
47
53
  * membership at every op).
48
54
  */
49
- export function makeInMemoryTeamBinding(): {
55
+ export function makeInMemoryTeamBinding(options: InMemoryHierarchyOptions = {}): {
50
56
  readonly teams: TeamBinding;
51
57
  readonly memberships: TeamMembershipBinding;
52
58
  } {
@@ -59,8 +65,20 @@ export function makeInMemoryTeamBinding(): {
59
65
  return t;
60
66
  }
61
67
 
68
+ /** Is `slug` held by a team in the tenant other than `exceptId`? */
69
+ function slugTaken(tenantId: TenantId, slug: string, exceptId?: TeamId): boolean {
70
+ for (const row of teamRows.values()) {
71
+ if (row.tenantId === tenantId && row.slug === slug && row.id !== exceptId) return true;
72
+ }
73
+ return false;
74
+ }
75
+
62
76
  const teams: TeamBinding = {
63
- async create(tenantId, spec: TeamSpec): Promise<TeamId> {
77
+ async create(tenantId, spec: TeamSpec): Promise<TeamCreateOutcome> {
78
+ if (spec.orgId !== undefined && (await options.orgExists?.(tenantId, spec.orgId)) === false) {
79
+ return { kind: 'org-not-found', orgId: spec.orgId };
80
+ }
81
+ if (slugTaken(tenantId, spec.slug)) return { kind: 'slug-conflict', slug: spec.slug };
64
82
  const id = nextTeamId();
65
83
  const now = nowTimestamp();
66
84
  const row: Team = {
@@ -74,7 +92,7 @@ export function makeInMemoryTeamBinding(): {
74
92
  ...(spec.description !== undefined ? { description: spec.description } : {}),
75
93
  };
76
94
  teamRows.set(id, row);
77
- return id;
95
+ return { kind: 'ok', teamId: id };
78
96
  },
79
97
 
80
98
  async get(tenantId, teamId): Promise<Team | undefined> {
@@ -98,10 +116,20 @@ export function makeInMemoryTeamBinding(): {
98
116
  return paginate(all, filter.limit, filter.cursor);
99
117
  },
100
118
 
101
- async update(tenantId, teamId, patch: TeamPatch): Promise<void> {
119
+ async update(tenantId, teamId, patch: TeamPatch): Promise<TeamUpdateOutcome> {
102
120
  const row = findTeamInTenant(tenantId, teamId);
103
121
  if (row === undefined) {
104
- throw new Error(`team-not-found: ${teamId}`);
122
+ return { kind: 'team-not-found' };
123
+ }
124
+ if (
125
+ patch.orgId !== undefined &&
126
+ patch.orgId !== null &&
127
+ (await options.orgExists?.(tenantId, patch.orgId)) === false
128
+ ) {
129
+ return { kind: 'org-not-found', orgId: patch.orgId };
130
+ }
131
+ if (patch.slug !== undefined && slugTaken(tenantId, patch.slug, teamId)) {
132
+ return { kind: 'slug-conflict', slug: patch.slug };
105
133
  }
106
134
  // orgId patch tri-state: undefined = untouched; null = clear;
107
135
  // OrgId value = set.
@@ -121,6 +149,7 @@ export function makeInMemoryTeamBinding(): {
121
149
  ...(nextDescription !== undefined ? { description: nextDescription } : {}),
122
150
  };
123
151
  teamRows.set(teamId, next);
152
+ return { kind: 'ok' };
124
153
  },
125
154
 
126
155
  async delete(tenantId, teamId): Promise<void> {
@@ -137,15 +166,15 @@ export function makeInMemoryTeamBinding(): {
137
166
  };
138
167
 
139
168
  const memberships: TeamMembershipBinding = {
140
- async add(tenantId, input: TeamMembershipAddInput): Promise<void> {
169
+ async add(tenantId, input: TeamMembershipAddInput): Promise<TeamMembershipAddOutcome> {
141
170
  const team = findTeamInTenant(tenantId, input.teamId);
142
171
  if (team === undefined) {
143
- throw new Error(`team-not-found: ${input.teamId}`);
172
+ return { kind: 'team-not-found' };
144
173
  }
145
174
  const key = membershipKey(input.teamId, input.userId);
146
175
  if (membershipRows.has(key)) {
147
176
  // Idempotent — role mutation goes through updateRole.
148
- return;
177
+ return { kind: 'ok' };
149
178
  }
150
179
  const row: TeamMembership = {
151
180
  teamId: input.teamId,
@@ -154,6 +183,7 @@ export function makeInMemoryTeamBinding(): {
154
183
  joinedAt: nowTimestamp(),
155
184
  };
156
185
  membershipRows.set(key, row);
186
+ return { kind: 'ok' };
157
187
  },
158
188
 
159
189
  async remove(tenantId, teamId, userId): Promise<void> {
@@ -187,17 +217,23 @@ export function makeInMemoryTeamBinding(): {
187
217
  return paginate(all, filter.limit, filter.cursor);
188
218
  },
189
219
 
190
- async updateRole(tenantId, teamId, userId, role: TeamRole): Promise<void> {
220
+ async updateRole(
221
+ tenantId,
222
+ teamId,
223
+ userId,
224
+ role: TeamRole,
225
+ ): Promise<TeamMembershipUpdateRoleOutcome> {
191
226
  const team = findTeamInTenant(tenantId, teamId);
192
227
  if (team === undefined) {
193
- throw new Error(`team-not-found: ${teamId}`);
228
+ return { kind: 'team-not-found' };
194
229
  }
195
230
  const key = membershipKey(teamId, userId);
196
231
  const row = membershipRows.get(key);
197
232
  if (row === undefined) {
198
- throw new Error(`team-membership-not-found: ${teamId}/${userId}`);
233
+ return { kind: 'team-membership-not-found' };
199
234
  }
200
235
  membershipRows.set(key, { ...row, role });
236
+ return { kind: 'ok' };
201
237
  },
202
238
  };
203
239
 
package/src/index.ts CHANGED
@@ -15,19 +15,30 @@ export { scopeKey } from './scope.js';
15
15
 
16
16
  export type {
17
17
  OrgBinding,
18
+ OrgCreateOutcome,
18
19
  OrgListFilter,
20
+ OrgDeleteConflict,
21
+ OrgUpdateOutcome,
19
22
  } from './org-binding.js';
20
23
  export type {
21
24
  TeamBinding,
25
+ TeamCreateOutcome,
22
26
  TeamListFilter,
23
27
  TeamMembershipAddInput,
28
+ TeamMembershipAddOutcome,
24
29
  TeamMembershipBinding,
30
+ TeamMembershipUpdateRoleOutcome,
31
+ TeamUpdateOutcome,
25
32
  } from './team-binding.js';
26
33
  export type {
27
34
  ProjectBinding,
35
+ ProjectCreateOutcome,
28
36
  ProjectListFilter,
29
37
  ProjectMembershipAddInput,
38
+ ProjectMembershipAddOutcome,
30
39
  ProjectMembershipBinding,
40
+ ProjectMembershipUpdateRoleOutcome,
41
+ ProjectUpdateOutcome,
31
42
  } from './project-binding.js';
32
43
  export type {
33
44
  TeamProjectGrant,
@@ -40,9 +51,12 @@ export {
40
51
  makeInMemoryTeamBinding,
41
52
  makeInMemoryProjectBinding,
42
53
  } from './in-memory/index.js';
54
+ export type { InMemoryHierarchyOptions } from './in-memory/index.js';
43
55
 
44
56
  export type {
57
+ AddProjectMemberError,
45
58
  AddProjectMemberParams,
59
+ AddTeamMemberError,
46
60
  AddTeamMemberParams,
47
61
  CreateOrgError,
48
62
  CreateOrgParams,
@@ -50,7 +64,6 @@ export type {
50
64
  CreateProjectParams,
51
65
  CreateTeamError,
52
66
  CreateTeamParams,
53
- MembershipMutationError,
54
67
  TenantHierarchyBinding,
55
68
  TenantSummary,
56
69
  } from './tenant-hierarchy-binding.js';
@@ -12,6 +12,12 @@
12
12
  * `@kindgi/types` (`items` + `nextCursor`).
13
13
  * - `list` filters extend the shared `Filter` shape so every list call
14
14
  * shares the same pagination fields.
15
+ * - A write the caller can get wrong (a slug already taken, a row that
16
+ * doesn't exist) resolves to an outcome — a union discriminated on
17
+ * `kind`, `'ok'` on success — and never rejects for it. Each
18
+ * non-`ok` `kind` is also the API error code the routes answer with
19
+ * (`slug-conflict` is a 409, `org-not-found` a 404). A rejected
20
+ * promise means the backend failed.
15
21
  *
16
22
  * This package ships the interface + reference in-memory adapter
17
23
  * (in `./in-memory/org-binding.ts`). Any adapter can be checked with
@@ -43,11 +49,13 @@ export interface OrgListFilter extends Filter {
43
49
  */
44
50
  export interface OrgBinding {
45
51
  /**
46
- * Create a new `Org` in the given tenant. Returns the freshly-
47
- * assigned `OrgId`. The binding is responsible for allocating IDs,
48
- * timestamps, and slug-uniqueness enforcement within the tenant.
52
+ * Create a new `Org` in the given tenant. Resolves to `ok` with the
53
+ * freshly-assigned `OrgId`, or to `slug-conflict` when another org in
54
+ * the tenant has `spec.slug`. The binding is responsible for
55
+ * allocating IDs, timestamps, and slug-uniqueness enforcement within
56
+ * the tenant.
49
57
  */
50
- create(tenantId: TenantId, spec: OrgSpec): Promise<OrgId>;
58
+ create(tenantId: TenantId, spec: OrgSpec): Promise<OrgCreateOutcome>;
51
59
  /**
52
60
  * Look up an `Org` by id. Returns `undefined` when no row exists in
53
61
  * the given tenant, or when the row exists in a different tenant
@@ -62,15 +70,55 @@ export interface OrgBinding {
62
70
  list(tenantId: TenantId, filter: OrgListFilter): Promise<Page<Org>>;
63
71
  /**
64
72
  * Partially update an `Org`. Fields absent from `patch` are left
65
- * unchanged; fields present are set to the new value. Not-found is
66
- * an error the adapter surfaces (e.g. as a rejected promise); the
67
- * conformance suite specifies the exact shape.
73
+ * unchanged; fields present are set to the new value. Resolves to
74
+ * `org-not-found` when no org has `orgId` in the tenant, and to
75
+ * `slug-conflict` when `patch.slug` is another org's slug.
68
76
  */
69
- update(tenantId: TenantId, orgId: OrgId, patch: OrgPatch): Promise<void>;
77
+ update(tenantId: TenantId, orgId: OrgId, patch: OrgPatch): Promise<OrgUpdateOutcome>;
70
78
  /**
71
- * Delete an `Org`. Cascade semantics (dependent teams / projects) are
72
- * a storage-layer concern — the in-memory adapter does the delete
73
- * unconditionally.
79
+ * Delete an `Org`: a tombstone, not an erase. From then on `get`,
80
+ * `list` and `update` treat it as unknown, and its slug is free for a
81
+ * new org; a retention policy on the `org` domain purges the row.
82
+ * What happens to dependents (teams, projects, org-scoped config) is
83
+ * the storage's concern; the in-memory adapter has none. A storage
84
+ * whose projects leave a deleted org (the Kindgi runtime's: they
85
+ * become projects without an org) answers `slug-conflict` instead
86
+ * when one of them has the slug of a project that has no org, and
87
+ * deletes nothing. Nothing (`void`): the org is deleted, or wasn't
88
+ * there (or was already deleted).
74
89
  */
75
- delete(tenantId: TenantId, orgId: OrgId): Promise<void>;
90
+ // biome-ignore lint/suspicious/noConfusingVoidType: `void`, not `undefined`, so a binding whose `delete` returns `Promise<void>` still conforms.
91
+ delete(tenantId: TenantId, orgId: OrgId): Promise<void | OrgDeleteConflict>;
76
92
  }
93
+
94
+ /**
95
+ * Why `OrgBinding.delete` deleted nothing: the org's projects would leave
96
+ * it with `slugs` that projects without an org already have. Rename or
97
+ * move them first.
98
+ */
99
+ export interface OrgDeleteConflict {
100
+ readonly kind: 'slug-conflict';
101
+ readonly slugs: readonly string[];
102
+ }
103
+
104
+ /** What `OrgBinding.create` did. */
105
+ export type OrgCreateOutcome =
106
+ | { readonly kind: 'ok'; readonly orgId: OrgId }
107
+ | {
108
+ /** Another org in the tenant already has this slug. */
109
+ readonly kind: 'slug-conflict';
110
+ readonly slug: string;
111
+ };
112
+
113
+ /** What `OrgBinding.update` did. */
114
+ export type OrgUpdateOutcome =
115
+ | { readonly kind: 'ok' }
116
+ | {
117
+ /** No org with this id in the tenant. */
118
+ readonly kind: 'org-not-found';
119
+ }
120
+ | {
121
+ /** Another org in the tenant already has the patched slug. */
122
+ readonly kind: 'slug-conflict';
123
+ readonly slug: string;
124
+ };
@@ -20,7 +20,9 @@
20
20
  * `AgentPublishInput.projectId` in `@kindgi/api`).
21
21
  *
22
22
  * Same convention as `org-binding.ts`: `tenantId` first, `Page<T>`
23
- * return, `Filter`-extending filter shapes.
23
+ * return, `Filter`-extending filter shapes, and writes the caller can
24
+ * get wrong resolve to an outcome discriminated on `kind` instead of
25
+ * rejecting.
24
26
  */
25
27
 
26
28
  import type { Filter, OrgId, Page, ProjectId, TenantId, UserId } from '@kindgi/types';
@@ -58,12 +60,14 @@ export interface ProjectMembershipAddInput {
58
60
  */
59
61
  export interface ProjectBinding {
60
62
  /**
61
- * Create a `Project` within the tenant. `spec.orgId` optional. If
62
- * `spec.isDefault === true` and a Default already exists, the
63
- * binding rejects — the isDefault-uniqueness guardrail is enforced
64
- * at write time.
63
+ * Create a `Project` within the tenant. `spec.orgId` optional.
64
+ * Resolves to `ok` with the assigned `ProjectId`; to `slug-conflict`
65
+ * when another project in the tenant has `spec.slug`; and, when
66
+ * `spec.isDefault === true` and the tenant already has a Default, to
67
+ * `project-default-already-exists` — the isDefault-uniqueness
68
+ * guardrail is enforced at write time.
65
69
  */
66
- create(tenantId: TenantId, spec: ProjectSpec): Promise<ProjectId>;
70
+ create(tenantId: TenantId, spec: ProjectSpec): Promise<ProjectCreateOutcome>;
67
71
  /**
68
72
  * Look up a `Project` by id. `undefined` when unknown or in a
69
73
  * different tenant.
@@ -81,9 +85,15 @@ export interface ProjectBinding {
81
85
  getDefault(tenantId: TenantId): Promise<Project | undefined>;
82
86
  /**
83
87
  * Partially update a `Project`. `ProjectPatch.orgId` supports
84
- * `null` to re-assign the project out of any org.
88
+ * `null` to re-assign the project out of any org. Resolves to
89
+ * `project-not-found` when no project has `projectId` in the tenant,
90
+ * and to `slug-conflict` when `patch.slug` is another project's slug.
85
91
  */
86
- update(tenantId: TenantId, projectId: ProjectId, patch: ProjectPatch): Promise<void>;
92
+ update(
93
+ tenantId: TenantId,
94
+ projectId: ProjectId,
95
+ patch: ProjectPatch,
96
+ ): Promise<ProjectUpdateOutcome>;
87
97
  /**
88
98
  * Delete a `Project`. What happens to content rows that still
89
99
  * reference the project is the storage layer's concern; the
@@ -104,8 +114,10 @@ export interface ProjectMembershipBinding {
104
114
  * Add a user directly to a project. Idempotent on
105
115
  * `(projectId, userId)` — re-adding the same user does not mutate
106
116
  * a stored differing role. Use `updateRole` to change roles.
117
+ * Resolves to `project-not-found` when no project has
118
+ * `input.projectId` in the tenant.
107
119
  */
108
- add(tenantId: TenantId, input: ProjectMembershipAddInput): Promise<void>;
120
+ add(tenantId: TenantId, input: ProjectMembershipAddInput): Promise<ProjectMembershipAddOutcome>;
109
121
  /**
110
122
  * Remove the direct project grant. No-op when absent.
111
123
  */
@@ -122,13 +134,77 @@ export interface ProjectMembershipBinding {
122
134
  */
123
135
  listForUser(tenantId: TenantId, userId: UserId, filter: Filter): Promise<Page<ProjectMembership>>;
124
136
  /**
125
- * Update an existing direct-membership's role. Errors when the
126
- * membership doesn't exist.
137
+ * Update an existing direct-membership's role. Resolves to
138
+ * `project-not-found` when the project isn't in the tenant, and to
139
+ * `project-membership-not-found` when the user has no direct
140
+ * membership.
127
141
  */
128
142
  updateRole(
129
143
  tenantId: TenantId,
130
144
  projectId: ProjectId,
131
145
  userId: UserId,
132
146
  role: ProjectRole,
133
- ): Promise<void>;
147
+ ): Promise<ProjectMembershipUpdateRoleOutcome>;
134
148
  }
149
+
150
+ /** What `ProjectBinding.create` did. */
151
+ export type ProjectCreateOutcome =
152
+ | { readonly kind: 'ok'; readonly projectId: ProjectId }
153
+ | {
154
+ /** Another project in the tenant already has this slug. */
155
+ readonly kind: 'slug-conflict';
156
+ readonly slug: string;
157
+ }
158
+ | {
159
+ /** `spec.isDefault` was `true` and the tenant already has a Default project. */
160
+ readonly kind: 'project-default-already-exists';
161
+ }
162
+ | {
163
+ /**
164
+ * The org the project would be in isn't one of the tenant's: it never
165
+ * existed, or it was deleted. Nothing changed.
166
+ */
167
+ readonly kind: 'org-not-found';
168
+ readonly orgId: OrgId;
169
+ };
170
+
171
+ /** What `ProjectBinding.update` did. */
172
+ export type ProjectUpdateOutcome =
173
+ | { readonly kind: 'ok' }
174
+ | {
175
+ /** No project with this id in the tenant. */
176
+ readonly kind: 'project-not-found';
177
+ }
178
+ | {
179
+ /** Another project in the tenant already has the patched slug. */
180
+ readonly kind: 'slug-conflict';
181
+ readonly slug: string;
182
+ }
183
+ | {
184
+ /**
185
+ * The org the project would be in isn't one of the tenant's: it never
186
+ * existed, or it was deleted. Nothing changed.
187
+ */
188
+ readonly kind: 'org-not-found';
189
+ readonly orgId: OrgId;
190
+ };
191
+
192
+ /** What `ProjectMembershipBinding.add` did (`ok` when already a member, too). */
193
+ export type ProjectMembershipAddOutcome =
194
+ | { readonly kind: 'ok' }
195
+ | {
196
+ /** No project with this id in the tenant. */
197
+ readonly kind: 'project-not-found';
198
+ };
199
+
200
+ /** What `ProjectMembershipBinding.updateRole` did. */
201
+ export type ProjectMembershipUpdateRoleOutcome =
202
+ | { readonly kind: 'ok' }
203
+ | {
204
+ /** No project with this id in the tenant. */
205
+ readonly kind: 'project-not-found';
206
+ }
207
+ | {
208
+ /** The user has no direct membership on the project. */
209
+ readonly kind: 'project-membership-not-found';
210
+ };