Skip to content
851 changes: 851 additions & 0 deletions convex/orm/example-project-access-reads.test.ts

Large diffs are not rendered by default.

244 changes: 244 additions & 0 deletions convex/orm/example-project-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { convexTest } from 'convex-test';
import { eq } from 'kitcn/orm';
import { expect, test, vi } from 'vitest';
import * as z from 'zod';
import { api, internal } from '../../example/convex/functions/_generated/api';
import schema, {
projectMembersTable,
projectsTable,
sessionTable,
userTable,
} from '../../example/convex/functions/schema';
import { withExampleEnv, withOrm } from '../setup.testing';

const modules = (
import.meta as ImportMeta & {
glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
).glob('../../example/convex/functions/**/*.ts');

test('registered project mutations preserve owner and member visibility', async () => {
await withExampleEnv(async () => {
const t = convexTest(schema, modules);
const runtime = (
globalThis as typeof globalThis & {
Convex: {
asyncSyscall: (name: string, args: string) => Promise<string>;
};
}
).Convex;
const originalAsyncSyscall = runtime.asyncSyscall.bind(runtime);
// convex-test does not implement the request metadata syscall used by auth.
const metadata = vi
.spyOn(runtime, 'asyncSyscall')
.mockImplementation(async (name, args) =>
name === '1.0/getRequestMetadata'
? '{}'
: originalAsyncSyscall(name, args)
);

try {
const seedUser = async (email: string) =>
t.run(async (ctx) => {
const { orm } = withOrm(ctx, schema);
const [user] = await orm
.insert(userTable)
.values({
name: email,
email,
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
const [session] = await orm
.insert(sessionTable)
.values({
token: email,
userId: user.id,
expiresAt: new Date(Date.now() + 3_600_000),
updatedAt: new Date(),
})
.returning();
return { userId: user.id, sessionId: session.id };
});
const owner = await seedUser('owner@project-lifecycle.test');
const member = await seedUser('member@project-lifecycle.test');
const asOwner = t.withIdentity({
subject: owner.userId,
sessionId: owner.sessionId,
});
const asMember = t.withIdentity({
subject: member.userId,
sessionId: member.sessionId,
});

await t.mutation(internal.generated.aggregate.aggregateBackfill, {});
const statusesSchema = z.array(z.object({ status: z.string() }));
for (let attempt = 0; attempt < 30; attempt += 1) {
const statuses = statusesSchema.parse(
await t.query(
internal.generated.aggregate.aggregateBackfillStatus,
{}
)
);
if (statuses.every((status) => status.status === 'READY')) {
break;
}
expect(attempt).toBeLessThan(29);
await t.mutation(
internal.generated.aggregate.aggregateBackfillChunk,
{}
);
}

const accessRows = async (projectId: string) =>
t.run(async (ctx) => {
const { orm } = withOrm(ctx, schema);
const rows = await orm.query.projectAccess.findMany({
where: { projectId },
columns: { userId: true, isOwner: true, archived: true },
limit: 10,
});
return rows.map(({ userId, isOwner, archived }) => ({
userId,
isOwner,
archived,
}));
});
const legacyProjectId = await t.run(async (ctx) => {
const { orm } = withOrm(ctx, schema);
const [project] = await orm
.insert(projectsTable)
.values({
name: 'Before backfill',
ownerId: owner.userId,
isPublic: false,
archived: false,
})
.returning();
await orm
.insert(projectMembersTable)
.values({ projectId: project.id, userId: member.userId });
return project.id;
});
expect(await accessRows(legacyProjectId)).toEqual([]);
expect(
(await asOwner.query(api.projects.list, { cursor: null, limit: 20 }))
.page
).toMatchObject([{ id: legacyProjectId }]);
for (const viewer of [asOwner, asMember]) {
expect(await viewer.query(api.projects.hasAny, {})).toBe(true);
expect(
await viewer.query(api.projects.listForDropdown, {})
).toMatchObject([{ id: legacyProjectId }]);
}
await t.run(async (ctx) => {
const { orm } = withOrm(ctx, schema);
await orm
.delete(projectsTable)
.where(eq(projectsTable.id, legacyProjectId));
});

const projectId = await asOwner.mutation(api.projects.create, {
name: 'Registered lifecycle',
isPublic: false,
});
expect(await accessRows(projectId)).toEqual([
{ userId: owner.userId, isOwner: true, archived: false },
]);
expect(await asOwner.query(api.projects.hasAny, {})).toBe(true);
expect(await asMember.query(api.projects.hasAny, {})).toBe(false);
expect(
(await asOwner.query(api.projects.list, { cursor: null, limit: 20 }))
.page
).toMatchObject([{ id: projectId, isOwner: true, memberCount: 0 }]);

await asOwner.mutation(api.projects.addMember, {
projectId,
userEmail: 'member@project-lifecycle.test',
});
expect(await accessRows(projectId)).toHaveLength(2);
await asOwner.mutation(api.projects.removeMember, {
projectId,
userId: member.userId,
});
expect(await accessRows(projectId)).toEqual([
{ userId: owner.userId, isOwner: true, archived: false },
]);
expect(await asMember.query(api.projects.hasAny, {})).toBe(false);
expect(await asMember.query(api.projects.listForDropdown, {})).toEqual(
[]
);
await asOwner.mutation(api.projects.addMember, {
projectId,
userEmail: 'member@project-lifecycle.test',
});

await asOwner.mutation(api.projects.archive, { projectId });
expect((await accessRows(projectId)).map((row) => row.archived)).toEqual([
true,
true,
]);
expect(
(await asOwner.query(api.projects.list, { cursor: null, limit: 20 }))
.page
).toEqual([]);
expect(
(
await asOwner.query(api.projects.list, {
cursor: null,
limit: 20,
includeArchived: true,
})
).page
).toMatchObject([{ id: projectId, archived: true }]);

await asOwner.mutation(api.projects.restore, { projectId });
expect((await accessRows(projectId)).map((row) => row.archived)).toEqual([
false,
false,
]);
expect(
(await asOwner.query(api.projects.list, { cursor: null, limit: 20 }))
.page
).toMatchObject([{ id: projectId, archived: false }]);

await asOwner.mutation(api.projects.transfer, {
projectId,
newOwnerId: member.userId,
});
expect(await accessRows(projectId)).toEqual(
expect.arrayContaining([
{ userId: owner.userId, isOwner: false, archived: false },
{ userId: member.userId, isOwner: true, archived: false },
])
);
expect(await asMember.query(api.projects.listForDropdown, {})).toEqual([
{ id: projectId, name: 'Registered lifecycle', isOwner: true },
]);
expect(await asOwner.query(api.projects.listForDropdown, {})).toEqual([
{ id: projectId, name: 'Registered lifecycle', isOwner: false },
]);

await asOwner.mutation(api.projects.leave, { projectId });
expect(await accessRows(projectId)).toEqual([
{ userId: member.userId, isOwner: true, archived: false },
]);
expect(await asOwner.query(api.projects.hasAny, {})).toBe(false);
expect(await asMember.query(api.projects.hasAny, {})).toBe(true);

await t.run(async (ctx) => {
const { orm } = withOrm(ctx, schema);
await orm.delete(projectsTable).where(eq(projectsTable.id, projectId));
});
expect(await accessRows(projectId)).toEqual([]);
expect(await asMember.query(api.projects.hasAny, {})).toBe(false);
expect(await asMember.query(api.projects.listForDropdown, {})).toEqual(
[]
);
} finally {
metadata.mockRestore();
}
});
});
90 changes: 48 additions & 42 deletions convex/orm/example-tag-merge-reads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,35 @@ import schema, {
todoTagsTable,
userTable,
} from '../../example/convex/functions/schema';
import { countDocumentReads, withOrmCtx } from '../setup.testing';

const EXAMPLE_ENV_DEFAULTS = {
ADMIN: 'admin@example.com',
BETTER_AUTH_SECRET: 'test-secret',
GITHUB_CLIENT_ID: 'github-client-id',
GITHUB_CLIENT_SECRET: 'github-client-secret',
GOOGLE_CLIENT_ID: 'google-client-id',
GOOGLE_CLIENT_SECRET: 'google-client-secret',
} as const;

const withExampleEnv = async (run: () => Promise<void>) => {
const original = Object.fromEntries(
Object.keys(EXAMPLE_ENV_DEFAULTS).map((key) => [key, process.env[key]])
);

for (const [key, value] of Object.entries(EXAMPLE_ENV_DEFAULTS)) {
process.env[key] = value;
}

try {
await run();
} finally {
for (const [key, value] of Object.entries(original)) {
if (typeof value === 'string') {
process.env[key] = value;
} else {
delete process.env[key];
}
}
}
import {
convexTest,
countDocumentReads,
withExampleEnv,
withOrm,
} from '../setup.testing';

/**
* `countDocumentReads` swaps `db.query`/`db.get`, and the ORM binds both when it
* is built, so the counter has to be installed *before* `withOrm`. Installed on
* a context that already carries an ORM it counts only reads issued directly
* through `ctx.db` -- a constant zero for an ORM-only test, which makes every
* bound below pass vacuously.
*/
const withCountedOrmCtx = async (
run: (
ctx: {
db: Parameters<typeof countDocumentReads>[0]['db'];
orm: OrmWriter<typeof schema>;
},
reads: ReturnType<typeof countDocumentReads>
) => Promise<void>
): Promise<void> => {
const t = convexTest(schema);
await t.run(async (baseCtx) => {
const reads = countDocumentReads(baseCtx);
const ctx = withOrm(baseCtx, schema);
await run(ctx, reads);
});
};

const TARGET_TAG_TODOS = 40;
Expand Down Expand Up @@ -90,7 +88,7 @@ const seedTodo = async (ctx: ExampleCtx, userId: string, title: string) => {
*/
test('merge cost does not track the target tag size', async () => {
await withExampleEnv(async () => {
await withOrmCtx(schema, schema, async (ctx) => {
await withCountedOrmCtx(async (ctx, reads) => {
const userId = await seedUser(ctx, 'merge-reads@test.dev');
const sourceTagId = await seedTag(ctx, userId, 'Work');
const targetTagId = await seedTag(ctx, userId, 'work');
Expand All @@ -109,13 +107,13 @@ test('merge cost does not track the target tag size', async () => {
.insert(todoTagsTable)
.values({ todoId: sourceTodoId, tagId: sourceTagId });

const reads = countDocumentReads(ctx);
const readsBefore = reads.scanned;

await mergeTags(ctx, userId, {
sourceTagId,
targetTagId,
});
const mergeReads = reads.scanned;
const mergeReads = reads.scanned - readsBefore;

const [sourceTag, targetJoins] = await Promise.all([
ctx.orm.query.tags.findFirst({ where: { id: sourceTagId } }),
Expand All @@ -128,19 +126,27 @@ test('merge cost does not track the target tag size', async () => {

expect(sourceTag).toBeNull();
expect(targetJoins).toHaveLength(TARGET_TAG_TODOS + 1);
expect(targetJoins.some((join) => join.todoId === sourceTodoId)).toBe(
true
);
expect(
targetJoins.some(
(join: { todoId: string }) => join.todoId === sourceTodoId
)
).toBe(true);
// The merge reads its two tags, one source join, one indexed target
// probe, and aggregate bookkeeping. It never materializes 40 target rows.
expect(mergeReads).toBeLessThanOrEqual(12);
// probe, and aggregate bookkeeping. It never materializes the target rows.
//
// 16 is measured, and it is the same at TARGET_TAG_TODOS 40 and 120, which
// is what this test exists to pin. The previous bound of 12 was never
// exercised: the counter was installed after the ORM was built, so it
// reported a constant zero and the assertion passed against nothing.
expect(mergeReads).toBeLessThanOrEqual(16);
expect(mergeReads).toBeLessThan(TARGET_TAG_TODOS);
});
});
});

test('merge probe finds a target row the source todo already carries', async () => {
await withExampleEnv(async () => {
await withOrmCtx(schema, schema, async (ctx) => {
await withCountedOrmCtx(async (ctx, reads) => {
const userId = await seedUser(ctx, 'merge-reads-2@test.dev');
const sourceTagId = await seedTag(ctx, userId, 'Work');
const targetTagId = await seedTag(ctx, userId, 'work');
Expand All @@ -161,7 +167,7 @@ test('merge probe finds a target row the source todo already carries', async ()
.insert(todoTagsTable)
.values({ todoId: sharedTodoId, tagId: sourceTagId });

const reads = countDocumentReads(ctx);
const readsBefore = reads.scanned;

await mergeTags(ctx, userId, {
sourceTagId,
Expand All @@ -181,7 +187,7 @@ test('merge probe finds a target row the source todo already carries', async ()
expect(targetJoins).toHaveLength(1);
// The probe is index-backed: the merge must not scan the target's other
// rows or insert a duplicate for the shared todo.
expect(reads.scanned).toBeLessThanOrEqual(20);
expect(reads.scanned - readsBefore).toBeLessThanOrEqual(20);
});
});
});
Loading
Loading