Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions packages/features/src/contract/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
* chat, named by `?workspaceId=`, as the statements the window's chat read
* is built from, once the account has joined it; and the account's
* workspaces, as the statements of the workspaces and of the GitHub
* installations connected to them. Each
* installations connected to them; and one workspace's people, named by
* `?workspaceId=`, as its members and who its work names but left. Each
* asked with `?poll=true` answers each statement's rows inline instead, for
* a reader polling while Realtime fails or is off; with `&after=` too, the
* poll is held until the rows move past it, or answered unchanged. Each
Expand Down Expand Up @@ -45,7 +46,7 @@ import {
} from "../live-wire.js";
import { HOSTED_SERVICE_PATH } from "../service-paths.js";
import { taskListLiveAnswerSchema } from "../task-list-live.js";
import { workspacesLiveAnswerSchema } from "../workspace-live.js";
import { workspaceMembersLiveAnswerSchema, workspacesLiveAnswerSchema } from "../workspace-live.js";
import { hostedProcedure } from "./base.js";

/** Whether the reader asks for the rows inline, polling while Realtime fails it (`LIVE_POLL`). */
Expand Down Expand Up @@ -114,6 +115,14 @@ const liveChatSchema = EffectSchema.Struct({
[LIVE_USER_QUERY]: userField,
});

/** The workspace a live read is followed for; one that names no workspace of the account's is answered as not found, so it is read as text here. */
const liveWorkspaceSchema = EffectSchema.Struct({
workspaceId: EffectSchema.String,
[LIVE_POLL.QUERY]: pollField,
[LIVE_POLL.AFTER]: afterField,
[LIVE_USER_QUERY]: userField,
});

export const liveContract = {
inbox: hostedProcedure
.meta(openapi({ method: HTTP_METHOD.GET, path: HOSTED_SERVICE_PATH.LIVE_INBOX }))
Expand Down Expand Up @@ -188,4 +197,8 @@ export const liveContract = {
.meta(openapi({ method: HTTP_METHOD.GET, path: HOSTED_SERVICE_PATH.LIVE_WORKSPACES }))
.input(liveReadSchema)
.output(EffectSchema.flip(workspacesLiveAnswerSchema)),
workspaceMembers: hostedProcedure
.meta(openapi({ method: HTTP_METHOD.GET, path: HOSTED_SERVICE_PATH.LIVE_WORKSPACE_MEMBERS }))
.input(liveWorkspaceSchema)
.output(EffectSchema.flip(workspaceMembersLiveAnswerSchema)),
};
2 changes: 2 additions & 0 deletions packages/features/src/service-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ export const HOSTED_SERVICE_PATH = {
LIVE_AUTOMATION_RUNS: "/api/live/automation-runs",
/** The account's workspaces, sealed for following over Neon Realtime (GET): the workspaces and their installations, or their rows inline where polled. */
LIVE_WORKSPACES: "/api/live/workspaces",
/** One workspace's people under `?workspaceId=`, sealed for following over Neon Realtime (GET): its members and who its work names but left, or `not-found`, or their rows inline where polled. */
LIVE_WORKSPACE_MEMBERS: "/api/live/workspace-members",
/**
* A workspace's agents, on a task or with none, newest started first and
* the archived ones left out unless the query asks for them, narrowed to
Expand Down
87 changes: 85 additions & 2 deletions packages/features/src/workspace-live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ import { Schema as EffectSchema } from "effect";
import { pinnedPictureUrl } from "./account-picture.js";
import { sealedLiveQuerySchema } from "./live-wire.js";
import { countedNumber } from "./service-wire.js";
import { roleOf, type Workspace } from "./workspace-wire.js";
import {
roleOf,
WORKSPACE_ROLE,
type Workspace,
type WorkspaceMembersAnswer,
type WorkspaceRole,
} from "./workspace-wire.js";

/**
* workspace-live.ts -- the account's workspaces as the desktop follows them over Neon Realtime: the sealed statements the service answers, and the rows they deliver.
* workspace-live.ts -- the account's workspaces, and one workspace's people, as the desktop follows them over Neon Realtime: the sealed statements the service answers, and the rows they deliver.
*
* Neon's guide is followed as `live-wire.ts` follows it: the service builds
* each statement with the account's id in it and seals it, and the desktop
Expand Down Expand Up @@ -119,3 +125,80 @@ export function workspacesOfLiveRows(
)
.map((row) => workspaceOfLiveRow(row, installations.get(row.id) ?? []));
}

/**
* One workspace's people sealed for following, read for the account asking:
* the address, and two statements, its members and who its work names but
* left. The service answers `not-found` for a workspace the account is not
* a member of; asked to poll, or where Realtime cannot seal them, it answers
* each statement's rows inline instead (`LIVE_POLL`).
*/
export const workspaceMembersLiveAnswerSchema = EffectSchema.Struct({
url: EffectSchema.String,
queries: EffectSchema.Struct({
members: sealedLiveQuerySchema,
former: sealedLiveQuerySchema,
}),
});

/** A workspace's people as their rows deliver them: each sign-in picture as the account holds it, null for none. */
export const workspaceMembersLiveRowsSchema = EffectSchema.Struct({
members: EffectSchema.Array(
EffectSchema.Struct({
userId: EffectSchema.String,
name: EffectSchema.String,
email: EffectSchema.String,
/** The plugin's role text, which may list several roles comma-separated. */
role: EffectSchema.String,
image: EffectSchema.NullOr(EffectSchema.String),
}),
),
former: EffectSchema.Array(
EffectSchema.Struct({
userId: EffectSchema.String,
name: EffectSchema.String,
image: EffectSchema.NullOr(EffectSchema.String),
}),
),
});

/** The order roles are listed in: owners, then admins, then members. */
const ROLE_ORDER: readonly WorkspaceRole[] = [
WORKSPACE_ROLE.OWNER,
WORKSPACE_ROLE.ADMIN,
WORKSPACE_ROLE.MEMBER,
];

/** By name, then by id, as the held read orders people. */
function byName(
a: { readonly name: string; readonly userId: string },
b: { readonly name: string; readonly userId: string },
): number {
return byText(a.name, b.name) || byText(a.userId, b.userId);
Comment thread
luke-agent[bot] marked this conversation as resolved.
Resolved
}

/** A picture the renderer may draw, under the key the wire carries it by; nothing for any other. */
function pictureOf(image: string | null): { readonly pictureUrl?: string } {
const pictureUrl = pinnedPictureUrl(image);
return pictureUrl === undefined ? {} : { pictureUrl };
}

/**
* A workspace's people as the held read answers them: the members owners
* first, then admins, then members, each by name, and who left by name,
* absent where nobody did.
*/
export function workspaceMembersOfLiveRows(
rows: typeof workspaceMembersLiveRowsSchema.Type,
): WorkspaceMembersAnswer {
const members = rows.members
.toSorted(byName)
.map(({ image, role, ...member }) => ({ ...member, role: roleOf(role), ...pictureOf(image) }));
const former = rows.former
.toSorted(byName)
.map(({ image, ...person }) => ({ ...person, ...pictureOf(image) }));
return {
members: ROLE_ORDER.flatMap((role) => members.filter((one) => one.role === role)),
...(former.length === 0 ? undefined : { former }),
};
}
2 changes: 2 additions & 0 deletions packages/features/src/workspace-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,8 @@ export const workspaceMembersAnswerSchema = EffectSchema.Struct({
former: EffectSchema.optionalKey(EffectSchema.Array(formerMemberSchema)),
});

export type WorkspaceMembersAnswer = typeof workspaceMembersAnswerSchema.Type;

/** The query a members read names its workspace under. */
export const WORKSPACE_MEMBERS_QUERY = { WORKSPACE_ID: "workspaceId" } as const;

Expand Down
95 changes: 95 additions & 0 deletions packages/service/src/accounts/workspace-members.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { it } from "@effect/vitest";
import { Database } from "@luke/database/core/query";
import { createTask } from "@luke/database/models/task-store";
import { user } from "@luke/database/schemas/auth";
import {
getTestDB,
joinWorkspace,
leaveWorkspace,
openWorkspace,
WORKSPACE_ROLE,
} from "@luke/database/testing";
import {
workspaceMembersLiveRowsSchema,
workspaceMembersOfLiveRows,
} from "@luke/features/workspace-live";
import { Effect, Option, Schema } from "effect";
import { SqlClient } from "effect/sql";
import {
listFormerMembers,
listMembers,
workspaceMembersLiveStatements,
} from "./workspace-members.js";

/**
* A workspace's people as the live statements deliver them: the members and
* who the workspace's work names but left, exactly as the held reads answer
* them, to a member, and nothing at all to an outsider or to a member once
* they leave.
*
* Synthetic accounts throughout, over PGlite.
*/

/** A new account under the name and sign-in picture given. */
const openUser = (name: string, image: string | null = null) =>
Effect.gen(function* () {
const userId = `user-${randomUUID()}`;
yield* Database.use((db) =>
db.insert(user).values({ id: userId, name, email: `${userId}@luke.test`, image }),
);
return userId;
});

/** The workspace's people as the desktop builds them from the live rows, run as Realtime runs them, for `readerId`. */
const livePeopleOf = (readerId: string, organizationId: string) =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const statements = Option.getOrThrow(workspaceMembersLiveStatements(readerId, organizationId));
const rows = yield* Effect.all({
members: sql.unsafe(statements.members.sql, [...statements.members.params]),
former: sql.unsafe(statements.former.sql, [...statements.former.params]),
});
return workspaceMembersOfLiveRows(
yield* Schema.decodeUnknownEffect(workspaceMembersLiveRowsSchema)(rows),
);
});

it.layer(getTestDB())("a workspace's people, live", (it) => {
it.effect(
"the live statements deliver the members and the former members the held reads answer to a member, and nothing to an outsider or to a member who left",
() =>
Effect.gen(function* () {
const owner = yield* openUser("Owner", "https://avatars.githubusercontent.com/u/1");
const admin = yield* openUser("Admin");
const teammate = yield* openUser("Bea");
const leaver = yield* openUser("Leaver", "https://example.test/not-drawn.png");
const outsider = yield* openUser("Outsider");
const workspaceId = yield* openWorkspace(owner);
yield* openWorkspace(outsider);
yield* joinWorkspace(workspaceId, admin, WORKSPACE_ROLE.ADMIN);
yield* joinWorkspace(workspaceId, teammate);
yield* joinWorkspace(workspaceId, leaver);
yield* createTask(leaver, { organizationId: workspaceId, name: "Left behind" });
yield* leaveWorkspace(workspaceId, leaver);

const members = yield* listMembers(workspaceId);
const former = yield* listFormerMembers(workspaceId);
assert.deepEqual(
members.map((one) => one.role),
[WORKSPACE_ROLE.OWNER, WORKSPACE_ROLE.ADMIN, WORKSPACE_ROLE.MEMBER],
);
assert.deepEqual(
former.map((one) => one.userId),
[leaver],
);
for (const reader of [owner, admin, teammate]) {
assert.deepEqual(yield* livePeopleOf(reader, workspaceId), { members, former });
}
const nobody = { members: [] };
assert.deepEqual(yield* livePeopleOf(outsider, workspaceId), nobody);
assert.deepEqual(yield* livePeopleOf(leaver, workspaceId), nobody);
}),
);
});
Loading
Loading