@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
@@ -10,7 +10,9 @@
10
10
  * truth.
11
11
  *
12
12
  * Same convention as `org-binding.ts`: `tenantId` first, `Page<T>`
13
- * return, `Filter`-extending filter shapes.
13
+ * return, `Filter`-extending filter shapes, and writes the caller can
14
+ * get wrong resolve to an outcome discriminated on `kind` instead of
15
+ * rejecting.
14
16
  */
15
17
 
16
18
  import type { Filter, OrgId, Page, TeamId, TenantId, UserId } from '@kindgi/types';
@@ -46,10 +48,11 @@ export interface TeamMembershipAddInput {
46
48
  export interface TeamBinding {
47
49
  /**
48
50
  * Create a `Team` within the tenant. `spec.orgId` optional (a team
49
- * may be cross-org — nullable FK). Returns the assigned
50
- * `TeamId`.
51
+ * may be cross-org — nullable FK). Resolves to `ok` with the
52
+ * assigned `TeamId`, or to `slug-conflict` when another team in the
53
+ * tenant has `spec.slug`.
51
54
  */
52
- create(tenantId: TenantId, spec: TeamSpec): Promise<TeamId>;
55
+ create(tenantId: TenantId, spec: TeamSpec): Promise<TeamCreateOutcome>;
53
56
  /**
54
57
  * Look up a `Team` by id. `undefined` when unknown or in a different
55
58
  * tenant.
@@ -63,9 +66,11 @@ export interface TeamBinding {
63
66
  /**
64
67
  * Partially update a `Team`. `TeamPatch.orgId` supports `null` to
65
68
  * explicitly re-assign the team out of any org (cross-org), and
66
- * `undefined` to leave the FK untouched.
69
+ * `undefined` to leave the FK untouched. Resolves to `team-not-found`
70
+ * when no team has `teamId` in the tenant, and to `slug-conflict`
71
+ * when `patch.slug` is another team's slug.
67
72
  */
68
- update(tenantId: TenantId, teamId: TeamId, patch: TeamPatch): Promise<void>;
73
+ update(tenantId: TenantId, teamId: TeamId, patch: TeamPatch): Promise<TeamUpdateOutcome>;
69
74
  /**
70
75
  * Delete a `Team`. Its membership rows are removed with it (the
71
76
  * in-memory adapter deletes them from its map).
@@ -88,9 +93,10 @@ export interface TeamMembershipBinding {
88
93
  * silently mutate a differing role).
89
94
  *
90
95
  * The conformance suite pins this: adding an existing member whose
91
- * stored role differs is a no-op (not a role overwrite).
96
+ * stored role differs is a no-op (not a role overwrite). Resolves to
97
+ * `team-not-found` when no team has `input.teamId` in the tenant.
92
98
  */
93
- add(tenantId: TenantId, input: TeamMembershipAddInput): Promise<void>;
99
+ add(tenantId: TenantId, input: TeamMembershipAddInput): Promise<TeamMembershipAddOutcome>;
94
100
  /**
95
101
  * Remove a user from a team. Missing membership is a no-op (not an
96
102
  * error) — matches every peer platform's membership-remove shape.
@@ -108,9 +114,73 @@ export interface TeamMembershipBinding {
108
114
  */
109
115
  listForUser(tenantId: TenantId, userId: UserId, filter: Filter): Promise<Page<TeamMembership>>;
110
116
  /**
111
- * Update an existing membership's role. Errors when the membership
112
- * doesn't exist — role mutation of a non-member is a caller mistake,
113
- * not a silent add. Conformance suite pins the not-found behaviour.
117
+ * Update an existing membership's role. Resolves to `team-not-found`
118
+ * when the team isn't in the tenant, and to
119
+ * `team-membership-not-found` when the user isn't a member — role
120
+ * mutation of a non-member is a caller mistake, not a silent add.
114
121
  */
115
- updateRole(tenantId: TenantId, teamId: TeamId, userId: UserId, role: TeamRole): Promise<void>;
122
+ updateRole(
123
+ tenantId: TenantId,
124
+ teamId: TeamId,
125
+ userId: UserId,
126
+ role: TeamRole,
127
+ ): Promise<TeamMembershipUpdateRoleOutcome>;
116
128
  }
129
+
130
+ /** What `TeamBinding.create` did. */
131
+ export type TeamCreateOutcome =
132
+ | { readonly kind: 'ok'; readonly teamId: TeamId }
133
+ | {
134
+ /** Another team in the tenant already has this slug. */
135
+ readonly kind: 'slug-conflict';
136
+ readonly slug: string;
137
+ }
138
+ | {
139
+ /**
140
+ * The org the team would be in isn't one of the tenant's: it never
141
+ * existed, or it was deleted. Nothing changed.
142
+ */
143
+ readonly kind: 'org-not-found';
144
+ readonly orgId: OrgId;
145
+ };
146
+
147
+ /** What `TeamBinding.update` did. */
148
+ export type TeamUpdateOutcome =
149
+ | { readonly kind: 'ok' }
150
+ | {
151
+ /** No team with this id in the tenant. */
152
+ readonly kind: 'team-not-found';
153
+ }
154
+ | {
155
+ /** Another team in the tenant already has the patched slug. */
156
+ readonly kind: 'slug-conflict';
157
+ readonly slug: string;
158
+ }
159
+ | {
160
+ /**
161
+ * The org the team would be in isn't one of the tenant's: it never
162
+ * existed, or it was deleted. Nothing changed.
163
+ */
164
+ readonly kind: 'org-not-found';
165
+ readonly orgId: OrgId;
166
+ };
167
+
168
+ /** What `TeamMembershipBinding.add` did (`ok` when already a member, too). */
169
+ export type TeamMembershipAddOutcome =
170
+ | { readonly kind: 'ok' }
171
+ | {
172
+ /** No team with this id in the tenant. */
173
+ readonly kind: 'team-not-found';
174
+ };
175
+
176
+ /** What `TeamMembershipBinding.updateRole` did. */
177
+ export type TeamMembershipUpdateRoleOutcome =
178
+ | { readonly kind: 'ok' }
179
+ | {
180
+ /** No team with this id in the tenant. */
181
+ readonly kind: 'team-not-found';
182
+ }
183
+ | {
184
+ /** The user isn't a member of the team. */
185
+ readonly kind: 'team-membership-not-found';
186
+ };
@@ -35,6 +35,8 @@ export interface CreateTeamParams {
35
35
 
36
36
  export type CreateTeamError =
37
37
  | { readonly code: 'slug-conflict'; readonly message: string; readonly cause?: unknown }
38
+ /** `spec.orgId` isn't the tenant's: it never existed, or it was deleted. */
39
+ | { readonly code: 'org-not-found'; readonly message: string; readonly orgId: OrgId }
38
40
  | { readonly code: 'insert-failed'; readonly message: string; readonly cause?: unknown };
39
41
 
40
42
  export interface AddTeamMemberParams {
@@ -52,6 +54,8 @@ export interface CreateProjectParams {
52
54
 
53
55
  export type CreateProjectError =
54
56
  | { readonly code: 'slug-conflict'; readonly message: string; readonly cause?: unknown }
57
+ /** `spec.orgId` isn't the tenant's: it never existed, or it was deleted. */
58
+ | { readonly code: 'org-not-found'; readonly message: string; readonly orgId: OrgId }
55
59
  | { readonly code: 'default-conflict'; readonly message: string; readonly cause?: unknown }
56
60
  | { readonly code: 'insert-failed'; readonly message: string; readonly cause?: unknown };
57
61
 
@@ -62,10 +66,21 @@ export interface AddProjectMemberParams {
62
66
  readonly role: ProjectRole;
63
67
  }
64
68
 
65
- export interface MembershipMutationError {
66
- readonly code: string;
67
- readonly message: string;
68
- }
69
+ export type AddTeamMemberError =
70
+ | {
71
+ /** No team with this id in the tenant. */
72
+ readonly code: 'team-not-found';
73
+ readonly message: string;
74
+ }
75
+ | { readonly code: 'add-failed'; readonly message: string; readonly cause?: unknown };
76
+
77
+ export type AddProjectMemberError =
78
+ | {
79
+ /** No project with this id in the tenant. */
80
+ readonly code: 'project-not-found';
81
+ readonly message: string;
82
+ }
83
+ | { readonly code: 'add-failed'; readonly message: string; readonly cause?: unknown };
69
84
 
70
85
  /**
71
86
  * Tenant-summary read shape returned by `getTenant`. Only the fields
@@ -96,8 +111,8 @@ export interface TenantHierarchyBinding {
96
111
  createProject(
97
112
  params: CreateProjectParams,
98
113
  ): Promise<Result<{ readonly projectId: ProjectId }, CreateProjectError>>;
99
- addTeamMember(params: AddTeamMemberParams): Promise<Result<void, MembershipMutationError>>;
100
- addProjectMember(params: AddProjectMemberParams): Promise<Result<void, MembershipMutationError>>;
114
+ addTeamMember(params: AddTeamMemberParams): Promise<Result<void, AddTeamMemberError>>;
115
+ addProjectMember(params: AddProjectMemberParams): Promise<Result<void, AddProjectMemberError>>;
101
116
  /**
102
117
  * Look up a tenant by id. Returns `null` when no tenant exists.
103
118
  * Not itself tenant-scoped: it is how a caller finds out which
@@ -7,6 +7,18 @@
7
7
  */
8
8
 
9
9
  import { makeInMemoryProjectBinding } from '../src/in-memory/project-binding.js';
10
- import { runProjectBindingConformance } from './project-binding.conformance.js';
10
+ import {
11
+ CONFORMANCE_MISSING_ORG,
12
+ runProjectBindingConformance,
13
+ } from './project-binding.conformance.js';
11
14
 
12
15
  runProjectBindingConformance(makeInMemoryProjectBinding, 'InMemoryProjectBinding');
16
+ // With an org check: every org but the missing one is the tenant's.
17
+ runProjectBindingConformance(
18
+ () =>
19
+ makeInMemoryProjectBinding({
20
+ orgExists: (_tenantId, orgId) => orgId !== CONFORMANCE_MISSING_ORG,
21
+ }),
22
+ 'InMemoryProjectBinding (checking orgs)',
23
+ { checksOrgs: true },
24
+ );
@@ -7,6 +7,13 @@
7
7
  */
8
8
 
9
9
  import { makeInMemoryTeamBinding } from '../src/in-memory/team-binding.js';
10
- import { runTeamBindingConformance } from './team-binding.conformance.js';
10
+ import { CONFORMANCE_MISSING_ORG, runTeamBindingConformance } from './team-binding.conformance.js';
11
11
 
12
12
  runTeamBindingConformance(makeInMemoryTeamBinding, 'InMemoryTeamBinding');
13
+ // With an org check: every org but the missing one is the tenant's.
14
+ runTeamBindingConformance(
15
+ () =>
16
+ makeInMemoryTeamBinding({ orgExists: (_tenantId, orgId) => orgId !== CONFORMANCE_MISSING_ORG }),
17
+ 'InMemoryTeamBinding (checking orgs)',
18
+ { checksOrgs: true },
19
+ );
@@ -14,7 +14,12 @@
14
14
  * asserted at every reachable read op.
15
15
  * - Pagination via `Filter.cursor`.
16
16
  * - `nameContains` substring filter.
17
- * - `get`/`update`/`delete` behaviour on unknown orgIds.
17
+ * - `get`/`update`/`delete` behaviour on unknown orgIds: `update`
18
+ * resolves to `org-not-found`.
19
+ * - Slug uniqueness within a tenant: `create` and `update` resolve to
20
+ * `slug-conflict`; another tenant may reuse the slug.
21
+ * - `delete` (a tombstone in a durable binding): the org is unknown to
22
+ * every read, its slug is free, and deleting again is a no-op.
18
23
  */
19
24
 
20
25
  import { describe, expect, it } from 'vitest';
@@ -22,10 +27,18 @@ import { describe, expect, it } from 'vitest';
22
27
  import type { OrgId, TenantId } from '@kindgi/types';
23
28
 
24
29
  import type { OrgBinding } from '../src/org-binding.js';
30
+ import type { OrgSpec } from '../src/types.js';
25
31
 
26
32
  const T1 = 'tenant-1' as TenantId;
27
33
  const T2 = 'tenant-2' as TenantId;
28
34
 
35
+ /** Create an org the test expects to succeed; its id. */
36
+ async function createOrg(b: OrgBinding, tenantId: TenantId, spec: OrgSpec): Promise<OrgId> {
37
+ const outcome = await b.create(tenantId, spec);
38
+ if (outcome.kind !== 'ok') throw new Error(`expected the org to be created, got ${outcome.kind}`);
39
+ return outcome.orgId;
40
+ }
41
+
29
42
  export function runOrgBindingConformance(
30
43
  makeBinding: () => OrgBinding,
31
44
  label = 'OrgBinding',
@@ -33,7 +46,7 @@ export function runOrgBindingConformance(
33
46
  describe(`${label} — CRUD happy paths`, () => {
34
47
  it('creates + gets an Org', async () => {
35
48
  const b = makeBinding();
36
- const id = await b.create(T1, { name: 'Acme', slug: 'acme' });
49
+ const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
37
50
  const got = await b.get(T1, id);
38
51
  expect(got).toBeDefined();
39
52
  expect(got?.id).toBe(id);
@@ -44,8 +57,8 @@ export function runOrgBindingConformance(
44
57
 
45
58
  it('updates an Org partially', async () => {
46
59
  const b = makeBinding();
47
- const id = await b.create(T1, { name: 'Acme', slug: 'acme' });
48
- await b.update(T1, id, { name: 'Acme Corp' });
60
+ const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
61
+ expect(await b.update(T1, id, { name: 'Acme Corp' })).toEqual({ kind: 'ok' });
49
62
  const got = await b.get(T1, id);
50
63
  expect(got?.name).toBe('Acme Corp');
51
64
  expect(got?.slug).toBe('acme');
@@ -53,12 +66,38 @@ export function runOrgBindingConformance(
53
66
 
54
67
  it('deletes an Org', async () => {
55
68
  const b = makeBinding();
56
- const id = await b.create(T1, { name: 'Acme', slug: 'acme' });
69
+ const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
57
70
  await b.delete(T1, id);
58
71
  const got = await b.get(T1, id);
59
72
  expect(got).toBeUndefined();
60
73
  });
61
74
 
75
+ it('a deleted Org is unknown to list and update, and deleting it again is a no-op', async () => {
76
+ const b = makeBinding();
77
+ const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
78
+ const kept = await createOrg(b, T1, { name: 'Kept', slug: 'kept' });
79
+ await b.delete(T1, id);
80
+ const page = await b.list(T1, {});
81
+ expect(page.items.map((o) => o.id)).toEqual([kept]);
82
+ expect(await b.update(T1, id, { name: 'Acme 2' })).toEqual({ kind: 'org-not-found' });
83
+ await b.delete(T1, id);
84
+ expect(await b.get(T1, kept)).toBeDefined();
85
+ });
86
+
87
+ it("a deleted Org's slug is free for a new Org", async () => {
88
+ const b = makeBinding();
89
+ const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
90
+ await b.delete(T1, id);
91
+ const again = await createOrg(b, T1, { name: 'Acme again', slug: 'acme' });
92
+ expect(again).not.toBe(id);
93
+ expect((await b.get(T1, again))?.slug).toBe('acme');
94
+ const other = await createOrg(b, T1, { name: 'Other', slug: 'other' });
95
+ expect(await b.update(T1, other, { slug: 'acme' })).toEqual({
96
+ kind: 'slug-conflict',
97
+ slug: 'acme',
98
+ });
99
+ });
100
+
62
101
  it('lists orgs (empty tenant)', async () => {
63
102
  const b = makeBinding();
64
103
  const page = await b.list(T1, {});
@@ -70,27 +109,28 @@ export function runOrgBindingConformance(
70
109
  describe(`${label} — cross-tenant isolation`, () => {
71
110
  it('get on tenant B for tenant-A orgId returns undefined', async () => {
72
111
  const b = makeBinding();
73
- const id = await b.create(T1, { name: 'Acme', slug: 'acme' });
112
+ const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
74
113
  expect(await b.get(T2, id)).toBeUndefined();
75
114
  });
76
115
 
77
116
  it('list on tenant B does not surface tenant-A orgs', async () => {
78
117
  const b = makeBinding();
79
- await b.create(T1, { name: 'Acme', slug: 'acme' });
80
- await b.create(T1, { name: 'Globex', slug: 'globex' });
118
+ await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
119
+ await createOrg(b, T1, { name: 'Globex', slug: 'globex' });
81
120
  const t2Page = await b.list(T2, {});
82
121
  expect(t2Page.items).toEqual([]);
83
122
  });
84
123
 
85
- it('update on tenant B for tenant-A orgId errors', async () => {
124
+ it('update on tenant B for tenant-A orgId → org-not-found', async () => {
86
125
  const b = makeBinding();
87
- const id = await b.create(T1, { name: 'Acme', slug: 'acme' });
88
- await expect(b.update(T2, id, { name: 'X' })).rejects.toThrow();
126
+ const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
127
+ expect(await b.update(T2, id, { name: 'X' })).toEqual({ kind: 'org-not-found' });
128
+ expect((await b.get(T1, id))?.name).toBe('Acme');
89
129
  });
90
130
 
91
131
  it('delete on tenant B for tenant-A orgId is a no-op (does not mutate A)', async () => {
92
132
  const b = makeBinding();
93
- const id = await b.create(T1, { name: 'Acme', slug: 'acme' });
133
+ const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
94
134
  await b.delete(T2, id); // no-op
95
135
  const got = await b.get(T1, id);
96
136
  expect(got).toBeDefined();
@@ -102,7 +142,7 @@ export function runOrgBindingConformance(
102
142
  const b = makeBinding();
103
143
  const created: OrgId[] = [];
104
144
  for (let i = 0; i < 5; i += 1) {
105
- created.push(await b.create(T1, { name: `Org${i}`, slug: `org-${i}` }));
145
+ created.push(await createOrg(b, T1, { name: `Org${i}`, slug: `org-${i}` }));
106
146
  }
107
147
  const page1 = await b.list(T1, { limit: 2 });
108
148
  expect(page1.items).toHaveLength(2);
@@ -133,9 +173,9 @@ export function runOrgBindingConformance(
133
173
  describe(`${label} — nameContains filter`, () => {
134
174
  it('narrows to orgs whose name contains the substring', async () => {
135
175
  const b = makeBinding();
136
- await b.create(T1, { name: 'Litigation', slug: 'lit' });
137
- await b.create(T1, { name: 'Corporate', slug: 'corp' });
138
- await b.create(T1, { name: 'Litigation-Sub', slug: 'lit-sub' });
176
+ await createOrg(b, T1, { name: 'Litigation', slug: 'lit' });
177
+ await createOrg(b, T1, { name: 'Corporate', slug: 'corp' });
178
+ await createOrg(b, T1, { name: 'Litigation-Sub', slug: 'lit-sub' });
139
179
 
140
180
  const page = await b.list(T1, { nameContains: 'Litigation' });
141
181
  expect(page.items).toHaveLength(2);
@@ -152,9 +192,9 @@ export function runOrgBindingConformance(
152
192
  expect(await b.get(T1, unknown)).toBeUndefined();
153
193
  });
154
194
 
155
- it('update on unknown orgId errors', async () => {
195
+ it('update on unknown orgId → org-not-found', async () => {
156
196
  const b = makeBinding();
157
- await expect(b.update(T1, unknown, { name: 'X' })).rejects.toThrow();
197
+ expect(await b.update(T1, unknown, { name: 'X' })).toEqual({ kind: 'org-not-found' });
158
198
  });
159
199
 
160
200
  it('delete on unknown orgId is a no-op', async () => {
@@ -162,4 +202,39 @@ export function runOrgBindingConformance(
162
202
  await expect(b.delete(T1, unknown)).resolves.toBeUndefined();
163
203
  });
164
204
  });
205
+
206
+ describe(`${label} — slug uniqueness`, () => {
207
+ it('create with a slug the tenant already has → slug-conflict', async () => {
208
+ const b = makeBinding();
209
+ await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
210
+ expect(await b.create(T1, { name: 'Acme again', slug: 'acme' })).toEqual({
211
+ kind: 'slug-conflict',
212
+ slug: 'acme',
213
+ });
214
+ expect((await b.list(T1, {})).items).toHaveLength(1);
215
+ });
216
+
217
+ it('another tenant may use the same slug', async () => {
218
+ const b = makeBinding();
219
+ await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
220
+ expect((await b.create(T2, { name: 'Acme', slug: 'acme' })).kind).toBe('ok');
221
+ });
222
+
223
+ it("update to another org's slug → slug-conflict; the org is unchanged", async () => {
224
+ const b = makeBinding();
225
+ await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
226
+ const id = await createOrg(b, T1, { name: 'Globex', slug: 'globex' });
227
+ expect(await b.update(T1, id, { slug: 'acme' })).toEqual({
228
+ kind: 'slug-conflict',
229
+ slug: 'acme',
230
+ });
231
+ expect((await b.get(T1, id))?.slug).toBe('globex');
232
+ });
233
+
234
+ it('update to its own slug → ok', async () => {
235
+ const b = makeBinding();
236
+ const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
237
+ expect(await b.update(T1, id, { name: 'Acme Corp', slug: 'acme' })).toEqual({ kind: 'ok' });
238
+ });
239
+ });
165
240
  }