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
96 changes: 95 additions & 1 deletion apps/web/tests/workspace-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,37 @@ import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { it } from "@effect/vitest";
import { Database } from "@luke/database/core/query";
import {
GITHUB_ACCOUNT_TYPE,
GITHUB_INSTALLATION_STATUS,
type GitHubInstallationStatus,
} from "@luke/database/github-installation-vocabulary";
import { INTEGRATION_ROW_STATUS } from "@luke/database/integration-vocabulary";
import type { WorkspaceSeams } from "@luke/database/models/workspace-seams";
import {
ensurePersonalWorkspace,
listWorkspaces,
personalWorkspaceOf,
readWorkspacePhoto,
workspacesLiveStatements,
} from "@luke/database/models/workspace-store";
import { member, organization, user } from "@luke/database/schemas/auth";
import { githubInstallation } from "@luke/database/schemas/github-installation";
import { personalWorkspace } from "@luke/database/schemas/personal-workspace";
import { workspaceIntegrationConnection } from "@luke/database/schemas/workspace-integration";
import {
fakeWorkspaceSeams,
getTestDB,
joinWorkspace,
WORKSPACE_ROLE,
} from "@luke/database/testing";
import { INTEGRATION_PROVIDER } from "@luke/features/integration-wire";
import { workspacesLiveRowsSchema, workspacesOfLiveRows } from "@luke/features/workspace-live";
import { WORKSPACE_PHOTO_TYPE } from "@luke/features/workspace-wire";
import { openWorkspacePlugin } from "@luke/service/testing/workspace-plugin";
import { eq } from "drizzle-orm";
import { Cause, DateTime, Effect, Option } from "effect";
import { Cause, DateTime, Effect, Option, Schema } from "effect";
import { SqlClient } from "effect/sql";

/**
* An account's workspaces over a real dialect: its first one is made the
Expand Down Expand Up @@ -84,6 +96,38 @@ const keepLogo = (organizationId: string, logo: string | null) =>
const keepImage = (userId: string, image: string | null) =>
Database.use((db) => db.update(user).set({ image }).where(eq(user.id, userId)));

/** The account's workspaces as the desktop builds them from the live statements' rows, run as Realtime runs them. */
const liveWorkspacesOf = (userId: string) =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const statements = Option.getOrThrow(workspacesLiveStatements(userId));
const rows = yield* Effect.all({
workspaces: sql.unsafe(statements.workspaces.sql, [...statements.workspaces.params]),
installations: sql.unsafe(statements.installations.sql, [...statements.installations.params]),
});
return workspacesOfLiveRows(yield* Schema.decodeUnknownEffect(workspacesLiveRowsSchema)(rows));
});

/** An installation connected to the workspace, linked at the instant given. */
const connectInstallation = (
organizationId: string,
installationId: number,
status: GitHubInstallationStatus,
linkedAt: Date,
) =>
Database.use((db) =>
db.insert(githubInstallation).values({
organizationId,
installationId,
accountId: installationId,
accountLogin: `account-${installationId}`,
accountType: GITHUB_ACCOUNT_TYPE.ORGANIZATION,
status,
createdAt: linkedAt,
updatedAt: linkedAt,
}),
);

/** What one listed workspace shows of its face: its photo's version and its picture's address, where it lists them. */
const faceIn = (
listed: readonly { id: string; photoVersion?: string; pictureUrl?: string }[],
Expand Down Expand Up @@ -189,6 +233,56 @@ it.layer(getTestDB())("an account's workspaces over effect/unstable/sql", (it) =
}),
);

it.effect(
"the live statements deliver the workspaces the held read lists, photos, installations, and Linear among them, to each member and none of them to an outsider",
() =>
Effect.gen(function* () {
const seams = yield* fakeWorkspaceSeams;
const owner = yield* openUser("Owner");
const teammate = yield* openUser("Teammate");
const outsider = yield* openUser("Outsider");
assert.ok(Option.isSome(yield* ensurePersonalWorkspace(owner, seams)));
assert.ok(Option.isSome(yield* ensurePersonalWorkspace(outsider, seams)));
yield* keepImage(owner, "https://avatars.githubusercontent.com/u/1");
const acme = yield* openSharedWorkspace(seams, "Acme", owner, [teammate]);
const beta = yield* openSharedWorkspace(seams, "Beta", teammate, [owner]);
yield* keepLogo(acme.id, photoUrl(WORKSPACE_PHOTO_TYPE.PNG, PNG_BYTES));
yield* keepLogo(beta.id, "https://example.test/beta.png");
const now = yield* DateTime.nowAsDate;
const later = new Date(now.getTime() + 1000);
yield* connectInstallation(acme.id, 902, GITHUB_INSTALLATION_STATUS.ACTIVE, later);
yield* connectInstallation(acme.id, 901, GITHUB_INSTALLATION_STATUS.SUSPENDED, now);
yield* Database.use((db) =>
db.insert(workspaceIntegrationConnection).values({
organizationId: beta.id,
provider: INTEGRATION_PROVIDER.LINEAR,
status: INTEGRATION_ROW_STATUS.EXPIRED,
}),
);

for (const reader of [owner, teammate, outsider]) {
assert.deepEqual(yield* liveWorkspacesOf(reader), yield* listWorkspaces(reader));
}
const theirs = (yield* liveWorkspacesOf(outsider)).map((workspace) => workspace.id);
assert.ok(!theirs.includes(acme.id) && !theirs.includes(beta.id));
const acmeListed = (yield* liveWorkspacesOf(teammate)).find((one) => one.id === acme.id);
assert.deepEqual(acmeListed?.github, { login: "account-902", active: true });

// A logo the held read refuses as a photo lists none live either, so the owner's own first workspace keeps the sign-in picture.
const own = Option.getOrThrow(yield* personalWorkspaceOf(owner));
for (const logo of [
photoUrl(WORKSPACE_PHOTO_TYPE.PNG, JPEG_BYTES),
"data:image/png;base64,not base64!",
photoUrl(WORKSPACE_PHOTO_TYPE.PNG, "iVBORw0KGg"),
photoUrl("image/svg+xml", "PHN2Zz48L3N2Zz4="),
photoUrl(WORKSPACE_PHOTO_TYPE.JPEG, JPEG_BYTES),
]) {
yield* keepLogo(own, logo);
assert.deepEqual(yield* liveWorkspacesOf(owner), yield* listWorkspaces(owner), logo);
}
}),
);

it.effect("an account's first ensure makes its first workspace and the second makes none", () =>
Effect.gen(function* () {
const seams = yield* fakeWorkspaceSeams;
Expand Down
115 changes: 106 additions & 9 deletions packages/database/src/models/workspace-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ import { GITHUB_TOKEN_STATUS } from "@luke/features/github-token-wire";
import { INTEGRATION_PROVIDER } from "@luke/features/integration-wire";
import {
RESERVED_WORKSPACE_SLUGS,
roleOf,
type Workspace as WireWorkspace,
WORKSPACE_PHOTO_TYPE,
WORKSPACE_ROLE,
type WorkspacePhoto,
type WorkspaceRole,
workspacePhotoOfDataUrl,
} from "@luke/features/workspace-wire";
import { and, asc, eq, exists, inArray, type SQL, type SQLWrapper, sql } from "drizzle-orm";
import { type Cause, DateTime, Effect, Option, Schema } from "effect";
import { SqlSchema } from "effect/sql";
import { type LiveStatement, liveSelection, liveStatementOf } from "../core/live-statement.js";
import {
Database,
type DatabaseServices,
Expand Down Expand Up @@ -87,8 +89,6 @@ const PHOTO_VERSION_CHARS = 16;
*/
export type Workspace = WireWorkspace;

const WORKSPACE_ROLES: readonly WorkspaceRole[] = Object.values(WORKSPACE_ROLE);

/** What a workspace read answers: an effect over the ambient database. */
/**
* A workspace's name as its members read it: an account's first workspace is
Expand Down Expand Up @@ -447,12 +447,6 @@ function slugOf(name: string): string {
return own ? `workspace-${spelled}` : spelled;
}

/** The highest role the plugin's role text names, read as a member where it names none Luke knows. */
export function roleOf(text: string): WorkspaceRole {
const named = text.split(",").map((part) => part.trim());
return WORKSPACE_ROLES.find((role) => named.includes(role)) ?? WORKSPACE_ROLE.MEMBER;
}

/** Whether an organization already stands for what a workspace is being made for, as the caller reads it. */
type OrganizationLinked = (organizationId: string) => WorkspaceStoreEffect<boolean>;

Expand Down Expand Up @@ -645,6 +639,109 @@ export function listWorkspaces(userId: string): WorkspaceStoreEffect<readonly Wo
});
}

/** The statements the account's workspaces are followed by live: the workspaces, and the installations connected to them. */
export type WorkspacesLiveStatements = Readonly<
Record<"workspaces" | "installations", LiveStatement>
>;

/**
* A logo's text as its bytes, the ones the held read digests. Note that it
* is `decode(…, 'escape')` over the text with each backslash doubled, rather
* than `convert_to`, because Realtime refuses any function Postgres does not
* mark immutable, and `convert_to` and `textsend` are only stable.
*/
const LOGO_BYTES = sql`decode(replace(${organization.logo}, ${"\\"}, ${"\\\\"}), 'escape')`;

/** The base64 a logo's `data:` URL carries, after its comma. */
const LOGO_PAYLOAD = sql`substring(${organization.logo} from position(',' in ${organization.logo}) + 1)`;

/** The first bytes of a logo's photo, which name its image type (`sniffImageType`). */
const LOGO_HEAD = sql`decode(left(${LOGO_PAYLOAD}, 16), 'base64')`;

/**
* A logo that holds a photo, as the held read reads one
* (`workspacePhotoOfDataUrl`): a `data:` URL of one of
* `WORKSPACE_PHOTO_TYPE` whose base64 reads whole, and whose bytes are the
* image its label names. Note that the base64 is only decoded once it is
* known to read, inside the outer case, so a logo of any other text answers
* false rather than failing the statement.
*/
const LOGO_IS_PHOTO = sql<boolean>`case when coalesce(${organization.logo} ~ ${`^data:(${Object.values(WORKSPACE_PHOTO_TYPE).join("|")});base64,[A-Za-z0-9+/]+={0,2}$`}, false) and length(${LOGO_PAYLOAD}) % 4 = 0 then case split_part(substring(${organization.logo} from 6), ';', 1)
when ${WORKSPACE_PHOTO_TYPE.PNG} then substring(${LOGO_HEAD} from 1 for 8) = '\\x89504e470d0a1a0a'::bytea
when ${WORKSPACE_PHOTO_TYPE.JPEG} then substring(${LOGO_HEAD} from 1 for 3) = '\\xffd8ff'::bytea
when ${WORKSPACE_PHOTO_TYPE.WEBP} then substring(${LOGO_HEAD} from 1 for 4) = '\\x52494646'::bytea and substring(${LOGO_HEAD} from 9 for 4) = '\\x57454250'::bytea
else false end else false end`;

/**
* The account's workspaces written for Neon Realtime: the same rows, joins,
* and rule of who may read them as `listWorkspaces`, each column under the
* key `live-wire.ts` reads it by. What the held read works out from a row
* here is worked out in the statement, so a row carries no photo's bytes:
* its digest, the Linear connection's standing, and an installation's.
* Note that a photo is told by its `data:` label alone, where the held read
* also reads its bytes, because a photo is checked when it is set. The
* order is the reader's to keep, since Realtime keeps none. None where a
* statement names a value Realtime cannot carry.
*/
export function workspacesLiveStatements(userId: string): Option.Option<WorkspacesLiveStatements> {
const workspaces = queryBuilder
.select(
liveSelection({
id: organization.id,
name: WORKSPACE_NAME,
slug: organization.slug,
own: sql<boolean>`${personalWorkspace.userId} is not null`,
personal: sql<boolean>`exists(select 1 from ${personalWorkspace} as "first" where "first"."organization_id" = ${organization.id})`,
role: member.role,
linearActive: sql<
boolean | null
>`case ${workspaceIntegrationConnection.status} when ${INTEGRATION_ROW_STATUS.CONNECTED} then true when ${INTEGRATION_ROW_STATUS.EXPIRED} then false end`,
memberCount: sql<number>`(select count(*)::int from ${member} as "members" where "members"."organization_id" = ${organization.id})`,
photoVersion: sql<
string | null
>`case when ${LOGO_IS_PHOTO} then left(encode(sha256(${LOGO_BYTES}), 'hex'), ${PHOTO_VERSION_CHARS}) end`,
ownerImage: user.image,
}),
)
.from(member)
.innerJoin(organization, eq(organization.id, member.organizationId))
.leftJoin(
personalWorkspace,
and(
eq(personalWorkspace.organizationId, organization.id),
eq(personalWorkspace.userId, userId),
),
)
.leftJoin(user, eq(user.id, personalWorkspace.userId))
.leftJoin(
workspaceIntegrationConnection,
and(
eq(workspaceIntegrationConnection.organizationId, organization.id),
eq(workspaceIntegrationConnection.provider, INTEGRATION_PROVIDER.LINEAR),
),
)
.where(eq(member.userId, userId));
const installations = queryBuilder
.select(
liveSelection(
{
workspaceId: githubInstallation.organizationId,
installationId: sql<number>`${githubInstallation.installationId}::float8`,
login: githubInstallation.accountLogin,
active: sql<boolean>`${githubInstallation.status} = ${GITHUB_INSTALLATION_STATUS.ACTIVE}`,
},
{ linkedAt: githubInstallation.createdAt },
),
)
.from(githubInstallation)
.innerJoin(member, eq(member.organizationId, githubInstallation.organizationId))
.where(eq(member.userId, userId));
return Option.all({
workspaces: liveStatementOf(workspaces),
installations: liveStatementOf(installations),
});
}

/** One workspace the account is a member of, as its list carries it; nothing for one it is not in. */
export function workspaceOf(
userId: string,
Expand Down
1 change: 1 addition & 0 deletions packages/features/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"./terminal-wire": "./src/terminal-wire.js",
"./transcript-wire": "./src/transcript-wire.js",
"./workspace-invitation-wire": "./src/workspace-invitation-wire.js",
"./workspace-live": "./src/workspace-live.js",
"./workspace-wire": "./src/workspace-wire.js",
"./workspace-key": "./src/workspace-key.js",
"./sign-in-provider": "./src/sign-in-provider.js",
Expand Down
16 changes: 11 additions & 5 deletions packages/features/src/contract/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
* pages are read from, and one task's planning worker's transcript, by the
* task and the call that started the worker, on the same statements; and the
* commands of the kinds `?kinds=` names that no Mac of the account has
* claimed, as one statement; and
* one plan's inbox, or one agent's, named by `?id=`, as its items, their
* threads, and their files; and one workspace's chat, named by
* `?workspaceId=`, as the statements the window's chat read is built from,
* once the account has joined it. Each
* claimed, as one statement; and one plan's inbox, or one agent's, named by
* `?id=`, as its items, their threads, and their files; and one workspace's
* 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
* 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 @@ -44,6 +45,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 { hostedProcedure } from "./base.js";

/** Whether the reader asks for the rows inline, polling while Realtime fails it (`LIVE_POLL`). */
Expand Down Expand Up @@ -182,4 +184,8 @@ export const liveContract = {
.meta(openapi({ method: HTTP_METHOD.GET, path: HOSTED_SERVICE_PATH.LIVE_WORKSPACE_CHAT }))
.input(liveChatSchema)
.output(EffectSchema.flip(workspaceChatLiveAnswerSchema)),
workspaces: hostedProcedure
.meta(openapi({ method: HTTP_METHOD.GET, path: HOSTED_SERVICE_PATH.LIVE_WORKSPACES }))
.input(liveReadSchema)
.output(EffectSchema.flip(workspacesLiveAnswerSchema)),
};
2 changes: 2 additions & 0 deletions packages/features/src/service-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export const HOSTED_SERVICE_PATH = {
LIVE_AUTOMATIONS: "/api/live/automations",
/** One automation's runs under `?id=`, sealed for following over Neon Realtime (GET), or `not-found` or `unavailable`. */
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",
/**
* 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
Loading
Loading