@astrale-domains/shell 0.1.7 → 0.1.9

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 (79) hide show
  1. package/dist/domain.d.ts +54 -25
  2. package/dist/domain.js +21 -1
  3. package/dist/functions/authority/roles.js +58 -35
  4. package/dist/functions/group/assign-member.js +6 -3
  5. package/dist/functions/group/change-group-membership.js +92 -39
  6. package/dist/functions/group/group-membership-errors.js +1 -1
  7. package/dist/functions/group/index.js +2 -2
  8. package/dist/functions/group/legacy/index.js +1 -1
  9. package/dist/functions/group/legacy/membership.js +21 -17
  10. package/dist/functions/group/membership-laws.js +23 -28
  11. package/dist/functions/group/unassign-member.js +6 -3
  12. package/dist/functions/invitation/send-invitation.js +57 -7
  13. package/dist/functions/view-access/index.js +2 -2
  14. package/dist/functions/view-access/legacy/index.js +1 -0
  15. package/dist/functions/view-access/legacy/migrate-view-access.js +29 -0
  16. package/dist/mutations/group/index.js +1 -1
  17. package/dist/mutations/group/set-group-membership.js +45 -13
  18. package/dist/mutations/invitation/write-invitation.js +19 -2
  19. package/dist/mutations/view-access/legacy/index.js +1 -0
  20. package/dist/mutations/view-access/legacy/move-view-access.js +26 -0
  21. package/dist/mutations/view-access/set-view-access.js +1 -1
  22. package/dist/queries/composition/index.js +1 -1
  23. package/dist/queries/composition/read-role-nodes.js +48 -13
  24. package/dist/queries/group/index.js +1 -0
  25. package/dist/queries/group/legacy/index.js +1 -0
  26. package/dist/queries/group/legacy/read-members-of-group.js +27 -0
  27. package/dist/queries/group/read-group-members.js +73 -0
  28. package/dist/queries/invitation/index.js +1 -1
  29. package/dist/queries/invitation/read-invitation.js +33 -8
  30. package/dist/queries/role/index.js +1 -1
  31. package/dist/queries/role/read-role-node.js +8 -0
  32. package/dist/queries/view-access/legacy/index.js +1 -0
  33. package/dist/queries/view-access/legacy/read-view-access-to-move.js +40 -0
  34. package/dist/queries/view-access/read-view-access.js +1 -1
  35. package/dist/runtime.d.ts +3930 -924
  36. package/dist/runtime.js +6 -3
  37. package/dist/schema/index.d.ts +1 -1
  38. package/dist/schema/index.js +2 -1
  39. package/dist/schema/legacy/schema.js +5 -3
  40. package/dist/schema/modules/application/legacy/functions/list-entrypoints.js +5 -4
  41. package/dist/schema/modules/group/classes/group-profile-link.d.ts +1 -1
  42. package/dist/schema/modules/group/classes/group.d.ts +14 -0
  43. package/dist/schema/modules/group/classes/group.js +12 -2
  44. package/dist/schema/modules/group/functions/assign-members.js +17 -0
  45. package/dist/schema/modules/group/functions/index.js +2 -0
  46. package/dist/schema/modules/group/functions/unassign-members.js +17 -0
  47. package/dist/schema/modules/group/types/group-membership-input.js +8 -0
  48. package/dist/schema/modules/group/types/index.js +1 -1
  49. package/dist/schema/modules/invitation/classes/invitation.js +4 -2
  50. package/dist/schema/modules/role/core/roles.d.ts +2 -0
  51. package/dist/schema/modules/role/core/roles.js +1 -0
  52. package/dist/schema/modules/view-access/index.d.ts +1 -2
  53. package/dist/schema/modules/view-access/index.js +2 -2
  54. package/dist/schema/modules/view-access/legacy/classes/index.d.ts +1 -0
  55. package/dist/schema/modules/view-access/legacy/classes/index.js +1 -0
  56. package/dist/schema/modules/view-access/{classes → legacy/classes}/view-access.d.ts +7 -5
  57. package/dist/schema/modules/view-access/legacy/classes/view-access.js +25 -0
  58. package/dist/schema/modules/view-access/legacy/functions/assign-views.d.ts +18 -0
  59. package/dist/schema/modules/view-access/legacy/functions/index.d.ts +2 -0
  60. package/dist/schema/modules/view-access/legacy/functions/index.js +1 -0
  61. package/dist/schema/modules/view-access/legacy/functions/migrate-view-access.d.ts +13 -0
  62. package/dist/schema/modules/view-access/legacy/functions/migrate-view-access.js +22 -0
  63. package/dist/schema/modules/view-access/legacy/index.d.ts +4 -0
  64. package/dist/schema/modules/view-access/legacy/index.js +3 -1
  65. package/dist/schema/modules/view-access/legacy/policies/holds-view-access.d.ts +5 -0
  66. package/dist/schema/modules/view-access/legacy/policies/holds-view-access.js +8 -0
  67. package/dist/schema/modules/view-access/legacy/policies/index.d.ts +1 -0
  68. package/dist/schema/modules/view-access/legacy/policies/index.js +1 -0
  69. package/dist/schema/modules/view-access/legacy/view-assignment.d.ts +21 -0
  70. package/dist/schema/schema.d.ts +207 -99
  71. package/dist/schema/schema.js +1 -4
  72. package/package.json +2 -2
  73. package/dist/schema/modules/view-access/classes/index.d.ts +0 -1
  74. package/dist/schema/modules/view-access/classes/index.js +0 -1
  75. package/dist/schema/modules/view-access/classes/view-access.js +0 -23
  76. package/dist/schema/modules/view-access/policies/holds-view-access.d.ts +0 -2
  77. package/dist/schema/modules/view-access/policies/holds-view-access.js +0 -5
  78. package/dist/schema/modules/view-access/policies/index.d.ts +0 -1
  79. package/dist/schema/modules/view-access/policies/index.js +0 -1
@@ -1,4 +1,7 @@
1
- import { defineWorkflow } from '@astrale-os/sdk/workflow';
2
- import { changeGroupMembership } from './change-group-membership.js';
1
+ import { defineAction } from '@astrale-os/sdk/action';
2
+ import { changeGroupMembers, changeGroupMembership } from './change-group-membership.js';
3
3
  import { groupMembershipErrors } from './group-membership-errors.js';
4
- export const unassignMember = defineWorkflow()('Group.unassignMember', { errors: groupMembershipErrors }, (context) => changeGroupMembership(context, false));
4
+ /** Withdraw one account or group from a group, in one read and one write. */
5
+ export const unassignMember = defineAction()('Group.unassignMember', { errors: groupMembershipErrors }, (context) => changeGroupMembership(context, false));
6
+ /** Withdraw several accounts or groups from a group, in one commit. */
7
+ export const unassignMembers = defineAction()('Group.unassignMembers', { errors: groupMembershipErrors }, (context) => changeGroupMembers(context, context.input, false));
@@ -2,6 +2,7 @@ import { defineWorkflow } from '@astrale-os/sdk/workflow';
2
2
  import { inviteErrors, readAccount, userErrors } from '../user/index.js';
3
3
  import { InvitationRequestFailure } from '../../integrations/user-invitation/index.js';
4
4
  import { bindAccount, createInvitation, updateInvitation } from '../../mutations/invitation/index.js';
5
+ import { readInvitation, readPendingInvitations } from '../../queries/invitation/index.js';
5
6
  import { canonicalNodeId } from '../../utils/canonical-node-path/index.js';
6
7
  const errors = { ...inviteErrors, ...userErrors };
7
8
  /** How long an invitation waits for its first sign-in. */
@@ -12,6 +13,10 @@ export const invitationSignal = (round) => `event-${round}`;
12
13
  * Invite an account and keep its invitation until it ends: record it, have Admin send the email,
13
14
  * then wait for the first sign-in (`accept`), a resend, a cancel or the expiry. It grants nothing:
14
15
  * the account holds what `User.create` or an admin gave it.
16
+ *
17
+ * An account has at most one pending invitation. When one is pending for the same email, this run
18
+ * has it resent, with a full time to live again, and ends. For another email, it cancels it and
19
+ * records a new one; Admin replaces its own link when it receives the new request.
15
20
  */
16
21
  export const send = defineWorkflow()('Invitation.send', { errors }, async (context) => {
17
22
  const { step, execution, awaitSignal, cancelled, error } = context;
@@ -24,13 +29,57 @@ export const send = defineWorkflow()('Invitation.send', { errors }, async (conte
24
29
  if (account.email === undefined)
25
30
  throw error('USER_EMAIL_INVALID', {});
26
31
  const email = account.email;
32
+ // Stop the runs of replaced invitations: each finds its invitation closed and ends.
33
+ const stop = (runs) => step.run('stop-replaced', async ({ kernel }) => {
34
+ for (const { execution: ref } of runs) {
35
+ await kernel.execution.cancel(ref).catch(() => undefined);
36
+ }
37
+ });
38
+ const retire = async (invitations) => {
39
+ for (const [index, { id }] of invitations.entries()) {
40
+ await step.mutate(`retire-${index}`, updateInvitation, {
41
+ invitation: id,
42
+ status: 'cancelled',
43
+ });
44
+ }
45
+ await stop(invitations);
46
+ };
47
+ // Close the invitation, unless another run already closed it (a replacement).
48
+ const close = async (name, status) => {
49
+ const current = await step.run(`read-${name}`, ({ query }) => query(readInvitation, { invitation }));
50
+ if (current?.status !== 'pending')
51
+ return { status: 'cancelled' };
52
+ await step.mutate(name, updateInvitation, { invitation, status });
53
+ return { status };
54
+ };
55
+ const pending = await step.run('read-pending', ({ query }) => query(readPendingInvitations, { user }));
56
+ const same = pending.find((previous) => previous.email === email);
57
+ // Its run sends the email again and restarts the time to live. A run that no longer answers
58
+ // has its invitation replaced instead.
59
+ const resent = same !== undefined &&
60
+ (await step.run('resend-pending', ({ kernel }) => kernel.execution
61
+ .signal(same.execution, {
62
+ name: invitationSignal(same.round),
63
+ value: { kind: 'resend' },
64
+ })
65
+ .then(() => true, () => false)));
66
+ if (resent) {
67
+ const others = pending.filter((previous) => previous !== same);
68
+ if (others.length > 0)
69
+ await retire(others);
70
+ return { status: 'pending' };
71
+ }
27
72
  const startedAt = await step.run('clock', () => Date.now());
28
- const expiresAt = startedAt + INVITATION_TTL_MS;
73
+ let expiresAt = startedAt + INVITATION_TTL_MS;
29
74
  const { invitation } = await step.mutate('record-invitation', createInvitation, {
30
75
  user,
31
76
  execution: execution.id,
32
77
  expiresAt: new Date(expiresAt).toISOString(),
78
+ email,
79
+ replaces: pending.map((previous) => previous.id),
33
80
  });
81
+ if (pending.length > 0)
82
+ await stop(pending);
34
83
  const requestedBy = execution.initiator.caller ?? execution.initiator.principal;
35
84
  const sendEmail = async (round) => {
36
85
  try {
@@ -55,27 +104,28 @@ export const send = defineWorkflow()('Invitation.send', { errors }, async (conte
55
104
  const event = await awaitSignal(invitationSignal(round), {
56
105
  timeoutMs: Math.max(expiresAt - now, 1),
57
106
  });
58
- if (event.kind === 'timeout') {
59
- await step.mutate('expire', updateInvitation, { invitation, status: 'expired' });
60
- return { status: 'expired' };
61
- }
107
+ if (event.kind === 'timeout')
108
+ return await close('expire', 'expired');
62
109
  if (event.value.kind === 'accepted') {
63
110
  const { iss, sub } = event.value;
64
111
  await step.mutate('bind-account', bindAccount, { user, iss, sub });
65
112
  await step.mutate('accept', updateInvitation, { invitation, status: 'accepted' });
66
113
  return { status: 'accepted' };
67
114
  }
115
+ // A resend is a new link from Admin, with a full time to live again.
68
116
  await sendEmail(round + 1);
117
+ const resentAt = await step.run(`resent-at-${round + 1}`, () => Date.now());
118
+ expiresAt = resentAt + INVITATION_TTL_MS;
69
119
  await step.mutate(`next-round-${round + 1}`, updateInvitation, {
70
120
  invitation,
71
121
  round: round + 1,
122
+ expiresAt: new Date(expiresAt).toISOString(),
72
123
  });
73
124
  }
74
125
  }
75
126
  catch (cause) {
76
127
  if (!cancelled(cause))
77
128
  throw cause;
78
- await step.mutate('cancel', updateInvitation, { invitation, status: 'cancelled' });
79
- return { status: 'cancelled' };
129
+ return await close('cancel', 'cancelled');
80
130
  }
81
131
  });
@@ -1,2 +1,2 @@
1
- // Seam L-SHELL-12: the legacy handler the Runtime registers.
2
- export { legacyAssignViews } from './legacy/index.js';
1
+ // Seams L-SHELL-12, L-SHELL-20: the legacy handlers the Runtime registers.
2
+ export { legacyAssignViews, legacyMigrateViewAccess } from './legacy/index.js';
@@ -1 +1,2 @@
1
1
  export { legacyAssignViews } from './assign-views.js';
2
+ export { legacyMigrateViewAccess } from './migrate-view-access.js';
@@ -0,0 +1,29 @@
1
+ import { defineAction } from '@astrale-os/sdk/action';
2
+ import { legacyMoveViewAccess } from '../../../mutations/view-access/legacy/index.js';
3
+ import { legacyGivenViewKey, legacyReadViewAccessToMove } from '../../../queries/view-access/legacy/index.js';
4
+ /** Edges moved by one write. */
5
+ const BATCH = 200;
6
+ /**
7
+ * @deprecated Copy every Shell `view_access` Edge to the Kernel's, the one the Kernel enforces
8
+ * from kernel-host 1.0.0-beta.45, then delete the Shell's (L-SHELL-20). Idempotent: a View the
9
+ * Kernel's Edge already gives is not copied again, and a second run finds no Shell Edge. Each batch
10
+ * copies and deletes in one write, so a View is never given by neither Edge. Replaced by nothing:
11
+ * Views are given through the Kernel's Edge. Removal: Shell major (delivery step 5), once every
12
+ * instance has run it.
13
+ */
14
+ export const legacyMigrateViewAccess = defineAction()('legacyMigrateViewAccess', async (context) => {
15
+ const { shell, kernel } = await context.query(legacyReadViewAccessToMove, {});
16
+ let copied = 0;
17
+ let removed = 0;
18
+ for (let start = 0; start < shell.length; start += BATCH) {
19
+ const remove = shell.slice(start, start + BATCH);
20
+ const copy = remove.filter((given) => !kernel.has(legacyGivenViewKey(given)));
21
+ const result = await context.mutate(legacyMoveViewAccess, { copy, remove });
22
+ if (!result.ok) {
23
+ throw new Error(`view_access migration stopped after ${removed} Edges: ${result.code}; run it again.`);
24
+ }
25
+ copied += copy.length;
26
+ removed += remove.length;
27
+ }
28
+ return { copied, removed };
29
+ });
@@ -1 +1 @@
1
- export { setGroupMembership } from './set-group-membership.js';
1
+ export { setGroupMembership, setGroupMemberships, } from './set-group-membership.js';
@@ -1,6 +1,46 @@
1
1
  import { defineMutation } from '@astrale-os/sdk/mutation';
2
2
  // Seam L-SHELL-8: the legacy membership Edge a change may converge beside `extends_with`.
3
3
  import { legacyMembershipClass } from './legacy/index.js';
4
+ const rejectChange = (failure) => {
5
+ if (failure.outcomeKnown &&
6
+ failure.code !== 'MUTATION_PRECONDITION_FAILED' &&
7
+ failure.code !== 'MUTATION_CONFLICT' &&
8
+ failure.code !== 'MUTATION_POST_STATE_INVALID')
9
+ throw failure;
10
+ return {
11
+ ok: false,
12
+ code: failure.code,
13
+ ...(failure.rule !== undefined ? { rule: failure.rule } : {}),
14
+ outcomeKnown: failure.outcomeKnown,
15
+ };
16
+ };
17
+ /**
18
+ * Create or delete membership Edges of several members of one group in one commit, under the few
19
+ * guards that cover all of them: every change is written, or none.
20
+ */
21
+ export const setGroupMemberships = defineMutation()((domain) => ({
22
+ id: 'shell.group.memberships.set',
23
+ build(input, mutation) {
24
+ for (const guard of input.guards)
25
+ mutation.expect.query(guard);
26
+ for (const change of input.changes) {
27
+ const endpoints = { source: { id: change.member }, target: { id: input.group } };
28
+ const selected = change.edge === 'authority'
29
+ ? domain.dependencies.kernel.classes.extends_with
30
+ : legacyMembershipClass(domain);
31
+ if (change.present)
32
+ mutation.createEdge({ class: selected, ...endpoints });
33
+ else
34
+ mutation.deleteEdge({ class: selected, ...endpoints });
35
+ }
36
+ },
37
+ project: () => ({ ok: true }),
38
+ reject: (failure) => rejectChange({
39
+ outcomeKnown: failure.outcomeKnown,
40
+ code: failure.code,
41
+ ...('rule' in failure && failure.rule !== undefined ? { rule: failure.rule } : {}),
42
+ }),
43
+ }));
4
44
  /** Converge the membership Edges of one pair, guarded by what was observed of them. */
5
45
  export const setGroupMembership = defineMutation()((domain) => ({
6
46
  id: 'shell.group.membership.set',
@@ -22,17 +62,9 @@ export const setGroupMembership = defineMutation()((domain) => ({
22
62
  }
23
63
  },
24
64
  project: () => ({ ok: true }),
25
- reject: (failure) => {
26
- if (failure.outcomeKnown &&
27
- failure.code !== 'MUTATION_PRECONDITION_FAILED' &&
28
- failure.code !== 'MUTATION_CONFLICT' &&
29
- failure.code !== 'MUTATION_POST_STATE_INVALID')
30
- throw failure;
31
- return {
32
- ok: false,
33
- code: failure.code,
34
- ...('rule' in failure && failure.rule !== undefined ? { rule: failure.rule } : {}),
35
- outcomeKnown: failure.outcomeKnown,
36
- };
37
- },
65
+ reject: (failure) => rejectChange({
66
+ outcomeKnown: failure.outcomeKnown,
67
+ code: failure.code,
68
+ ...('rule' in failure && failure.rule !== undefined ? { rule: failure.rule } : {}),
69
+ }),
38
70
  }));
@@ -1,8 +1,23 @@
1
1
  import { defineMutation } from '@astrale-os/sdk/mutation';
2
- /** Record a pending invitation of one account, kept by one durable run. */
2
+ /**
3
+ * Record a pending invitation of one account, kept by one durable run, and cancel in the same
4
+ * commit the pending invitations it replaces: an account has at most one.
5
+ */
3
6
  export const createInvitation = defineMutation()((domain) => ({
4
7
  id: 'shell.invitation.create',
5
8
  build(input, mutation) {
9
+ for (const previous of input.replaces ?? []) {
10
+ mutation.expect.node({
11
+ node: { id: previous },
12
+ class: domain.classes.Invitation,
13
+ props: { equals: { status: 'pending' } },
14
+ });
15
+ mutation.updateNode({
16
+ node: { id: previous },
17
+ class: domain.classes.Invitation,
18
+ props: { set: { status: 'cancelled' } },
19
+ });
20
+ }
6
21
  const invitation = mutation.createNode({
7
22
  as: 'invitation',
8
23
  class: domain.classes.Invitation,
@@ -11,6 +26,7 @@ export const createInvitation = defineMutation()((domain) => ({
11
26
  expiresAt: input.expiresAt,
12
27
  execution: input.execution,
13
28
  round: 0,
29
+ email: input.email,
14
30
  },
15
31
  });
16
32
  mutation.createEdge({
@@ -26,7 +42,7 @@ export const createInvitation = defineMutation()((domain) => ({
26
42
  return { invitation };
27
43
  },
28
44
  }));
29
- /** Change an invitation's status or its round, while it is still pending. */
45
+ /** Change an invitation's status, or its round and expiry, while it is still pending. */
30
46
  export const updateInvitation = defineMutation()((domain) => ({
31
47
  id: 'shell.invitation.update',
32
48
  build(input, mutation) {
@@ -42,6 +58,7 @@ export const updateInvitation = defineMutation()((domain) => ({
42
58
  set: {
43
59
  ...(input.status === undefined ? {} : { status: input.status }),
44
60
  ...(input.round === undefined ? {} : { round: input.round }),
61
+ ...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }),
45
62
  },
46
63
  },
47
64
  });
@@ -0,0 +1 @@
1
+ export { legacyMoveViewAccess } from './move-view-access.js';
@@ -0,0 +1,26 @@
1
+ import { defineMutation } from '@astrale-os/sdk/mutation';
2
+ /**
3
+ * @deprecated Create Kernel `view_access` Edges and delete Shell ones in one write (L-SHELL-20).
4
+ * Replaced by nothing. Removal: with `legacyMigrateViewAccess`.
5
+ */
6
+ export const legacyMoveViewAccess = defineMutation()((domain) => ({
7
+ id: 'shell.view-access.legacy-move',
8
+ build(input, mutation) {
9
+ for (const { identity, view } of input.copy) {
10
+ mutation.createEdge({
11
+ class: domain.dependencies.kernel.classes.view_access,
12
+ source: identity,
13
+ target: view,
14
+ });
15
+ }
16
+ for (const { identity, view } of input.remove) {
17
+ mutation.deleteEdge({ class: domain.classes.view_access, source: identity, target: view });
18
+ }
19
+ },
20
+ project: () => ({ ok: true }),
21
+ reject: (failure) => ({
22
+ ok: false,
23
+ code: failure.code,
24
+ outcomeKnown: failure.outcomeKnown,
25
+ }),
26
+ }));
@@ -5,7 +5,7 @@ export const setViewAccess = defineMutation()((domain) => ({
5
5
  build(input, mutation) {
6
6
  mutation.expect.query(input.guard);
7
7
  const edge = ({ identity, view }) => ({
8
- class: domain.classes.view_access,
8
+ class: domain.dependencies.kernel.classes.view_access,
9
9
  source: identity,
10
10
  target: view,
11
11
  });
@@ -1,2 +1,2 @@
1
1
  export { readExtensions } from './read-extensions.js';
2
- export { readRoleNodes } from './read-role-nodes.js';
2
+ export { readRoleKeys, readRoleNodes } from './read-role-nodes.js';
@@ -1,3 +1,4 @@
1
+ import { Path } from '@astrale-os/sdk/graph/path';
1
2
  import { defineQuery, Query, queryResult, } from '@astrale-os/sdk/query';
2
3
  const CORE = 'core.';
3
4
  /** Each Core Node of a Kernel Role Class, named by its `of_domain` locator. */
@@ -22,20 +23,54 @@ export const readRoleNodes = defineQuery()((domain) => {
22
23
  },
23
24
  project(pages) {
24
25
  const byKey = new Map();
25
- for (const page of pages) {
26
- for (const item of queryResult.edgeItems(page, 'Shell Roles')) {
27
- if (item.edge.kind !== 'value' || item.source.kind !== 'reference')
28
- continue;
29
- if (item.target.kind !== 'value')
30
- continue;
31
- const key = item.edge.value.props[member];
32
- const owner = item.target.value.props[origin];
33
- if (typeof key !== 'string' || !key.startsWith(CORE) || typeof owner !== 'string')
34
- continue;
35
- byKey.set(`${owner}:${key.slice(CORE.length)}`, item.source.value.id);
36
- }
37
- }
26
+ for (const [id, key] of roleKeys(pages, member, origin))
27
+ byKey.set(key, id);
38
28
  return { byKey, ids: new Set(byKey.values()) };
39
29
  },
40
30
  };
41
31
  });
32
+ /** Each Core Role in pages of `of_domain` Edges: its Node, and `<origin>:<Core key>`. */
33
+ function roleKeys(pages, member, origin) {
34
+ const keys = new Map();
35
+ for (const page of pages) {
36
+ for (const item of queryResult.edgeItems(page, 'Shell Roles')) {
37
+ if (item.edge.kind !== 'value' || item.source.kind !== 'reference')
38
+ continue;
39
+ if (item.target.kind !== 'value')
40
+ continue;
41
+ const key = item.edge.value.props[member];
42
+ const owner = item.target.value.props[origin];
43
+ if (typeof key !== 'string' || !key.startsWith(CORE) || typeof owner !== 'string')
44
+ continue;
45
+ keys.set(item.source.value.id, `${owner}:${key.slice(CORE.length)}`);
46
+ }
47
+ }
48
+ return keys;
49
+ }
50
+ /**
51
+ * The Core Roles among some Identities, by `<origin>:<Core key>`: what one composition holds, read
52
+ * without the catalogue of every installed Role.
53
+ */
54
+ export const readRoleKeys = defineQuery()((domain) => {
55
+ const kernel = domain.dependencies.kernel.classes;
56
+ const member = kernel.of_domain.properties.member.key;
57
+ const origin = kernel.Domain.properties.origin.key;
58
+ return {
59
+ id: 'shell.composition.role-keys',
60
+ page: { size: 256 },
61
+ pagination: { maximumPages: 16 },
62
+ build: ({ identities }) => {
63
+ const locator = Query.from({
64
+ nodes: [Path.id(identities[0]), ...identities.slice(1).map((id) => Path.id(id))],
65
+ })
66
+ .filter({ class: kernel.Role })
67
+ .expand({ via: [kernel.of_domain], direction: 'outgoing' });
68
+ return locator.select({
69
+ kind: 'edges',
70
+ binding: locator.edge,
71
+ projection: { edge: 'value', source: 'reference', target: 'value' },
72
+ });
73
+ },
74
+ project: (pages) => roleKeys(pages, member, origin),
75
+ };
76
+ });
@@ -1,2 +1,3 @@
1
1
  export { readGroupMembership, } from './read-group-membership.js';
2
2
  export { readGroupMembershipEndpoints, } from './read-group-membership-endpoints.js';
3
+ export { readGroupMembers, readGroupMembersEndpoints, } from './read-group-members.js';
@@ -1,2 +1,3 @@
1
1
  export { legacyReadMemberOfGroup } from './read-member-of-group.js';
2
2
  export { legacyReadCoreGroups } from './read-core-groups.js';
3
+ export { legacyReadMembersOfGroup } from './read-members-of-group.js';
@@ -0,0 +1,27 @@
1
+ import { Path } from '@astrale-os/sdk/graph/path';
2
+ import { Query, defineQuery, } from '@astrale-os/sdk/query';
3
+ import { canonical, sources, } from '../read-group-members.js';
4
+ /**
5
+ * @deprecated The legacy `member_of_group` Edges from the members to the group, in one read and
6
+ * kept whole as one guard (L-SHELL-8). Replaced by `readGroupMembers` (`extends_with`). Removal:
7
+ * Shell major (delivery step 5) after 1Pact step 4.
8
+ */
9
+ export const legacyReadMembersOfGroup = defineQuery()((domain) => ({
10
+ id: 'shell.group.members.legacy',
11
+ page: { size: 256 },
12
+ pagination: { maximumPages: 2 },
13
+ build({ members, group }) {
14
+ const walk = Query.from({
15
+ nodes: [Path.id(members[0]), ...members.slice(1).map((id) => Path.id(id))],
16
+ }).expand({ via: [domain.classes.member_of_group], direction: 'outgoing' });
17
+ return walk.filter({ predicate: { kind: 'identity.equal', identity: group } }).select({
18
+ kind: 'edges',
19
+ binding: walk.edge,
20
+ projection: { edge: 'reference', source: 'reference', target: 'reference' },
21
+ });
22
+ },
23
+ project: (pages, _input, { ast, complete }) => ({
24
+ present: sources(pages, 'Shell legacy group members'),
25
+ guard: { query: ast, equals: canonical(complete) },
26
+ }),
27
+ }));
@@ -0,0 +1,73 @@
1
+ import { Path } from '@astrale-os/sdk/graph/path';
2
+ import { Query, defineQuery, queryResult, } from '@astrale-os/sdk/query';
3
+ const anchors = (ids) => [Path.id(ids[0]), ...ids.slice(1).map((id) => Path.id(id))];
4
+ /** Every member and the group, in one read whatever the number of members. */
5
+ export const readGroupMembersEndpoints = defineQuery()(() => ({
6
+ id: 'shell.group.members.endpoints',
7
+ page: { size: 256 },
8
+ pagination: { maximumPages: 2 },
9
+ build: ({ members, group }) => Query.from({ nodes: [...anchors(members), Path.id(group)] }).select({
10
+ kind: 'nodes',
11
+ projection: { kind: 'reference' },
12
+ }),
13
+ project(pages, input, { ast, complete }) {
14
+ const visible = new Set(queryResult.completeNodeIds(pages, 'Shell group members'));
15
+ let groupClass;
16
+ for (const page of pages) {
17
+ if (page.kind !== 'nodes')
18
+ continue;
19
+ for (const item of page.nodes) {
20
+ if (item.kind === 'reference' && item.value.id === input.group)
21
+ groupClass = item.value.class;
22
+ }
23
+ }
24
+ return {
25
+ visible,
26
+ ...(groupClass === undefined ? {} : { groupClass }),
27
+ // The Kernel takes a guard's Nodes in canonical order.
28
+ guard: { query: ast, equals: { ...complete, ids: [...complete.ids].sort(order) } },
29
+ };
30
+ },
31
+ }));
32
+ /** The `extends_with` Edges from the members to the group: their memberships (G1), in one read. */
33
+ export const readGroupMembers = defineQuery()((domain) => ({
34
+ id: 'shell.group.members.read',
35
+ page: { size: 256 },
36
+ pagination: { maximumPages: 2 },
37
+ build({ members, group }) {
38
+ const walk = Query.from({ nodes: anchors(members) }).expand({
39
+ via: [domain.dependencies.kernel.classes.extends_with],
40
+ direction: 'outgoing',
41
+ });
42
+ return walk.filter({ predicate: { kind: 'identity.equal', identity: group } }).select({
43
+ kind: 'edges',
44
+ binding: walk.edge,
45
+ projection: { edge: 'reference', source: 'reference', target: 'reference' },
46
+ });
47
+ },
48
+ project: (pages, _input, { ast, complete }) => ({
49
+ present: sources(pages, 'Shell group members'),
50
+ guard: { query: ast, equals: canonical(complete) },
51
+ }),
52
+ }));
53
+ /** The members an Edge read finds, by source. */
54
+ export function sources(pages, label) {
55
+ const found = new Set();
56
+ for (const page of pages) {
57
+ for (const item of queryResult.edgeItems(page, label)) {
58
+ if (item.source.kind === 'reference')
59
+ found.add(item.source.value.id);
60
+ }
61
+ }
62
+ return found;
63
+ }
64
+ const order = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
65
+ /** A guard's Edges in the Kernel's canonical order: by source, then target, then Class. */
66
+ export function canonical(selection) {
67
+ if (selection.kind !== 'edge')
68
+ return selection;
69
+ const keys = [...selection.keys].sort((left, right) => order(left.source, right.source) ||
70
+ order(left.target, right.target) ||
71
+ order(left.class, right.class));
72
+ return { ...selection, keys };
73
+ }
@@ -1 +1 @@
1
- export { readInvitation } from './read-invitation.js';
1
+ export { readInvitation, readPendingInvitations } from './read-invitation.js';
@@ -1,5 +1,17 @@
1
1
  import { Path } from '@astrale-os/sdk/graph/path';
2
2
  import { defineQuery, Query, queryResult, } from '@astrale-os/sdk/query';
3
+ /** An invitation Node as the methods and the run read it. */
4
+ function toInvitation(node, keys) {
5
+ const props = node.props;
6
+ const email = props[keys.email.key];
7
+ return {
8
+ id: node.id,
9
+ status: String(props[keys.status.key]),
10
+ execution: String(props[keys.execution.key]),
11
+ round: Number(props[keys.round.key] ?? 0),
12
+ ...(typeof email === 'string' ? { email } : {}),
13
+ };
14
+ }
3
15
  /** One invitation, or undefined when there is none the reader reads. */
4
16
  export const readInvitation = defineQuery()((domain) => ({
5
17
  id: 'shell.invitation.read',
@@ -13,13 +25,26 @@ export const readInvitation = defineQuery()((domain) => ({
13
25
  const node = queryResult.completeNodeValues(pages, 'Shell invitation')[0];
14
26
  if (node === undefined)
15
27
  return undefined;
16
- const props = node.props;
17
- const keys = domain.classes.Invitation.properties;
18
- return {
19
- id: node.id,
20
- status: String(props[keys.status.key]),
21
- execution: String(props[keys.execution.key]),
22
- round: Number(props[keys.round.key] ?? 0),
23
- };
28
+ return toInvitation(node, domain.classes.Invitation.properties);
24
29
  },
25
30
  }));
31
+ /** The pending invitations of one account: at most one, unless recorded before that rule. */
32
+ export const readPendingInvitations = defineQuery()((domain) => ({
33
+ id: 'shell.invitation.pending',
34
+ page: { size: 64 },
35
+ pagination: { maximumPages: 1 },
36
+ build: ({ user }) => Query.from({ nodes: [Path.id(user)] })
37
+ .expand({ via: [domain.classes.invitation_user], direction: 'incoming' })
38
+ .filter({ class: domain.classes.Invitation })
39
+ .filter({
40
+ predicate: {
41
+ kind: 'property.equal',
42
+ property: domain.classes.Invitation.properties.status.key,
43
+ value: 'pending',
44
+ },
45
+ })
46
+ .select({ kind: 'nodes', projection: { kind: 'value' } }),
47
+ project: (pages) => queryResult
48
+ .completeNodeValues(pages, 'Shell pending invitations')
49
+ .map((node) => toInvitation(node, domain.classes.Invitation.properties)),
50
+ }));
@@ -1 +1 @@
1
- export { readRoleNode } from './read-role-node.js';
1
+ export { readRoleId, readRoleNode } from './read-role-node.js';
@@ -11,3 +11,11 @@ export const readRoleNode = defineQuery()(() => ({
11
11
  build: ({ role }) => Query.from({ nodes: [role] }).select({ kind: 'nodes', projection: { kind: 'value' } }),
12
12
  project: (pages) => queryResult.completeNodeValues(pages, 'Shell Role').length === 1,
13
13
  }));
14
+ /** One Role's Node, read by its Core target; nothing when it is not installed. */
15
+ export const readRoleId = defineQuery()(() => ({
16
+ id: 'shell.role.id',
17
+ page: { size: 1 },
18
+ pagination: { maximumPages: 1 },
19
+ build: ({ role }) => Query.from({ nodes: [role] }).select({ kind: 'nodes', projection: { kind: 'reference' } }),
20
+ project: (pages) => queryResult.completeNodeIds(pages, 'Shell Role')[0],
21
+ }));
@@ -0,0 +1 @@
1
+ export { legacyGivenViewKey, legacyReadViewAccessToMove, } from './read-view-access-to-move.js';
@@ -0,0 +1,40 @@
1
+ import { defineQuery, Query, queryResult, } from '@astrale-os/sdk/query';
2
+ /**
3
+ * @deprecated The key `LegacyViewAccessToMove.kernel` holds for an Edge (L-SHELL-20). Replaced by
4
+ * nothing. Removal: with `legacyMigrateViewAccess`.
5
+ */
6
+ export const legacyGivenViewKey = ({ identity, view }) => `${identity} ${view}`;
7
+ /**
8
+ * @deprecated Every `view_access` Edge, the Shell's and the Kernel's, in one read (L-SHELL-20).
9
+ * Replaced by nothing: the Kernel's alone is written. Removal: with `legacyMigrateViewAccess`.
10
+ */
11
+ export const legacyReadViewAccessToMove = defineQuery()((domain) => {
12
+ const shellClass = domain.classes.view_access;
13
+ const kernelClass = domain.dependencies.kernel.classes.view_access;
14
+ return {
15
+ id: 'shell.view-access.legacy-to-move',
16
+ page: { size: 1000 },
17
+ pagination: { maximumPages: 103 },
18
+ build: () => Query.from({ edges: [shellClass, kernelClass] }).select({
19
+ kind: 'edges',
20
+ projection: { edge: 'value', source: 'reference', target: 'reference' },
21
+ }),
22
+ project(pages) {
23
+ const shell = [];
24
+ const kernel = new Set();
25
+ for (const page of pages) {
26
+ for (const item of queryResult.edgeItems(page, 'Shell view access to move')) {
27
+ if (item.edge.kind !== 'value')
28
+ continue;
29
+ const { class: key, source, target } = item.edge.value;
30
+ const given = { identity: source, view: target };
31
+ if (key === shellClass.key)
32
+ shell.push(given);
33
+ else if (key === kernelClass.key)
34
+ kernel.add(legacyGivenViewKey(given));
35
+ }
36
+ }
37
+ return { shell, kernel };
38
+ },
39
+ };
40
+ });
@@ -7,7 +7,7 @@ export const readViewAccess = defineQuery()((domain) => ({
7
7
  const held = Query.from({
8
8
  nodes: [Path.id(identities[0]), ...identities.slice(1).map((identity) => Path.id(identity))],
9
9
  }).expand({
10
- via: [domain.classes.view_access],
10
+ via: [domain.dependencies.kernel.classes.view_access],
11
11
  direction: 'outgoing',
12
12
  });
13
13
  return held.select({