snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
This commit is contained in:
12
packages/common/auth/package.json
Normal file
12
packages/common/auth/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@affine/auth",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.27.5",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
1
packages/common/auth/src/index.ts
Normal file
1
packages/common/auth/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './token-broker';
|
||||
409
packages/common/auth/src/token-broker.spec.ts
Normal file
409
packages/common/auth/src/token-broker.spec.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AuthSessionError,
|
||||
AuthTokenBroker,
|
||||
type AuthTokenPair,
|
||||
type AuthTokenResponse,
|
||||
type AuthTokenStorage,
|
||||
createRealtimeAuthAdapter,
|
||||
createRequestAuthAdapter,
|
||||
withAuthRetry,
|
||||
} from './token-broker';
|
||||
|
||||
const now = Date.parse('2026-07-11T00:00:00.000Z');
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
function response(sequence: number): AuthTokenResponse {
|
||||
return {
|
||||
tokenType: 'Bearer',
|
||||
accessToken: `access-${sequence}`,
|
||||
expiresIn: 900,
|
||||
refreshToken: `refresh-${sequence}`,
|
||||
refreshExpiresAt: '2026-08-11T00:00:00.000Z',
|
||||
session: {
|
||||
id: 'session-1',
|
||||
absoluteExpiresAt: '2027-01-11T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pair(sequence: number, accessExpiresAt: number): AuthTokenPair {
|
||||
return {
|
||||
version: 1,
|
||||
...response(sequence),
|
||||
accessExpiresAt: new Date(accessExpiresAt).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function setup(initial: AuthTokenPair | null) {
|
||||
let persisted = initial;
|
||||
const storage: AuthTokenStorage = {
|
||||
load: vi.fn(async () => persisted),
|
||||
save: vi.fn(async next => {
|
||||
persisted = next;
|
||||
}),
|
||||
clear: vi.fn(async () => {
|
||||
persisted = null;
|
||||
}),
|
||||
};
|
||||
const transport = { refresh: vi.fn(async () => response(2)) };
|
||||
return {
|
||||
broker: new AuthTokenBroker(storage, transport, {
|
||||
now: () => now,
|
||||
retryDelays: [],
|
||||
}),
|
||||
storage,
|
||||
transport,
|
||||
persisted: () => persisted,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuthTokenBroker', () => {
|
||||
test('proactively refreshes and atomically persists before publishing', async () => {
|
||||
const { broker, storage, persisted } = setup(pair(1, now + 30_000));
|
||||
const states: string[] = [];
|
||||
broker.observeAuthState(state => states.push(state.status));
|
||||
|
||||
await expect(broker.getValidAccessToken()).resolves.toBe('access-2');
|
||||
expect(storage.save).toHaveBeenCalledOnce();
|
||||
expect(persisted()?.accessToken).toBe('access-2');
|
||||
expect(states).toEqual([
|
||||
'initializing',
|
||||
'authenticated',
|
||||
'refreshing',
|
||||
'authenticated',
|
||||
]);
|
||||
});
|
||||
|
||||
test('collapses concurrent refresh into one transport request', async () => {
|
||||
const { broker, transport } = setup(pair(1, now + 30_000));
|
||||
const tokens = await Promise.all(
|
||||
Array.from({ length: 100 }, () => broker.getValidAccessToken())
|
||||
);
|
||||
|
||||
expect(new Set(tokens)).toEqual(new Set(['access-2']));
|
||||
expect(transport.refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('keeps credentials for transient failures', async () => {
|
||||
const { broker, storage, transport } = setup(pair(1, now + 30_000));
|
||||
transport.refresh.mockRejectedValueOnce({
|
||||
code: 'NETWORK_ERROR',
|
||||
config: { body: 'refresh-1' },
|
||||
});
|
||||
const states: unknown[] = [];
|
||||
broker.observeAuthState(state => states.push(state));
|
||||
|
||||
const error = await broker.getValidAccessToken().catch(error => error);
|
||||
expect(error).toMatchObject({
|
||||
transient: true,
|
||||
});
|
||||
expect(JSON.stringify(error)).not.toContain('refresh-1');
|
||||
expect(JSON.stringify(states)).not.toContain('refresh-1');
|
||||
expect(storage.clear).not.toHaveBeenCalled();
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'offline-authenticated',
|
||||
code: 'NETWORK_ERROR',
|
||||
});
|
||||
});
|
||||
|
||||
test('retries transient refresh failures with bounded jittered backoff', async () => {
|
||||
const { storage, transport } = setup(pair(1, now + 30_000));
|
||||
transport.refresh
|
||||
.mockRejectedValueOnce(new TypeError('offline'))
|
||||
.mockResolvedValueOnce(response(2));
|
||||
const sleep = vi.fn(async () => {});
|
||||
const broker = new AuthTokenBroker(storage, transport, {
|
||||
now: () => now,
|
||||
random: () => 0.5,
|
||||
retryDelays: [250],
|
||||
sleep,
|
||||
});
|
||||
|
||||
await expect(broker.getValidAccessToken()).resolves.toBe('access-2');
|
||||
expect(transport.refresh).toHaveBeenCalledTimes(2);
|
||||
expect(sleep).toHaveBeenCalledWith(250);
|
||||
});
|
||||
|
||||
test('clears credentials only for permanent auth failures', async () => {
|
||||
const { broker, storage, transport } = setup(pair(1, now + 30_000));
|
||||
transport.refresh.mockRejectedValueOnce({ code: 'AUTH_SESSION_REVOKED' });
|
||||
|
||||
await expect(broker.getValidAccessToken()).rejects.toMatchObject({
|
||||
code: 'AUTH_SESSION_REVOKED',
|
||||
transient: false,
|
||||
});
|
||||
expect(storage.clear).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('retries a request once only for access-token expiration', async () => {
|
||||
const { broker, transport } = setup(pair(1, now + 600_000));
|
||||
const request = vi
|
||||
.fn<(token: string) => Promise<string>>()
|
||||
.mockRejectedValueOnce({ code: 'ACCESS_TOKEN_EXPIRED' })
|
||||
.mockResolvedValueOnce('ok');
|
||||
|
||||
await expect(withAuthRetry(broker, request)).resolves.toBe('ok');
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(transport.refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('does not retry permission failures', async () => {
|
||||
const { broker, transport } = setup(pair(1, now + 600_000));
|
||||
await expect(
|
||||
withAuthRetry(broker, async () => {
|
||||
throw new AuthSessionError('FORBIDDEN', false);
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
await expect(
|
||||
createRealtimeAuthAdapter(broker).recover({ code: 'FORBIDDEN' })
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
expect(transport.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('serializes initialization before set and clear mutations', async () => {
|
||||
const loaded = deferred<AuthTokenPair | null>();
|
||||
let persisted: AuthTokenPair | null = pair(1, now + 600_000);
|
||||
const storage: AuthTokenStorage = {
|
||||
load: vi.fn(() => loaded.promise),
|
||||
save: vi.fn(async next => {
|
||||
persisted = next;
|
||||
}),
|
||||
clear: vi.fn(async () => {
|
||||
persisted = null;
|
||||
}),
|
||||
};
|
||||
const broker = new AuthTokenBroker(
|
||||
storage,
|
||||
{ refresh: vi.fn() },
|
||||
{
|
||||
now: () => now,
|
||||
}
|
||||
);
|
||||
const setting = broker.set(response(2));
|
||||
loaded.resolve(persisted);
|
||||
await setting;
|
||||
|
||||
expect(await broker.getValidAccessToken(0)).toBe('access-2');
|
||||
await broker.clear('logout');
|
||||
expect(persisted).toBeNull();
|
||||
expect(await broker.getValidAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
test('does not let delayed initialization undo clear', async () => {
|
||||
const loaded = deferred<AuthTokenPair | null>();
|
||||
let persisted: AuthTokenPair | null = pair(1, now + 600_000);
|
||||
const storage: AuthTokenStorage = {
|
||||
load: vi.fn(() => loaded.promise),
|
||||
save: vi.fn(),
|
||||
clear: vi.fn(async () => {
|
||||
persisted = null;
|
||||
}),
|
||||
};
|
||||
const broker = new AuthTokenBroker(
|
||||
storage,
|
||||
{ refresh: vi.fn() },
|
||||
{
|
||||
now: () => now,
|
||||
}
|
||||
);
|
||||
const clearing = broker.clear('logout');
|
||||
loaded.resolve(persisted);
|
||||
await clearing;
|
||||
|
||||
expect(persisted).toBeNull();
|
||||
expect(await broker.getValidAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
test('retries pending secure-store persistence without rotating again', async () => {
|
||||
const { broker, storage, transport, persisted } = setup(
|
||||
pair(1, now + 30_000)
|
||||
);
|
||||
vi.mocked(storage.save).mockRejectedValueOnce(new Error('locked'));
|
||||
|
||||
await expect(broker.getValidAccessToken()).rejects.toMatchObject({
|
||||
code: 'AUTH_TOKEN_STORAGE_UNAVAILABLE',
|
||||
});
|
||||
const recovered = await broker.refresh('storage-retry');
|
||||
expect(recovered).toMatchObject({ accessToken: 'access-2' });
|
||||
expect(JSON.stringify(recovered)).not.toContain('refresh-2');
|
||||
expect(transport.refresh).toHaveBeenCalledOnce();
|
||||
expect(persisted()?.accessToken).toBe('access-2');
|
||||
});
|
||||
|
||||
test('does not resurrect a session when clear races refresh', async () => {
|
||||
const { broker, transport, persisted } = setup(pair(1, now + 600_000));
|
||||
await broker.getValidAccessToken(0);
|
||||
const rotated = deferred<AuthTokenResponse>();
|
||||
transport.refresh.mockReturnValueOnce(rotated.promise);
|
||||
const refreshing = broker.refresh('manual');
|
||||
await Promise.resolve();
|
||||
const clearing = broker.clear('logout');
|
||||
rotated.resolve(response(2));
|
||||
|
||||
await expect(refreshing).rejects.toMatchObject({
|
||||
code: 'AUTH_OPERATION_CANCELLED',
|
||||
});
|
||||
await clearing;
|
||||
expect(persisted()).toBeNull();
|
||||
expect(await broker.getValidAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
test('isolates observers and rejects malformed persisted records', async () => {
|
||||
const { broker } = setup(pair(1, now + 600_000));
|
||||
const states: unknown[] = [];
|
||||
broker.observeAuthState(() => {
|
||||
throw new Error('observer failure');
|
||||
});
|
||||
broker.observeAuthState(state => states.push(state));
|
||||
await expect(broker.getValidAccessToken()).resolves.toBe('access-1');
|
||||
expect(JSON.stringify(states)).not.toContain('refresh-1');
|
||||
|
||||
const storage: AuthTokenStorage = {
|
||||
load: vi.fn(async () => ({ version: 2 }) as never),
|
||||
save: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const malformed = new AuthTokenBroker(storage, { refresh: vi.fn() });
|
||||
await expect(malformed.getValidAccessToken()).resolves.toBeNull();
|
||||
expect(storage.clear).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('shares one refresh across HTTP replay and realtime recovery', async () => {
|
||||
const { broker, transport } = setup(pair(1, now + 600_000));
|
||||
const rotated = deferred<AuthTokenResponse>();
|
||||
transport.refresh.mockReturnValueOnce(rotated.promise);
|
||||
const request = vi
|
||||
.fn<(token: string) => Promise<string>>()
|
||||
.mockRejectedValueOnce({ code: 'ACCESS_TOKEN_EXPIRED' })
|
||||
.mockResolvedValueOnce('ok');
|
||||
const realtime = createRealtimeAuthAdapter(broker);
|
||||
const http = createRequestAuthAdapter(broker).execute(request);
|
||||
const socket = realtime.recover({ code: 'ACCESS_TOKEN_EXPIRED' });
|
||||
await Promise.resolve();
|
||||
rotated.resolve(response(2));
|
||||
|
||||
await expect(Promise.all([http, socket])).resolves.toEqual([
|
||||
'ok',
|
||||
'access-2',
|
||||
]);
|
||||
expect(transport.refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ name: 'permanent', error: { code: 'AUTH_SESSION_REVOKED' } },
|
||||
{ name: 'transient', error: new TypeError('offline') },
|
||||
])('ignores a stale $name refresh failure after a new login', async item => {
|
||||
const { broker, transport, persisted } = setup(pair(1, now + 600_000));
|
||||
await broker.getValidAccessToken(0);
|
||||
const oldRefresh = deferred<AuthTokenResponse>();
|
||||
transport.refresh.mockReturnValueOnce(oldRefresh.promise);
|
||||
const refreshing = broker.refresh('manual');
|
||||
await Promise.resolve();
|
||||
await broker.set(response(3));
|
||||
oldRefresh.reject(item.error);
|
||||
|
||||
await expect(refreshing).rejects.toMatchObject({
|
||||
code: 'AUTH_OPERATION_CANCELLED',
|
||||
});
|
||||
expect(await broker.getValidAccessToken(0)).toBe('access-3');
|
||||
expect(persisted()?.accessToken).toBe('access-3');
|
||||
});
|
||||
|
||||
test('retries initialization after a transient storage failure', async () => {
|
||||
const stored = pair(1, now + 600_000);
|
||||
const storage: AuthTokenStorage = {
|
||||
load: vi
|
||||
.fn<() => Promise<AuthTokenPair | null>>()
|
||||
.mockRejectedValueOnce(new Error('locked'))
|
||||
.mockResolvedValueOnce(stored),
|
||||
save: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const broker = new AuthTokenBroker(
|
||||
storage,
|
||||
{ refresh: vi.fn() },
|
||||
{
|
||||
now: () => now,
|
||||
}
|
||||
);
|
||||
const states: string[] = [];
|
||||
broker.observeAuthState(state => states.push(state.status));
|
||||
|
||||
await expect(broker.getValidAccessToken()).rejects.toMatchObject({
|
||||
code: 'AUTH_TOKEN_STORAGE_UNAVAILABLE',
|
||||
transient: true,
|
||||
});
|
||||
await expect(broker.getValidAccessToken()).resolves.toBe('access-1');
|
||||
expect(storage.load).toHaveBeenCalledTimes(2);
|
||||
expect(states).toEqual(['initializing', 'authenticated']);
|
||||
});
|
||||
|
||||
test.each([0, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_VALUE])(
|
||||
'rejects malformed expiresIn %s before persistence',
|
||||
async expiresIn => {
|
||||
const { broker, storage, transport } = setup(pair(1, now + 30_000));
|
||||
transport.refresh.mockResolvedValueOnce({ ...response(2), expiresIn });
|
||||
|
||||
await expect(broker.getValidAccessToken()).rejects.toMatchObject({
|
||||
code: 'AUTH_TOKEN_RESPONSE_INVALID',
|
||||
});
|
||||
expect(storage.save).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test('normalizes clear storage failures after clearing memory', async () => {
|
||||
const { broker, storage } = setup(pair(1, now + 600_000));
|
||||
await broker.getValidAccessToken(0);
|
||||
vi.mocked(storage.clear).mockRejectedValueOnce(new Error('locked'));
|
||||
|
||||
await expect(broker.clear('logout')).rejects.toMatchObject({
|
||||
code: 'AUTH_TOKEN_STORAGE_UNAVAILABLE',
|
||||
});
|
||||
await expect(broker.getValidAccessToken()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('revokes captured credential even when local clear fails', async () => {
|
||||
const { broker, storage } = setup(pair(1, now + 600_000));
|
||||
await broker.getValidAccessToken(0);
|
||||
vi.mocked(storage.clear).mockRejectedValueOnce(new Error('locked'));
|
||||
const revoke = vi.fn(async () => {});
|
||||
|
||||
await expect(broker.revoke('logout', revoke)).rejects.toMatchObject({
|
||||
code: 'AUTH_TOKEN_STORAGE_UNAVAILABLE',
|
||||
});
|
||||
expect(revoke).toHaveBeenCalledWith('refresh-1');
|
||||
await expect(broker.getValidAccessToken()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('initializes when the first consumer only observes state', async () => {
|
||||
const { broker, storage } = setup(pair(1, now + 600_000));
|
||||
const states: string[] = [];
|
||||
broker.observeAuthState(state => states.push(state.status));
|
||||
await vi.waitFor(() => expect(states.at(-1)).toBe('authenticated'));
|
||||
|
||||
expect(storage.load).toHaveBeenCalledOnce();
|
||||
expect(states).toEqual(['initializing', 'authenticated']);
|
||||
});
|
||||
|
||||
test('redacts unknown external error codes', async () => {
|
||||
const { broker, transport } = setup(pair(1, now + 30_000));
|
||||
transport.refresh.mockRejectedValueOnce({
|
||||
code: 'secret-refresh-1',
|
||||
});
|
||||
|
||||
await expect(broker.getValidAccessToken()).rejects.toMatchObject({
|
||||
code: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
|
||||
});
|
||||
});
|
||||
});
|
||||
464
packages/common/auth/src/token-broker.ts
Normal file
464
packages/common/auth/src/token-broker.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
export const PERMANENT_AUTH_CODES = new Set([
|
||||
'ACCESS_TOKEN_INVALID',
|
||||
'AUTH_SESSION_EXPIRED',
|
||||
'AUTH_SESSION_REVOKED',
|
||||
'REFRESH_TOKEN_INVALID',
|
||||
'REFRESH_TOKEN_REUSED',
|
||||
'UNSUPPORTED_CLIENT_VERSION',
|
||||
]);
|
||||
const PUBLIC_AUTH_CODES = new Set([
|
||||
...PERMANENT_AUTH_CODES,
|
||||
'ACCESS_TOKEN_EXPIRED',
|
||||
'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
|
||||
'AUTH_TOKEN_RESPONSE_INVALID',
|
||||
'AUTH_TOKEN_STORAGE_UNAVAILABLE',
|
||||
'FORBIDDEN',
|
||||
'NETWORK_ERROR',
|
||||
'TOO_MANY_REQUESTS',
|
||||
]);
|
||||
const SAFE_AUTH_CODES = new Set([
|
||||
...PUBLIC_AUTH_CODES,
|
||||
'AUTH_OPERATION_CANCELLED',
|
||||
'AUTH_SESSION_EMPTY',
|
||||
]);
|
||||
|
||||
export interface AuthTokenPair {
|
||||
version: 1;
|
||||
tokenType: 'Bearer';
|
||||
accessToken: string;
|
||||
accessExpiresAt: string;
|
||||
refreshToken: string;
|
||||
refreshExpiresAt: string;
|
||||
session: {
|
||||
id: string;
|
||||
absoluteExpiresAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthTokenResponse {
|
||||
tokenType: 'Bearer';
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
refreshToken: string;
|
||||
refreshExpiresAt: string;
|
||||
session: AuthTokenPair['session'];
|
||||
}
|
||||
|
||||
export interface AuthSessionSnapshot {
|
||||
accessExpiresAt: string;
|
||||
refreshExpiresAt: string;
|
||||
session: AuthTokenPair['session'];
|
||||
}
|
||||
|
||||
export interface AuthAccessToken extends AuthSessionSnapshot {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export type AuthState =
|
||||
| { status: 'initializing' }
|
||||
| { status: 'empty' }
|
||||
| { status: 'authenticated'; session: AuthSessionSnapshot }
|
||||
| { status: 'refreshing'; session: AuthSessionSnapshot; reason: string }
|
||||
| {
|
||||
status: 'offline-authenticated';
|
||||
session: AuthSessionSnapshot;
|
||||
code: string;
|
||||
}
|
||||
| { status: 'revoked'; code: string };
|
||||
|
||||
export interface AuthTokenStorage {
|
||||
load(): Promise<AuthTokenPair | null>;
|
||||
save(pair: AuthTokenPair): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AuthTokenTransport {
|
||||
refresh(refreshToken: string): Promise<AuthTokenResponse>;
|
||||
}
|
||||
|
||||
export interface AuthTokenBrokerContract {
|
||||
getValidAccessToken(minValidity?: number): Promise<string | null>;
|
||||
refresh(reason: string): Promise<AuthAccessToken>;
|
||||
clear(reason: string): Promise<void>;
|
||||
observeAuthState(listener: (state: AuthState) => void): () => void;
|
||||
}
|
||||
|
||||
export interface AuthTokenBrokerOptions {
|
||||
now?: () => number;
|
||||
random?: () => number;
|
||||
retryDelays?: number[];
|
||||
sleep?: (delay: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export class AuthSessionError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
readonly transient: boolean
|
||||
) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthTokenBroker implements AuthTokenBrokerContract {
|
||||
private pair: AuthTokenPair | null = null;
|
||||
private initialized: Promise<void> | null = null;
|
||||
private refreshPromise: Promise<AuthTokenPair> | null = null;
|
||||
private pending: { pair: AuthTokenPair; epoch: number } | null = null;
|
||||
private mutationEpoch = 0;
|
||||
private storageMutation: Promise<void> = Promise.resolve();
|
||||
private readonly listeners = new Set<(state: AuthState) => void>();
|
||||
private state: AuthState = { status: 'initializing' };
|
||||
private readonly now: () => number;
|
||||
private readonly random: () => number;
|
||||
private readonly retryDelays: number[];
|
||||
private readonly sleep: (delay: number) => Promise<void>;
|
||||
|
||||
constructor(
|
||||
private readonly storage: AuthTokenStorage,
|
||||
private readonly transport: AuthTokenTransport,
|
||||
options: AuthTokenBrokerOptions = {}
|
||||
) {
|
||||
this.now = options.now ?? Date.now;
|
||||
this.random = options.random ?? Math.random;
|
||||
this.retryDelays = options.retryDelays ?? [250, 1000];
|
||||
this.sleep =
|
||||
options.sleep ??
|
||||
(delay => new Promise(resolve => setTimeout(resolve, delay)));
|
||||
}
|
||||
|
||||
observeAuthState(listener: (state: AuthState) => void) {
|
||||
this.listeners.add(listener);
|
||||
try {
|
||||
listener(this.state);
|
||||
} catch {
|
||||
// Observers cannot participate in credential lifecycle decisions.
|
||||
}
|
||||
void this.initialize().catch(() => {});
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async set(response: AuthTokenResponse) {
|
||||
await this.initialize();
|
||||
const epoch = ++this.mutationEpoch;
|
||||
const pair = this.toPair(response);
|
||||
this.pending = { pair, epoch };
|
||||
return this.toAccessToken(await this.persistPending());
|
||||
}
|
||||
|
||||
async getValidAccessToken(minValidity = 60_000) {
|
||||
await this.initialize();
|
||||
if (!this.pair) return null;
|
||||
if (Date.parse(this.pair.accessExpiresAt) - this.now() <= minValidity) {
|
||||
await this.refresh('proactive');
|
||||
}
|
||||
return this.pair?.accessToken ?? null;
|
||||
}
|
||||
|
||||
async refresh(reason: string) {
|
||||
await this.initialize();
|
||||
if (!this.pair) throw new AuthSessionError('AUTH_SESSION_EMPTY', false);
|
||||
if (!this.refreshPromise) {
|
||||
this.refreshPromise = (
|
||||
this.pending ? this.persistPending() : this.performRefresh(reason)
|
||||
).finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return this.toAccessToken(await this.refreshPromise);
|
||||
}
|
||||
|
||||
async clear(_reason: string) {
|
||||
await this.initialize();
|
||||
++this.mutationEpoch;
|
||||
this.pending = null;
|
||||
let failed = false;
|
||||
try {
|
||||
await this.mutateStorage(() => this.storage.clear());
|
||||
} catch {
|
||||
failed = true;
|
||||
} finally {
|
||||
this.pair = null;
|
||||
this.publish({ status: 'empty' });
|
||||
}
|
||||
if (failed) {
|
||||
throw new AuthSessionError('AUTH_TOKEN_STORAGE_UNAVAILABLE', true);
|
||||
}
|
||||
}
|
||||
|
||||
async revoke(
|
||||
_reason: string,
|
||||
revokeToken: (refreshToken: string) => Promise<void>
|
||||
) {
|
||||
await this.initialize();
|
||||
++this.mutationEpoch;
|
||||
this.pending = null;
|
||||
const refreshToken = this.pair?.refreshToken;
|
||||
let storageError: unknown;
|
||||
try {
|
||||
await this.mutateStorage(() => this.storage.clear());
|
||||
} catch (error) {
|
||||
storageError = error;
|
||||
} finally {
|
||||
this.pair = null;
|
||||
this.publish({ status: 'empty' });
|
||||
}
|
||||
if (refreshToken) await revokeToken(refreshToken);
|
||||
if (storageError) {
|
||||
throw new AuthSessionError('AUTH_TOKEN_STORAGE_UNAVAILABLE', true);
|
||||
}
|
||||
}
|
||||
|
||||
private async initialize() {
|
||||
if (!this.initialized) {
|
||||
const attempt = Promise.resolve()
|
||||
.then(() => this.storage.load())
|
||||
.then(async pair => {
|
||||
if (pair && !isAuthTokenPair(pair)) {
|
||||
await this.mutateStorage(() => this.storage.clear());
|
||||
pair = null;
|
||||
}
|
||||
this.pair = pair;
|
||||
this.publish(
|
||||
pair
|
||||
? { status: 'authenticated', session: this.toSnapshot(pair) }
|
||||
: { status: 'empty' }
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
throw new AuthSessionError('AUTH_TOKEN_STORAGE_UNAVAILABLE', true);
|
||||
});
|
||||
this.initialized = attempt;
|
||||
try {
|
||||
await attempt;
|
||||
} catch (error) {
|
||||
if (this.initialized === attempt) this.initialized = null;
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
await this.initialized;
|
||||
}
|
||||
|
||||
private async performRefresh(reason: string) {
|
||||
const current = this.pair;
|
||||
if (!current) throw new AuthSessionError('AUTH_SESSION_EMPTY', false);
|
||||
const epoch = this.mutationEpoch;
|
||||
this.publish({
|
||||
status: 'refreshing',
|
||||
session: this.toSnapshot(current),
|
||||
reason,
|
||||
});
|
||||
try {
|
||||
const pair = this.toPair(await this.requestRefresh(current.refreshToken));
|
||||
if (epoch !== this.mutationEpoch) {
|
||||
throw new AuthSessionError('AUTH_OPERATION_CANCELLED', true);
|
||||
}
|
||||
this.pending = { pair, epoch };
|
||||
return await this.persistPending();
|
||||
} catch (error) {
|
||||
const classified = classifyAuthError(error);
|
||||
if (
|
||||
classified.code === 'AUTH_OPERATION_CANCELLED' ||
|
||||
epoch !== this.mutationEpoch
|
||||
) {
|
||||
throw new AuthSessionError('AUTH_OPERATION_CANCELLED', true);
|
||||
}
|
||||
if (!classified.transient) {
|
||||
try {
|
||||
await this.mutateStorage(() => this.storage.clear());
|
||||
} catch {
|
||||
// The in-memory credential must still become unusable immediately.
|
||||
} finally {
|
||||
this.pair = null;
|
||||
this.publish({ status: 'revoked', code: classified.code });
|
||||
}
|
||||
} else {
|
||||
this.publish({
|
||||
status: 'offline-authenticated',
|
||||
session: this.toSnapshot(current),
|
||||
code: classified.code,
|
||||
});
|
||||
}
|
||||
throw classified;
|
||||
}
|
||||
}
|
||||
|
||||
private async persistPending() {
|
||||
const pending = this.pending;
|
||||
if (!pending) throw new AuthSessionError('AUTH_SESSION_EMPTY', false);
|
||||
try {
|
||||
await this.mutateStorage(() => this.storage.save(pending.pair));
|
||||
} catch {
|
||||
throw new AuthSessionError('AUTH_TOKEN_STORAGE_UNAVAILABLE', true);
|
||||
}
|
||||
if (pending.epoch !== this.mutationEpoch) {
|
||||
throw new AuthSessionError('AUTH_OPERATION_CANCELLED', true);
|
||||
}
|
||||
this.pending = null;
|
||||
this.pair = pending.pair;
|
||||
this.publish({
|
||||
status: 'authenticated',
|
||||
session: this.toSnapshot(pending.pair),
|
||||
});
|
||||
return pending.pair;
|
||||
}
|
||||
|
||||
private async mutateStorage<T>(operation: () => Promise<T>) {
|
||||
const result = this.storageMutation.then(operation, operation);
|
||||
this.storageMutation = result.then(
|
||||
() => {},
|
||||
() => {}
|
||||
);
|
||||
return await result;
|
||||
}
|
||||
|
||||
private async requestRefresh(refreshToken: string) {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await this.transport.refresh(refreshToken);
|
||||
} catch (error) {
|
||||
const classified = classifyAuthError(error);
|
||||
const delay = this.retryDelays[attempt];
|
||||
if (!classified.transient || delay === undefined) throw classified;
|
||||
await this.sleep(delay * (0.75 + this.random() * 0.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toPair(response: AuthTokenResponse): AuthTokenPair {
|
||||
const accessExpiresAt = this.now() + response.expiresIn * 1000;
|
||||
if (
|
||||
!Number.isFinite(response.expiresIn) ||
|
||||
response.expiresIn <= 0 ||
|
||||
!Number.isFinite(accessExpiresAt) ||
|
||||
Math.abs(accessExpiresAt) > 8.64e15
|
||||
) {
|
||||
throw new AuthSessionError('AUTH_TOKEN_RESPONSE_INVALID', true);
|
||||
}
|
||||
const pair: AuthTokenPair = {
|
||||
version: 1,
|
||||
tokenType: response.tokenType,
|
||||
accessToken: response.accessToken,
|
||||
accessExpiresAt: new Date(accessExpiresAt).toISOString(),
|
||||
refreshToken: response.refreshToken,
|
||||
refreshExpiresAt: response.refreshExpiresAt,
|
||||
session: response.session,
|
||||
};
|
||||
if (!isAuthTokenPair(pair)) {
|
||||
throw new AuthSessionError('AUTH_TOKEN_RESPONSE_INVALID', true);
|
||||
}
|
||||
return pair;
|
||||
}
|
||||
|
||||
private toSnapshot(pair: AuthTokenPair): AuthSessionSnapshot {
|
||||
return {
|
||||
accessExpiresAt: pair.accessExpiresAt,
|
||||
refreshExpiresAt: pair.refreshExpiresAt,
|
||||
session: { ...pair.session },
|
||||
};
|
||||
}
|
||||
|
||||
private toAccessToken(pair: AuthTokenPair): AuthAccessToken {
|
||||
return {
|
||||
...this.toSnapshot(pair),
|
||||
accessToken: pair.accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
private publish(state: AuthState) {
|
||||
this.state = state;
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(state);
|
||||
} catch {
|
||||
// Observers cannot participate in credential lifecycle decisions.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyAuthError(error: unknown) {
|
||||
if (error instanceof AuthSessionError) {
|
||||
return SAFE_AUTH_CODES.has(error.code)
|
||||
? error
|
||||
: new AuthSessionError('AUTH_SESSION_TEMPORARILY_UNAVAILABLE', true);
|
||||
}
|
||||
const code =
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string'
|
||||
? error.code
|
||||
: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE';
|
||||
const publicCode = PUBLIC_AUTH_CODES.has(code)
|
||||
? code
|
||||
: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE';
|
||||
return new AuthSessionError(
|
||||
publicCode,
|
||||
!PERMANENT_AUTH_CODES.has(publicCode)
|
||||
);
|
||||
}
|
||||
|
||||
export async function withAuthRetry<T>(
|
||||
broker: AuthTokenBrokerContract,
|
||||
request: (accessToken: string) => Promise<T>
|
||||
) {
|
||||
const token = await broker.getValidAccessToken();
|
||||
if (!token) throw new AuthSessionError('AUTH_SESSION_EMPTY', false);
|
||||
try {
|
||||
return await request(token);
|
||||
} catch (error) {
|
||||
const classified = classifyAuthError(error);
|
||||
if (classified.code !== 'ACCESS_TOKEN_EXPIRED') throw classified;
|
||||
const pair = await broker.refresh('access-token-expired');
|
||||
return await request(pair.accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
export function createRealtimeAuthAdapter(broker: AuthTokenBrokerContract) {
|
||||
return {
|
||||
getAccessToken: () => broker.getValidAccessToken(120_000),
|
||||
recover: async (error: unknown) => {
|
||||
const classified = classifyAuthError(error);
|
||||
if (classified.code !== 'ACCESS_TOKEN_EXPIRED') throw classified;
|
||||
return (await broker.refresh('realtime-access-token-expired'))
|
||||
.accessToken;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createRequestAuthAdapter(broker: AuthTokenBrokerContract) {
|
||||
return {
|
||||
execute: <T>(request: (accessToken: string) => Promise<T>) =>
|
||||
withAuthRetry(broker, request),
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorkerAuthAdapter(broker: AuthTokenBrokerContract) {
|
||||
return {
|
||||
getAccessToken: () => broker.getValidAccessToken(60_000),
|
||||
};
|
||||
}
|
||||
|
||||
export function isAuthTokenPair(value: unknown): value is AuthTokenPair {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const pair = value as Partial<AuthTokenPair>;
|
||||
return (
|
||||
pair.version === 1 &&
|
||||
pair.tokenType === 'Bearer' &&
|
||||
typeof pair.accessToken === 'string' &&
|
||||
pair.accessToken.length > 0 &&
|
||||
typeof pair.refreshToken === 'string' &&
|
||||
pair.refreshToken.length > 0 &&
|
||||
typeof pair.accessExpiresAt === 'string' &&
|
||||
Number.isFinite(Date.parse(pair.accessExpiresAt)) &&
|
||||
typeof pair.refreshExpiresAt === 'string' &&
|
||||
Number.isFinite(Date.parse(pair.refreshExpiresAt)) &&
|
||||
!!pair.session &&
|
||||
typeof pair.session.id === 'string' &&
|
||||
typeof pair.session.absoluteExpiresAt === 'string' &&
|
||||
Number.isFinite(Date.parse(pair.session.absoluteExpiresAt))
|
||||
);
|
||||
}
|
||||
10
packages/common/auth/tsconfig.json
Normal file
10
packages/common/auth/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.web.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": []
|
||||
}
|
||||
3
packages/common/debug/README.md
Normal file
3
packages/common/debug/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# @affine/debug
|
||||
|
||||
A common debug interface for packages in this repository.
|
||||
16
packages/common/debug/package.json
Normal file
16
packages/common/debug/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@affine/debug",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.12",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"version": "0.27.5"
|
||||
}
|
||||
34
packages/common/debug/src/__tests__/index.spec.ts
Normal file
34
packages/common/debug/src/__tests__/index.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { DebugLogger } from '..';
|
||||
|
||||
describe('debug', () => {
|
||||
test('disabled', () => {
|
||||
const logger = new DebugLogger('test');
|
||||
logger.enabled = false;
|
||||
for (const level of ['debug', 'info', 'warn', 'error'] as const) {
|
||||
const fn = vi.fn();
|
||||
vi.spyOn(globalThis.console, level).mockImplementation(fn);
|
||||
expect(logger.enabled).toBe(false);
|
||||
expect(fn).not.toBeCalled();
|
||||
logger[level]('test');
|
||||
expect(fn, level).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test('log', () => {
|
||||
const logger = new DebugLogger('test');
|
||||
logger.enabled = true;
|
||||
for (const level of ['debug', 'info', 'warn', 'error'] as const) {
|
||||
const fn = vi.fn();
|
||||
vi.spyOn(globalThis.console, level).mockImplementation(fn);
|
||||
expect(logger.enabled).toBe(true);
|
||||
expect(fn).not.toBeCalled();
|
||||
logger[level]('test');
|
||||
expect(fn, level).toBeCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
82
packages/common/debug/src/index.ts
Normal file
82
packages/common/debug/src/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import debug from 'debug';
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
const SESSION_KEY = 'affine:debug';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const getSessionValue = (key: string) => {
|
||||
try {
|
||||
return sessionStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const setSessionValue = (key: string, value: string) => {
|
||||
try {
|
||||
sessionStorage.setItem(key, value);
|
||||
} catch {
|
||||
// ignore if storage is not accessible (e.g., sandboxed renderer)
|
||||
}
|
||||
};
|
||||
|
||||
// enable debug logs if the URL search string contains `debug`
|
||||
// e.g. http://localhost:3000/?debug
|
||||
if (window.location.search.includes('debug')) {
|
||||
// enable debug logs for the current session
|
||||
// since the query string may be removed by the browser after navigations,
|
||||
// we need to store the debug flag in sessionStorage
|
||||
setSessionValue(SESSION_KEY, 'true');
|
||||
}
|
||||
if (getSessionValue(SESSION_KEY) === 'true') {
|
||||
// enable all debug logs by default
|
||||
debug.enable('*');
|
||||
console.warn('Debug logs enabled');
|
||||
}
|
||||
if (BUILD_CONFIG.debug) {
|
||||
debug.enable('*,-micromark');
|
||||
console.warn('Debug logs enabled');
|
||||
}
|
||||
}
|
||||
|
||||
export class DebugLogger {
|
||||
private readonly _debug: debug.Debugger;
|
||||
|
||||
constructor(namespace: string) {
|
||||
this._debug = debug(namespace);
|
||||
}
|
||||
|
||||
set enabled(enabled: boolean) {
|
||||
this._debug.enabled = enabled;
|
||||
}
|
||||
|
||||
get enabled() {
|
||||
return this._debug.enabled;
|
||||
}
|
||||
|
||||
debug(message: string, ...args: any[]) {
|
||||
this.log('debug', message, ...args);
|
||||
}
|
||||
|
||||
info(message: string, ...args: any[]) {
|
||||
this.log('info', message, ...args);
|
||||
}
|
||||
|
||||
warn(message: string, ...args: any[]) {
|
||||
this.log('warn', message, ...args);
|
||||
}
|
||||
|
||||
error(message: string, ...args: any[]) {
|
||||
this.log('error', message, ...args);
|
||||
}
|
||||
|
||||
log(level: LogLevel, message: string, ...args: any[]) {
|
||||
this._debug.log = console[level].bind(console);
|
||||
this._debug(`[${level.toUpperCase()}] ${message}`, ...args);
|
||||
}
|
||||
|
||||
namespace(extra: string) {
|
||||
const currentNamespace = this._debug.namespace;
|
||||
return new DebugLogger(`${currentNamespace}:${extra}`);
|
||||
}
|
||||
}
|
||||
10
packages/common/debug/tsconfig.json
Normal file
10
packages/common/debug/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.web.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": []
|
||||
}
|
||||
25
packages/common/env/package.json
vendored
Normal file
25
packages/common/env/package.json
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@affine/env",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"exports": {
|
||||
"./automation": "./src/automation.ts",
|
||||
"./global": "./src/global.ts",
|
||||
"./constant": "./src/constant.ts",
|
||||
"./workspace": "./src/workspace.ts",
|
||||
"./filter": "./src/filter.ts",
|
||||
"./blocksuite": "./src/blocksuite/index.ts",
|
||||
"./worker": "./src/worker.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@affine/templates": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"version": "0.27.5"
|
||||
}
|
||||
27
packages/common/env/src/__tests__/is-valid-ip-address.spec.ts
vendored
Normal file
27
packages/common/env/src/__tests__/is-valid-ip-address.spec.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { isValidIPAddress } from '../is-valid-ip-address.js';
|
||||
|
||||
describe('isValidIpAddress', () => {
|
||||
test('should return true for valid IP address', () => {
|
||||
['115.42.150.37', '192.168.0.1', '110.234.52.124', 'localhost'].forEach(
|
||||
ip => {
|
||||
expect(isValidIPAddress(ip)).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should return false for invalid IP address', () => {
|
||||
[
|
||||
'210.110',
|
||||
'255',
|
||||
'y.y.y.y',
|
||||
'255.0.0.y',
|
||||
'666.10.10.20',
|
||||
'4444.11.11.11',
|
||||
'33.3333.33.3',
|
||||
].forEach(ip => {
|
||||
expect(isValidIPAddress(ip)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
12
packages/common/env/src/automation.ts
vendored
Normal file
12
packages/common/env/src/automation.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { z } from 'zod';
|
||||
|
||||
export type Action<
|
||||
InputSchema extends z.ZodObject<any, any, any, any>,
|
||||
Args extends readonly any[],
|
||||
> = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: InputSchema;
|
||||
action: (input: z.input<InputSchema>, ...args: Args) => void;
|
||||
};
|
||||
114
packages/common/env/src/constant.ts
vendored
Normal file
114
packages/common/env/src/constant.ts
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
// This file should has not side effect
|
||||
|
||||
declare global {
|
||||
// oxlint-disable-next-line no-var
|
||||
var __appInfo: {
|
||||
electron: boolean;
|
||||
scheme: string;
|
||||
windowName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_WORKSPACE_NAME = 'Demo Workspace';
|
||||
export const UNTITLED_WORKSPACE_NAME = 'Untitled';
|
||||
|
||||
export const DEFAULT_SORT_KEY = 'updatedDate';
|
||||
export const MessageCode = {
|
||||
loginError: 0,
|
||||
noPermission: 1,
|
||||
loadListFailed: 2,
|
||||
getDetailFailed: 3,
|
||||
createWorkspaceFailed: 4,
|
||||
getMembersFailed: 5,
|
||||
updateWorkspaceFailed: 6,
|
||||
deleteWorkspaceFailed: 7,
|
||||
inviteMemberFailed: 8,
|
||||
removeMemberFailed: 9,
|
||||
acceptInvitingFailed: 10,
|
||||
getBlobFailed: 11,
|
||||
leaveWorkspaceFailed: 12,
|
||||
downloadWorkspaceFailed: 13,
|
||||
refreshTokenError: 14,
|
||||
blobTooLarge: 15,
|
||||
} as const;
|
||||
|
||||
export const Messages = {
|
||||
[MessageCode.loginError]: {
|
||||
message: 'Login failed',
|
||||
},
|
||||
[MessageCode.noPermission]: {
|
||||
message: 'No permission',
|
||||
},
|
||||
[MessageCode.loadListFailed]: {
|
||||
message: 'Load list failed',
|
||||
},
|
||||
[MessageCode.getDetailFailed]: {
|
||||
message: 'Get detail failed',
|
||||
},
|
||||
[MessageCode.createWorkspaceFailed]: {
|
||||
message: 'Create workspace failed',
|
||||
},
|
||||
[MessageCode.getMembersFailed]: {
|
||||
message: 'Get members failed',
|
||||
},
|
||||
[MessageCode.updateWorkspaceFailed]: {
|
||||
message: 'Update workspace failed',
|
||||
},
|
||||
[MessageCode.deleteWorkspaceFailed]: {
|
||||
message: 'Delete workspace failed',
|
||||
},
|
||||
[MessageCode.inviteMemberFailed]: {
|
||||
message: 'Invite member failed',
|
||||
},
|
||||
[MessageCode.removeMemberFailed]: {
|
||||
message: 'Remove member failed',
|
||||
},
|
||||
[MessageCode.acceptInvitingFailed]: {
|
||||
message: 'Accept inviting failed',
|
||||
},
|
||||
[MessageCode.getBlobFailed]: {
|
||||
message: 'Get blob failed',
|
||||
},
|
||||
[MessageCode.leaveWorkspaceFailed]: {
|
||||
message: 'Leave workspace failed',
|
||||
},
|
||||
[MessageCode.downloadWorkspaceFailed]: {
|
||||
message: 'Download workspace failed',
|
||||
},
|
||||
[MessageCode.refreshTokenError]: {
|
||||
message: 'Refresh token failed',
|
||||
},
|
||||
[MessageCode.blobTooLarge]: {
|
||||
message: 'Blob too large',
|
||||
},
|
||||
} as const satisfies {
|
||||
[key in (typeof MessageCode)[keyof typeof MessageCode]]: {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class WorkspaceNotFoundError extends TypeError {
|
||||
readonly workspaceId: string;
|
||||
|
||||
constructor(workspaceId: string) {
|
||||
super();
|
||||
this.workspaceId = workspaceId;
|
||||
}
|
||||
}
|
||||
|
||||
export class QueryParamError extends TypeError {
|
||||
readonly targetKey: string;
|
||||
readonly query: unknown;
|
||||
|
||||
constructor(targetKey: string, query: unknown) {
|
||||
super();
|
||||
this.targetKey = targetKey;
|
||||
this.query = query;
|
||||
}
|
||||
}
|
||||
|
||||
export class Unreachable extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
60
packages/common/env/src/filter.ts
vendored
Normal file
60
packages/common/env/src/filter.ts
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { DocsPropertiesMeta } from '@blocksuite/affine/store';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const literalValueSchema: z.ZodType<LiteralValue, z.ZodTypeDef> =
|
||||
z.union([
|
||||
z.number(),
|
||||
z.string(),
|
||||
z.boolean(),
|
||||
z.array(z.lazy(() => literalValueSchema)),
|
||||
z.record(z.lazy(() => literalValueSchema)),
|
||||
]);
|
||||
|
||||
export type LiteralValue =
|
||||
| number
|
||||
| string
|
||||
| boolean
|
||||
| {
|
||||
[K: string]: LiteralValue;
|
||||
}
|
||||
| Array<LiteralValue>;
|
||||
|
||||
export const refSchema: z.ZodType<Ref, z.ZodTypeDef> = z.object({
|
||||
type: z.literal('ref'),
|
||||
name: z.never(),
|
||||
});
|
||||
|
||||
export type Ref = {
|
||||
type: 'ref';
|
||||
name: keyof VariableMap;
|
||||
};
|
||||
|
||||
export interface VariableMap {}
|
||||
|
||||
export const literalSchema = z.object({
|
||||
type: z.literal('literal'),
|
||||
value: literalValueSchema,
|
||||
});
|
||||
|
||||
export type Literal = z.input<typeof literalSchema>;
|
||||
|
||||
export const filterSchema = z.object({
|
||||
type: z.literal('filter'),
|
||||
left: refSchema,
|
||||
funcName: z.string(),
|
||||
args: z.array(literalSchema),
|
||||
});
|
||||
|
||||
export type Filter = z.input<typeof filterSchema>;
|
||||
|
||||
export const collectionSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
filterList: z.array(filterSchema),
|
||||
allowList: z.array(z.string()),
|
||||
createDate: z.union([z.date(), z.number()]).optional(),
|
||||
updateDate: z.union([z.date(), z.number()]).optional(),
|
||||
});
|
||||
export type Collection = z.input<typeof collectionSchema>;
|
||||
|
||||
export type PropertiesMeta = DocsPropertiesMeta;
|
||||
83
packages/common/env/src/global.ts
vendored
Normal file
83
packages/common/env/src/global.ts
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
import { UaHelper } from './ua-helper.js';
|
||||
|
||||
export function setupGlobal() {
|
||||
if (globalThis.$AFFINE_SETUP) {
|
||||
return;
|
||||
}
|
||||
|
||||
let environment: Environment = {
|
||||
isLinux: false,
|
||||
isMacOs: false,
|
||||
isSafari: false,
|
||||
isWindows: false,
|
||||
isFireFox: false,
|
||||
isChrome: false,
|
||||
isIOS: false,
|
||||
isPwa: false,
|
||||
isMobile: false,
|
||||
isSelfHosted: false,
|
||||
// publicPath is the root of assets files
|
||||
publicPath: '/',
|
||||
// subPath is the path to access the affine service
|
||||
subPath: '',
|
||||
};
|
||||
|
||||
if (globalThis.navigator) {
|
||||
const uaHelper = new UaHelper(globalThis.navigator);
|
||||
|
||||
environment = {
|
||||
...environment,
|
||||
isMobile: uaHelper.isMobile,
|
||||
isLinux: uaHelper.isLinux,
|
||||
isMacOs: uaHelper.isMacOs,
|
||||
isSafari: uaHelper.isSafari,
|
||||
isWindows: uaHelper.isWindows,
|
||||
isFireFox: uaHelper.isFireFox,
|
||||
isChrome: uaHelper.isChrome,
|
||||
isIOS: uaHelper.isIOS,
|
||||
isPwa: uaHelper.isStandalone,
|
||||
};
|
||||
// Chrome on iOS is still Safari
|
||||
if (environment.isChrome && !environment.isIOS) {
|
||||
environment = {
|
||||
...environment,
|
||||
isSafari: false,
|
||||
isFireFox: false,
|
||||
isChrome: true,
|
||||
chromeVersion: uaHelper.getChromeVersion(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
applyEnvironmentOverrides(environment);
|
||||
|
||||
globalThis.environment = environment;
|
||||
globalThis.$AFFINE_SETUP = true;
|
||||
}
|
||||
|
||||
function applyEnvironmentOverrides(environment: Environment) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const metaTags = document.querySelectorAll('meta');
|
||||
|
||||
metaTags.forEach(meta => {
|
||||
if (!meta.name.startsWith('env:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = meta.name.substring(4);
|
||||
|
||||
// all environments should have default value
|
||||
// so ignore non-defined overrides
|
||||
if (name in environment) {
|
||||
// @ts-expect-error safe
|
||||
environment[name] =
|
||||
// @ts-expect-error safe
|
||||
typeof environment[name] === 'string'
|
||||
? meta.content
|
||||
: JSON.parse(meta.content);
|
||||
}
|
||||
});
|
||||
}
|
||||
8
packages/common/env/src/is-valid-ip-address.ts
vendored
Normal file
8
packages/common/env/src/is-valid-ip-address.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export function isValidIPAddress(address: string) {
|
||||
if (address === 'localhost') {
|
||||
return true;
|
||||
}
|
||||
return /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(
|
||||
address
|
||||
);
|
||||
}
|
||||
7
packages/common/env/src/page-info.ts
vendored
Normal file
7
packages/common/env/src/page-info.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export type PageInfo = {
|
||||
isEdgeless: boolean;
|
||||
title: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type GetPageInfoById = (id: string) => PageInfo | undefined;
|
||||
94
packages/common/env/src/ua-helper.ts
vendored
Normal file
94
packages/common/env/src/ua-helper.ts
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
export class UaHelper {
|
||||
private readonly uaMap;
|
||||
public isLinux = false;
|
||||
public isMacOs = false;
|
||||
public isSafari = false;
|
||||
public isWindows = false;
|
||||
public isFireFox = false;
|
||||
public isMobile = false;
|
||||
public isChrome = false;
|
||||
public isIOS = false;
|
||||
public isStandalone = false;
|
||||
|
||||
getChromeVersion = (): number => {
|
||||
let raw = this.navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
|
||||
if (!raw) {
|
||||
raw = this.navigator.userAgent.match(/(CriOS)\/([0-9]+)/);
|
||||
}
|
||||
if (!raw) {
|
||||
console.error('Cannot get chrome version');
|
||||
return 0;
|
||||
}
|
||||
return parseInt(raw[2] ?? '', 10);
|
||||
};
|
||||
|
||||
constructor(private readonly navigator: Navigator) {
|
||||
this.uaMap = getUa(navigator);
|
||||
this.initUaFlags();
|
||||
}
|
||||
|
||||
public checkUseragent(isUseragent: keyof ReturnType<typeof getUa>) {
|
||||
return Boolean(this.uaMap[isUseragent]);
|
||||
}
|
||||
|
||||
private isStandaloneMode() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
if ('standalone' in window.navigator) {
|
||||
return !!window.navigator.standalone;
|
||||
}
|
||||
return !!window.matchMedia('(display-mode: standalone)').matches;
|
||||
}
|
||||
|
||||
private initUaFlags() {
|
||||
this.isLinux = this.checkUseragent('linux');
|
||||
this.isMacOs = this.checkUseragent('mac');
|
||||
this.isSafari = this.checkUseragent('safari');
|
||||
this.isWindows = this.checkUseragent('win');
|
||||
this.isFireFox = this.checkUseragent('firefox');
|
||||
this.isMobile = this.checkUseragent('mobile');
|
||||
this.isChrome = this.checkUseragent('chrome');
|
||||
this.isIOS = this.checkUseragent('ios');
|
||||
this.isStandalone = this.isStandaloneMode();
|
||||
}
|
||||
}
|
||||
|
||||
const getUa = (navigator: Navigator) => {
|
||||
const ua = navigator.userAgent;
|
||||
const uas = ua.toLowerCase();
|
||||
const mobile = /iPhone|iPad|iPod|Android/i.test(ua);
|
||||
const android =
|
||||
(mobile && (uas.indexOf('android') > -1 || uas.indexOf('linux') > -1)) ||
|
||||
uas.indexOf('adr') > -1;
|
||||
const ios = mobile && !android && /Mac OS/i.test(ua);
|
||||
const mac = !mobile && /Mac OS/i.test(ua);
|
||||
const iphone = ios && uas.indexOf('iphone') > -1;
|
||||
const ipad = ios && !iphone;
|
||||
const wx = /MicroMessenger/i.test(ua);
|
||||
const chrome = /CriOS/i.test(ua) || /Chrome/i.test(ua);
|
||||
const tiktok = mobile && /aweme/i.test(ua);
|
||||
const weibo = mobile && /Weibo/i.test(ua);
|
||||
const safari =
|
||||
!chrome && !wx && !weibo && !tiktok && /Safari|Macintosh/i.test(ua);
|
||||
const firefox = /Firefox/.test(ua);
|
||||
const win = /windows|win32|win64|wow32|wow64/.test(uas);
|
||||
const linux = /linux/.test(uas);
|
||||
return {
|
||||
ua,
|
||||
mobile,
|
||||
android,
|
||||
ios,
|
||||
mac,
|
||||
wx,
|
||||
chrome,
|
||||
iphone,
|
||||
ipad,
|
||||
safari,
|
||||
tiktok,
|
||||
weibo,
|
||||
win,
|
||||
linux,
|
||||
firefox,
|
||||
};
|
||||
};
|
||||
8
packages/common/env/src/worker.ts
vendored
Normal file
8
packages/common/env/src/worker.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export function getWorkerUrl(name: string) {
|
||||
return (
|
||||
// NOTE: worker can not use publicPath because it must obey the same-origin policy
|
||||
(environment.subPath || '/') +
|
||||
'js/' +
|
||||
`${name}-${BUILD_CONFIG.appVersion}.worker.js`
|
||||
);
|
||||
}
|
||||
11
packages/common/env/tsconfig.json
vendored
Normal file
11
packages/common/env/tsconfig.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.web.json",
|
||||
"compilerOptions": {
|
||||
"types": ["build-config", "affine__env"],
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": []
|
||||
}
|
||||
3
packages/common/error/README.md
Normal file
3
packages/common/error/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# @affine/error
|
||||
|
||||
AFFiNE error handler utilities
|
||||
16
packages/common/error/package.json
Normal file
16
packages/common/error/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@affine/error",
|
||||
"version": "0.27.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@affine/graphql": "workspace:*",
|
||||
"graphql": "^16.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
102
packages/common/error/src/__tests__/index.spec.ts
Normal file
102
packages/common/error/src/__tests__/index.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GraphQLError, UserFriendlyError } from '..';
|
||||
|
||||
describe('user friendly error', () => {
|
||||
it('should create from graphql error', () => {
|
||||
const gqlError = new GraphQLError('test', {
|
||||
extensions: {
|
||||
status: 400,
|
||||
code: 'BAD_REQUEST',
|
||||
type: 'BAD_REQUEST',
|
||||
name: 'SOME_ERROR_NAME',
|
||||
message: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
const error = UserFriendlyError.fromAny(gqlError);
|
||||
|
||||
expect(error.name).toBe('SOME_ERROR_NAME');
|
||||
expect(error.status).toBe(400);
|
||||
expect(error.code).toBe('BAD_REQUEST');
|
||||
expect(error.type).toBe('BAD_REQUEST');
|
||||
expect(error.message).toBe('test');
|
||||
});
|
||||
|
||||
it('should create from any error', () => {
|
||||
const error = UserFriendlyError.fromAny(new Error('test'));
|
||||
expect(error.message).toBe('test');
|
||||
});
|
||||
|
||||
it('should create from object', () => {
|
||||
const error = UserFriendlyError.fromAny({
|
||||
name: 'SOME_ERROR_NAME',
|
||||
status: 400,
|
||||
code: 'BAD_REQUEST',
|
||||
type: 'BAD_REQUEST',
|
||||
message: 'test',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('test');
|
||||
});
|
||||
|
||||
it('should create from string', () => {
|
||||
const error = UserFriendlyError.fromAny('test error');
|
||||
expect(error.message).toBe('test error');
|
||||
});
|
||||
|
||||
it('should create fallback error', () => {
|
||||
const error = UserFriendlyError.fromAny(null);
|
||||
|
||||
expect(error.message).toBe(
|
||||
'Unhandled error raised. Please contact us for help.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should test network error', () => {
|
||||
const error = UserFriendlyError.fromAny({
|
||||
name: 'NETWORK_ERROR',
|
||||
status: 500,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
type: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'test',
|
||||
});
|
||||
|
||||
expect(error.isNetworkError()).toBe(true);
|
||||
|
||||
const error2 = UserFriendlyError.fromAny({
|
||||
name: 'SOME_ERROR_NAME',
|
||||
status: 400,
|
||||
code: 'BAD_REQUEST',
|
||||
type: 'BAD_REQUEST',
|
||||
message: 'test',
|
||||
});
|
||||
|
||||
expect(error2.isNetworkError()).toBe(false);
|
||||
});
|
||||
|
||||
it('should test name', () => {
|
||||
const error = UserFriendlyError.fromAny({
|
||||
name: 'SOME_ERROR_NAME',
|
||||
status: 400,
|
||||
code: 'BAD_REQUEST',
|
||||
type: 'BAD_REQUEST',
|
||||
message: 'test',
|
||||
});
|
||||
|
||||
// @ts-expect-error test name
|
||||
expect(error.is('SOME_ERROR_NAME')).toBe(true);
|
||||
});
|
||||
|
||||
it('should test status', () => {
|
||||
const error = UserFriendlyError.fromAny({
|
||||
name: 'SOME_ERROR_NAME',
|
||||
status: 400,
|
||||
code: 'BAD_REQUEST',
|
||||
type: 'BAD_REQUEST',
|
||||
message: 'test',
|
||||
});
|
||||
|
||||
expect(error.isStatus(400)).toBe(true);
|
||||
});
|
||||
});
|
||||
110
packages/common/error/src/index.ts
Normal file
110
packages/common/error/src/index.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { ErrorDataUnion, ErrorNames } from '@affine/graphql';
|
||||
import { GraphQLError as BaseGraphQLError } from 'graphql';
|
||||
|
||||
export type ErrorName =
|
||||
| keyof typeof ErrorNames
|
||||
| 'NETWORK_ERROR'
|
||||
| 'CONTENT_TOO_LARGE'
|
||||
| 'REQUEST_ABORTED'
|
||||
| 'UNSUPPORTED_SERVER_VERSION';
|
||||
|
||||
export interface UserFriendlyErrorResponse {
|
||||
status: number;
|
||||
code: string;
|
||||
type: string;
|
||||
name: ErrorName;
|
||||
message: string;
|
||||
data?: any;
|
||||
stacktrace?: string;
|
||||
}
|
||||
|
||||
function UnknownError(message: string) {
|
||||
return new UserFriendlyError({
|
||||
status: 500,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
type: 'INTERNAL_SERVER_ERROR',
|
||||
name: 'INTERNAL_SERVER_ERROR',
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
type ToPascalCase<S extends string> = S extends `${infer A}_${infer B}`
|
||||
? `${Capitalize<Lowercase<A>>}${ToPascalCase<B>}`
|
||||
: Capitalize<Lowercase<S>>;
|
||||
|
||||
export type ErrorData = {
|
||||
[K in ErrorNames]: Extract<
|
||||
ErrorDataUnion,
|
||||
{ __typename?: `${ToPascalCase<K>}DataType` }
|
||||
>;
|
||||
};
|
||||
|
||||
export class GraphQLError extends BaseGraphQLError {
|
||||
// @ts-expect-error better to be a known type without any type casting
|
||||
override extensions!: UserFriendlyErrorResponse;
|
||||
}
|
||||
|
||||
export class UserFriendlyError
|
||||
extends Error
|
||||
implements UserFriendlyErrorResponse
|
||||
{
|
||||
readonly status = this.response.status;
|
||||
readonly code = this.response.code;
|
||||
readonly type = this.response.type;
|
||||
override readonly name = this.response.name;
|
||||
override readonly message = this.response.message;
|
||||
readonly data = this.response.data;
|
||||
readonly stacktrace = this.response.stacktrace;
|
||||
|
||||
static fromAny(anything: any) {
|
||||
if (anything instanceof UserFriendlyError) {
|
||||
return anything;
|
||||
}
|
||||
|
||||
switch (typeof anything) {
|
||||
case 'string':
|
||||
return UnknownError(anything);
|
||||
case 'object': {
|
||||
if (anything) {
|
||||
if (anything instanceof GraphQLError) {
|
||||
return new UserFriendlyError(anything.extensions);
|
||||
} else if (anything.type && anything.name && anything.message) {
|
||||
return new UserFriendlyError(anything);
|
||||
} else if (anything.message) {
|
||||
return UnknownError(anything.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return UnknownError('Unhandled error raised. Please contact us for help.');
|
||||
}
|
||||
|
||||
constructor(private readonly response: UserFriendlyErrorResponse) {
|
||||
super(response.message);
|
||||
}
|
||||
|
||||
is(name: ErrorName) {
|
||||
return this.name === name;
|
||||
}
|
||||
|
||||
isStatus(status: number) {
|
||||
return this.status === status;
|
||||
}
|
||||
|
||||
static isNetworkError(error: UserFriendlyError) {
|
||||
return error.name === 'NETWORK_ERROR';
|
||||
}
|
||||
|
||||
static notNetworkError(error: UserFriendlyError) {
|
||||
return !UserFriendlyError.isNetworkError(error);
|
||||
}
|
||||
|
||||
isNetworkError() {
|
||||
return UserFriendlyError.isNetworkError(this);
|
||||
}
|
||||
|
||||
notNetworkError() {
|
||||
return UserFriendlyError.notNetworkError(this);
|
||||
}
|
||||
}
|
||||
10
packages/common/error/tsconfig.json
Normal file
10
packages/common/error/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.web.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": []
|
||||
}
|
||||
3
packages/common/graphql/README.md
Normal file
3
packages/common/graphql/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `GraphQL` client
|
||||
|
||||
Auto generated `GraphQL` client for affine.pro
|
||||
34
packages/common/graphql/codegen.yml
Normal file
34
packages/common/graphql/codegen.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
hooks:
|
||||
afterOneFileWrite:
|
||||
- oxfmt --write
|
||||
config:
|
||||
strict: true
|
||||
maybeValue: T | null
|
||||
declarationKind: interface
|
||||
avoidOptionals:
|
||||
field: true
|
||||
inputValue: false
|
||||
object: false
|
||||
defaultValue: false
|
||||
preResolveTypes: true
|
||||
namingConvention:
|
||||
enumValues: keep
|
||||
scalars:
|
||||
DateTime: string
|
||||
Date: string
|
||||
Decimal: number
|
||||
UUID: string
|
||||
ID: string
|
||||
JSON: Record<string, string>
|
||||
Upload: File
|
||||
SafeInt: number
|
||||
overwrite: true
|
||||
schema: ../../backend/server/src/schema.gql
|
||||
documents: ./src/**/*.gql
|
||||
generates:
|
||||
./src/schema.ts:
|
||||
plugins:
|
||||
- typescript
|
||||
- typescript-operations
|
||||
- ./export-gql-plugin.cjs:
|
||||
output: ./src/graphql/index.ts
|
||||
305
packages/common/graphql/export-gql-plugin.cjs
Normal file
305
packages/common/graphql/export-gql-plugin.cjs
Normal file
@@ -0,0 +1,305 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { Kind, print, visit, TypeInfo, visitWithTypeInfo } = require('graphql');
|
||||
const { upperFirst, lowerFirst } = require('lodash');
|
||||
|
||||
/**
|
||||
* return exported name used in runtime.
|
||||
*
|
||||
* @param {import('graphql').ExecutableDefinitionNode} def
|
||||
* @returns {string}
|
||||
*/
|
||||
function getExportedName(def) {
|
||||
const name = lowerFirst(def.name?.value);
|
||||
const suffix =
|
||||
def.kind === Kind.OPERATION_DEFINITION
|
||||
? upperFirst(def.operation)
|
||||
: 'Fragment';
|
||||
return name.endsWith(suffix) ? name : name + suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a field is deprecated in the schema
|
||||
*
|
||||
* @param {import('graphql').GraphQLSchema} schema
|
||||
* @param {string} typeName
|
||||
* @param {string} fieldName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function fieldDeprecation(schema, typeName, fieldName) {
|
||||
const type = schema.getType(typeName);
|
||||
if (!type || !type.getFields) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fields = type.getFields();
|
||||
const field = fields[fieldName];
|
||||
|
||||
return field?.deprecationReason
|
||||
? {
|
||||
name: fieldName,
|
||||
reason: field.deprecationReason,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a query uses deprecated fields
|
||||
*
|
||||
* @param {import('graphql').GraphQLSchema} schema
|
||||
* @param {import('graphql').DocumentNode} document
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function parseDeprecations(schema, document) {
|
||||
const deprecations = [];
|
||||
|
||||
const typeInfo = new TypeInfo(schema);
|
||||
|
||||
visit(
|
||||
document,
|
||||
visitWithTypeInfo(typeInfo, {
|
||||
Field: {
|
||||
enter(node) {
|
||||
const parentType = typeInfo.getParentType();
|
||||
if (parentType && node.name) {
|
||||
const fieldName = node.name.value;
|
||||
let deprecation;
|
||||
if (
|
||||
parentType.name &&
|
||||
(deprecation = fieldDeprecation(
|
||||
schema,
|
||||
parentType.name,
|
||||
fieldName
|
||||
))
|
||||
) {
|
||||
deprecations.push(deprecation);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return deprecations.map(
|
||||
({ name, reason }) => `'${name}' is deprecated: ${reason}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('@graphql-codegen/plugin-helpers').CodegenPlugin}
|
||||
*/
|
||||
module.exports = {
|
||||
plugin: (schema, documents, { output }) => {
|
||||
const defs = new Map();
|
||||
const queries = [];
|
||||
const mutations = [];
|
||||
|
||||
const nameLocationMap = new Map();
|
||||
const locationSourceMap = new Map(
|
||||
documents
|
||||
.filter(source => !!source.location)
|
||||
.map(source => [source.location, source])
|
||||
);
|
||||
|
||||
function addDef(exportedName, location) {
|
||||
if (nameLocationMap.has(exportedName)) {
|
||||
throw new Error(
|
||||
`name ${exportedName} export from ${location} are duplicated.`
|
||||
);
|
||||
}
|
||||
|
||||
nameLocationMap.set(exportedName, location);
|
||||
}
|
||||
|
||||
function parseImports(location) {
|
||||
if (!location) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// parse '#import' lines
|
||||
const importedDefinitions = [];
|
||||
fs.readFileSync(location, 'utf-8')
|
||||
.split(/\r\n|\r|\n/)
|
||||
.forEach(line => {
|
||||
if (line[0] === '#') {
|
||||
const [importKeyword, importPath] = line.split(' ').filter(Boolean);
|
||||
if (importKeyword === '#import') {
|
||||
const realImportPath = path.posix.join(
|
||||
location,
|
||||
'..',
|
||||
importPath.replace(/["']/g, '')
|
||||
);
|
||||
const imports =
|
||||
locationSourceMap.get(realImportPath)?.document.definitions;
|
||||
if (imports) {
|
||||
importedDefinitions.push(...imports);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return importedDefinitions
|
||||
.map(def => `\${${getExportedName(def)}}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
for (const [location, source] of locationSourceMap) {
|
||||
if (!source || !source.document || !source.rawSDL) {
|
||||
return;
|
||||
}
|
||||
|
||||
visit(source.document, {
|
||||
[Kind.OPERATION_DEFINITION]: {
|
||||
enter: node => {
|
||||
if (!node.name) {
|
||||
throw new Error(
|
||||
`Anonymous operation definition found in ${location}.`
|
||||
);
|
||||
}
|
||||
|
||||
const exportedName = getExportedName(node);
|
||||
addDef(exportedName, location);
|
||||
|
||||
// parse 'file' fields
|
||||
const containsFile = node.variableDefinitions.some(def => {
|
||||
const varType =
|
||||
def.type.kind === 'NamedType'
|
||||
? def.type.name.value
|
||||
: def?.type?.type?.name?.value;
|
||||
const checkContainFile = type => {
|
||||
if (schema.getType(type)?.name === 'Upload') return true;
|
||||
const typeDef = schema.getType(type);
|
||||
const fields = typeDef.getFields?.();
|
||||
if (!fields || typeof fields !== 'object') return false;
|
||||
for (let field of Object.values(fields)) {
|
||||
let type = field.type;
|
||||
while (type.ofType) {
|
||||
type = type.ofType;
|
||||
}
|
||||
if (type.name === 'Upload') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return varType ? checkContainFile(varType) : false;
|
||||
});
|
||||
|
||||
// Check if the query uses deprecated fields
|
||||
const deprecations = parseDeprecations(schema, source.document);
|
||||
|
||||
const imports = parseImports(location);
|
||||
|
||||
defs.set(exportedName, {
|
||||
type: node.operation,
|
||||
name: exportedName,
|
||||
operationName: node.name.value,
|
||||
containsFile,
|
||||
deprecations,
|
||||
query: `${print(node)}${imports ? `\n${imports}` : ''}`,
|
||||
});
|
||||
|
||||
if (node.operation === 'query') {
|
||||
queries.push(exportedName);
|
||||
} else if (node.operation === 'mutation') {
|
||||
mutations.push(exportedName);
|
||||
}
|
||||
},
|
||||
},
|
||||
[Kind.FRAGMENT_DEFINITION]: {
|
||||
enter: node => {
|
||||
const exportedName = getExportedName(node);
|
||||
addDef(exportedName, location);
|
||||
|
||||
const imports = parseImports(location);
|
||||
|
||||
defs.set(exportedName, {
|
||||
type: 'fragment',
|
||||
name: exportedName,
|
||||
content: `${print(node)}${imports || ''}`,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const preludes = [
|
||||
'/* do not manipulate this file manually. */',
|
||||
`export interface GraphQLQuery {
|
||||
id: string;
|
||||
op: string;
|
||||
query: string;
|
||||
file?: boolean;
|
||||
deprecations?: string[];
|
||||
}`,
|
||||
];
|
||||
|
||||
const operations = [];
|
||||
|
||||
defs.forEach(def => {
|
||||
if (def.type === 'fragment') {
|
||||
preludes.push(`export const ${def.name} = \`${def.content}\`;`);
|
||||
} else {
|
||||
let item = `export const ${def.name} = {
|
||||
id: '${def.name}' as const,
|
||||
op: '${def.operationName}',
|
||||
query: \`${def.query}\`,
|
||||
`;
|
||||
if (def.containsFile) {
|
||||
item += ' file: true,\n';
|
||||
}
|
||||
if (def.deprecations.length) {
|
||||
item += ` deprecations: ${JSON.stringify(def.deprecations)},\n`;
|
||||
}
|
||||
item += '};\n';
|
||||
|
||||
operations.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
output,
|
||||
preludes.join('\n') + '\n' + operations.join('\n')
|
||||
);
|
||||
|
||||
const queriesUnion = queries
|
||||
.map(query => {
|
||||
const queryName = upperFirst(query);
|
||||
return `{
|
||||
name: '${query}',
|
||||
variables: ${queryName}Variables,
|
||||
response: ${queryName}
|
||||
}
|
||||
`;
|
||||
})
|
||||
.join('|');
|
||||
|
||||
const mutationsUnion = mutations
|
||||
.map(query => {
|
||||
const queryName = upperFirst(query);
|
||||
return `{
|
||||
name: '${query}',
|
||||
variables: ${queryName}Variables,
|
||||
response: ${queryName}
|
||||
}
|
||||
`;
|
||||
})
|
||||
.join('|');
|
||||
const queryTypes = queriesUnion
|
||||
? `export type Queries = ${queriesUnion}`
|
||||
: '';
|
||||
const mutationsTypes = mutationsUnion
|
||||
? `export type Mutations = ${mutationsUnion}`
|
||||
: '';
|
||||
return `
|
||||
${queryTypes}
|
||||
${mutationsTypes}
|
||||
`;
|
||||
},
|
||||
validate: (_schema, _documents, { output }) => {
|
||||
if (!output) {
|
||||
throw new Error('Export plugin must be used with a output file given');
|
||||
}
|
||||
},
|
||||
};
|
||||
32
packages/common/graphql/package.json
Normal file
32
packages/common/graphql/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@affine/graphql",
|
||||
"version": "0.27.5",
|
||||
"description": "Autogenerated GraphQL client for affine.pro",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^6.2.1",
|
||||
"@graphql-codegen/typescript": "^5.0.9",
|
||||
"@graphql-codegen/typescript-operations": "^5.0.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"oxfmt": "0.63.0",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "gql-gen --errors-only"
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine/debug": "workspace:*",
|
||||
"@affine/error": "workspace:*",
|
||||
"graphql": "^16.9.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.17.23"
|
||||
}
|
||||
}
|
||||
107
packages/common/graphql/src/__tests__/fetcher.spec.ts
Normal file
107
packages/common/graphql/src/__tests__/fetcher.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Mock } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { gqlFetcherFactory } from '../fetcher';
|
||||
import type { GraphQLQuery } from '../graphql';
|
||||
|
||||
const query: GraphQLQuery = {
|
||||
id: 'query',
|
||||
query: 'query { field }',
|
||||
op: 'query',
|
||||
};
|
||||
|
||||
let fetch: Mock;
|
||||
let gql: ReturnType<typeof gqlFetcherFactory>;
|
||||
describe('GraphQL fetcher', () => {
|
||||
beforeEach(() => {
|
||||
fetch = vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ data: { field: 1 } }), {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
gql = gqlFetcherFactory('https://example.com/graphql', fetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetch.mockReset();
|
||||
});
|
||||
|
||||
it('should send POST request to given endpoint', async () => {
|
||||
await gql(
|
||||
// @ts-expect-error variables is actually optional
|
||||
{ query }
|
||||
);
|
||||
|
||||
expect(fetch).toBeCalledTimes(1);
|
||||
expect(fetch.mock.lastCall?.[0]).toBe('https://example.com/graphql');
|
||||
const ctx = fetch.mock.lastCall?.[1] as RequestInit;
|
||||
expect(ctx.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('should send with correct graphql JSON body', async () => {
|
||||
await gql({
|
||||
query,
|
||||
// @ts-expect-error forgive the fake variables
|
||||
variables: { a: 1, b: '2', c: { d: false } },
|
||||
});
|
||||
|
||||
expect(fetch.mock.lastCall?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
body: '{"query":"query { field }","variables":{"a":1,"b":"2","c":{"d":false}},"operationName":"query"}',
|
||||
headers: expect.objectContaining({
|
||||
'content-type': 'application/json',
|
||||
'x-operation-name': 'query',
|
||||
}),
|
||||
method: 'POST',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly ignore nil variables', async () => {
|
||||
await gql({
|
||||
query,
|
||||
// @ts-expect-error forgive the fake variables
|
||||
variables: { a: false, b: null, c: undefined },
|
||||
});
|
||||
|
||||
expect(fetch.mock.lastCall?.[1].body).toMatchInlineSnapshot(
|
||||
`"{"query":"query { field }","variables":{"a":false,"b":null},"operationName":"query"}"`
|
||||
);
|
||||
|
||||
await gql({
|
||||
query,
|
||||
// @ts-expect-error forgive the fake variables
|
||||
variables: { a: false, b: null, c: undefined },
|
||||
keepNilVariables: false,
|
||||
});
|
||||
|
||||
expect(fetch.mock.lastCall?.[1].body).toMatchInlineSnapshot(
|
||||
`"{"query":"query { field }","variables":{"a":false},"operationName":"query"}"`
|
||||
);
|
||||
});
|
||||
|
||||
it('should correct handle graphql error', async () => {
|
||||
fetch.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: null,
|
||||
errors: [{ message: 'error', path: ['field'] }],
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
status: 400,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
gql({ query, variables: void 0 })
|
||||
).rejects.toMatchInlineSnapshot(`[GraphQLError: error]`);
|
||||
});
|
||||
});
|
||||
242
packages/common/graphql/src/fetcher.ts
Normal file
242
packages/common/graphql/src/fetcher.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { GraphQLError } from '@affine/error';
|
||||
import type { ExecutionResult } from 'graphql';
|
||||
import { isNil, isObject, merge } from 'lodash-es';
|
||||
|
||||
import type { GraphQLQuery } from './graphql';
|
||||
import type { Mutations, Queries } from './schema';
|
||||
|
||||
export type NotArray<T> = T extends Array<unknown> ? never : T;
|
||||
|
||||
export type FetchInit = RequestInit & { timeout?: number };
|
||||
|
||||
export type _QueryVariables<Q extends GraphQLQuery> =
|
||||
Q['id'] extends Queries['name']
|
||||
? Extract<Queries, { name: Q['id'] }>['variables']
|
||||
: Q['id'] extends Mutations['name']
|
||||
? Extract<Mutations, { name: Q['id'] }>['variables']
|
||||
: undefined;
|
||||
|
||||
export type QueryVariables<Q extends GraphQLQuery> =
|
||||
_QueryVariables<Q> extends never | Record<string, never>
|
||||
? never
|
||||
: _QueryVariables<Q>;
|
||||
|
||||
export type QueryResponse<Q extends GraphQLQuery> = Extract<
|
||||
Queries | Mutations,
|
||||
{ name: Q['id'] }
|
||||
>['response'];
|
||||
|
||||
type NullableKeys<T> = {
|
||||
[K in keyof T]: null extends T[K] ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
type NonNullableKeys<T> = {
|
||||
[K in keyof T]: null extends T[K] ? never : K;
|
||||
}[keyof T];
|
||||
|
||||
export type RecursiveMaybeFields<T> = T extends
|
||||
| number
|
||||
| boolean
|
||||
| string
|
||||
| null
|
||||
| undefined
|
||||
? T
|
||||
: {
|
||||
[K in NullableKeys<T>]?: RecursiveMaybeFields<T[K]>;
|
||||
} & {
|
||||
[K in NonNullableKeys<T>]: RecursiveMaybeFields<T[K]>;
|
||||
};
|
||||
|
||||
type AllowedRequestContext = Omit<RequestInit, 'method' | 'body'>;
|
||||
|
||||
export interface RequestBody {
|
||||
operationName?: string;
|
||||
variables: any;
|
||||
query: string;
|
||||
form?: FormData;
|
||||
}
|
||||
|
||||
type QueryVariablesOption<Q extends GraphQLQuery> =
|
||||
QueryVariables<Q> extends never
|
||||
? {
|
||||
variables?: undefined;
|
||||
}
|
||||
: { variables: RecursiveMaybeFields<QueryVariables<Q>> };
|
||||
|
||||
export type RequestOptions<Q extends GraphQLQuery> = QueryVariablesOption<Q> & {
|
||||
/**
|
||||
* parameter passed to `fetch` function
|
||||
*/
|
||||
context?: AllowedRequestContext;
|
||||
/**
|
||||
* Whether keep null or undefined value in variables.
|
||||
*
|
||||
* if `false` given, `{ a: 0, b: undefined, c: null }` will be converted to `{ a: 0 }`
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
keepNilVariables?: boolean;
|
||||
/**
|
||||
* Request timeout in milliseconds
|
||||
* @default 15000
|
||||
*/
|
||||
timeout?: number;
|
||||
/**
|
||||
* Abort signal
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type QueryOptions<Q extends GraphQLQuery> = RequestOptions<Q> & {
|
||||
query: Q;
|
||||
};
|
||||
export type MutationOptions<M extends GraphQLQuery> = RequestOptions<M> & {
|
||||
mutation: M;
|
||||
};
|
||||
|
||||
function filterEmptyValue(vars: any) {
|
||||
const newVars: Record<string, any> = {};
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (isNil(value)) {
|
||||
return;
|
||||
}
|
||||
if (isObject(value) && !(value instanceof File)) {
|
||||
newVars[key] = filterEmptyValue(value);
|
||||
return;
|
||||
}
|
||||
newVars[key] = value;
|
||||
});
|
||||
|
||||
return newVars;
|
||||
}
|
||||
|
||||
export function transformToForm(body: RequestBody) {
|
||||
const form = new FormData();
|
||||
const gqlBody: {
|
||||
name?: string;
|
||||
query: string;
|
||||
variables: any;
|
||||
map: any;
|
||||
} = {
|
||||
query: body.query,
|
||||
variables: body.variables,
|
||||
map: {},
|
||||
};
|
||||
|
||||
if (body.operationName) {
|
||||
gqlBody.name = body.operationName;
|
||||
}
|
||||
const map: Record<string, string[]> = {};
|
||||
const files: File[] = [];
|
||||
if (body.variables) {
|
||||
let i = 0;
|
||||
const buildMap = (key: string, value: any) => {
|
||||
if (value instanceof File) {
|
||||
map['' + i] = [key];
|
||||
files[i] = value;
|
||||
i++;
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach((v, index) => {
|
||||
buildMap(`${key}.${index}`, v);
|
||||
});
|
||||
} else if (isObject(value)) {
|
||||
Object.entries(value).forEach(([k, v]) => {
|
||||
buildMap(`${key}.${k}`, v);
|
||||
});
|
||||
}
|
||||
};
|
||||
buildMap('variables', body.variables);
|
||||
}
|
||||
|
||||
form.set('operations', JSON.stringify(gqlBody));
|
||||
form.set('map', JSON.stringify(map));
|
||||
for (const [i, file] of files.entries()) {
|
||||
form.set(`${i}`, file);
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
function formatRequestBody<Q extends GraphQLQuery>({
|
||||
query,
|
||||
variables,
|
||||
keepNilVariables,
|
||||
}: QueryOptions<Q>): RequestBody | FormData {
|
||||
const body: RequestBody = {
|
||||
query: query.query,
|
||||
variables:
|
||||
(keepNilVariables ?? true) ? variables : filterEmptyValue(variables),
|
||||
};
|
||||
|
||||
if (query.op) {
|
||||
body.operationName = query.op;
|
||||
}
|
||||
|
||||
if (query.file) {
|
||||
return transformToForm(body);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export const gqlFetcherFactory = (
|
||||
endpoint: string,
|
||||
fetcher: (input: string, init?: FetchInit) => Promise<Response> = fetch
|
||||
) => {
|
||||
const logger = new DebugLogger('GraphQL');
|
||||
const gqlFetch = async <Query extends GraphQLQuery>(
|
||||
options: QueryOptions<Query>
|
||||
): Promise<QueryResponse<Query>> => {
|
||||
if (
|
||||
BUILD_CONFIG.appBuildType === 'canary' &&
|
||||
options.query.deprecations?.length
|
||||
) {
|
||||
options.query.deprecations.forEach(deprecation => {
|
||||
logger.warn(deprecation);
|
||||
});
|
||||
}
|
||||
|
||||
const body = formatRequestBody(options);
|
||||
|
||||
const isFormData = body instanceof FormData;
|
||||
const headers: Record<string, string> = {
|
||||
'x-operation-name': options.query.op,
|
||||
};
|
||||
if (!isFormData) {
|
||||
headers['content-type'] = 'application/json';
|
||||
}
|
||||
const ret = fetcher(
|
||||
endpoint,
|
||||
merge(options.context, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: isFormData ? body : JSON.stringify(body),
|
||||
timeout: options.timeout,
|
||||
signal: options.signal,
|
||||
})
|
||||
).then(async res => {
|
||||
if (res.headers.get('content-type')?.startsWith('application/json')) {
|
||||
const result = (await res.json()) as ExecutionResult;
|
||||
if (res.status >= 400 || result.errors) {
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
// throw the first error is enough
|
||||
const firstError = result.errors[0];
|
||||
throw new GraphQLError(firstError.message, firstError);
|
||||
} else {
|
||||
throw new GraphQLError('Empty GraphQL error body');
|
||||
}
|
||||
} else if (result.data) {
|
||||
// we have to cast here because the type of result.data is a union type
|
||||
return result.data as any;
|
||||
}
|
||||
}
|
||||
|
||||
throw new GraphQLError(
|
||||
'GraphQL query responds unexpected result, query ' + options.query.op
|
||||
);
|
||||
});
|
||||
|
||||
return ret;
|
||||
};
|
||||
|
||||
return gqlFetch;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
query adminAllSharedLinks(
|
||||
$pagination: PaginationInput!
|
||||
$filter: AdminAllSharedLinksFilterInput
|
||||
) {
|
||||
adminAllSharedLinks(pagination: $pagination, filter: $filter) {
|
||||
totalCount
|
||||
analyticsWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
workspaceId
|
||||
docId
|
||||
title
|
||||
publishedAt
|
||||
docUpdatedAt
|
||||
workspaceOwnerId
|
||||
lastUpdaterId
|
||||
shareUrl
|
||||
views
|
||||
uniqueViews
|
||||
guestViews
|
||||
lastAccessedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
query adminDashboard($input: AdminDashboardInput) {
|
||||
adminDashboard(input: $input) {
|
||||
syncActiveUsers
|
||||
syncActiveUsersTimeline {
|
||||
minute
|
||||
activeUsers
|
||||
}
|
||||
syncWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
copilotConversations
|
||||
copilotWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
workspaceStorageBytes
|
||||
blobStorageBytes
|
||||
workspaceStorageHistory {
|
||||
date
|
||||
value
|
||||
}
|
||||
blobStorageHistory {
|
||||
date
|
||||
value
|
||||
}
|
||||
storageWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
topSharedLinks {
|
||||
workspaceId
|
||||
docId
|
||||
title
|
||||
shareUrl
|
||||
publishedAt
|
||||
views
|
||||
uniqueViews
|
||||
guestViews
|
||||
lastAccessedAt
|
||||
}
|
||||
topSharedLinksWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
generatedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
query adminMailDeliveries($input: AdminMailDeliveriesInput) {
|
||||
adminMailDeliveries(input: $input) {
|
||||
window {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
summary {
|
||||
total
|
||||
sent
|
||||
failed
|
||||
skipped
|
||||
canceled
|
||||
queued
|
||||
sending
|
||||
retryWait
|
||||
successRate
|
||||
}
|
||||
byStatus {
|
||||
key
|
||||
label
|
||||
total
|
||||
points {
|
||||
bucket
|
||||
count
|
||||
}
|
||||
}
|
||||
byType {
|
||||
key
|
||||
label
|
||||
total
|
||||
points {
|
||||
bucket
|
||||
count
|
||||
}
|
||||
}
|
||||
byOutcome {
|
||||
key
|
||||
label
|
||||
total
|
||||
points {
|
||||
bucket
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#import '../fragments/password-limits.gql'
|
||||
#import '../fragments/credentials-requirement.gql'
|
||||
|
||||
query adminServerConfig {
|
||||
serverConfig {
|
||||
version
|
||||
baseUrl
|
||||
name
|
||||
features
|
||||
type
|
||||
initialized
|
||||
credentialsRequirement {
|
||||
...CredentialsRequirements
|
||||
}
|
||||
availableUpgrade {
|
||||
changelog
|
||||
version
|
||||
publishedAt
|
||||
url
|
||||
}
|
||||
availableUserFeatures
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
mutation adminUpdateWorkspace($input: AdminUpdateWorkspaceInput!) {
|
||||
adminUpdateWorkspace(input: $input) {
|
||||
id
|
||||
public
|
||||
createdAt
|
||||
name
|
||||
avatarKey
|
||||
enableAi
|
||||
enableSharing
|
||||
enableUrlPreview
|
||||
enableDocEmbedding
|
||||
owner {
|
||||
id
|
||||
name
|
||||
email
|
||||
avatarUrl
|
||||
}
|
||||
memberCount
|
||||
publicPageCount
|
||||
snapshotCount
|
||||
snapshotSize
|
||||
blobCount
|
||||
blobSize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
query adminWorkspace(
|
||||
$id: String!
|
||||
$memberSkip: Int
|
||||
$memberTake: Int
|
||||
$memberQuery: String
|
||||
) {
|
||||
adminWorkspace(id: $id) {
|
||||
id
|
||||
public
|
||||
createdAt
|
||||
name
|
||||
avatarKey
|
||||
enableAi
|
||||
enableSharing
|
||||
enableUrlPreview
|
||||
enableDocEmbedding
|
||||
owner {
|
||||
id
|
||||
name
|
||||
email
|
||||
avatarUrl
|
||||
}
|
||||
memberCount
|
||||
publicPageCount
|
||||
snapshotCount
|
||||
snapshotSize
|
||||
blobCount
|
||||
blobSize
|
||||
sharedLinks {
|
||||
docId
|
||||
title
|
||||
publishedAt
|
||||
}
|
||||
members(skip: $memberSkip, take: $memberTake, query: $memberQuery) {
|
||||
id
|
||||
name
|
||||
email
|
||||
avatarUrl
|
||||
role
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
query adminWorkspaces($filter: ListWorkspaceInput!) {
|
||||
adminWorkspaces(filter: $filter) {
|
||||
id
|
||||
public
|
||||
createdAt
|
||||
name
|
||||
avatarKey
|
||||
enableAi
|
||||
enableSharing
|
||||
enableUrlPreview
|
||||
enableDocEmbedding
|
||||
owner {
|
||||
id
|
||||
name
|
||||
email
|
||||
avatarUrl
|
||||
}
|
||||
memberCount
|
||||
publicPageCount
|
||||
snapshotCount
|
||||
snapshotSize
|
||||
blobCount
|
||||
blobSize
|
||||
}
|
||||
}
|
||||
|
||||
query adminWorkspacesCount($filter: ListWorkspaceInput!) {
|
||||
adminWorkspacesCount(filter: $filter)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
query authSigningKeys {
|
||||
authSigningKeys {
|
||||
id
|
||||
status
|
||||
source
|
||||
createdAt
|
||||
retiredAt
|
||||
verifyUntil
|
||||
canDelete
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation createChangePasswordUrl($callbackUrl: String!, $userId: String!) {
|
||||
createChangePasswordUrl(callbackUrl: $callbackUrl, userId: $userId)
|
||||
}
|
||||
3
packages/common/graphql/src/graphql/admin/config.gql
Normal file
3
packages/common/graphql/src/graphql/admin/config.gql
Normal file
@@ -0,0 +1,3 @@
|
||||
query appConfig {
|
||||
appConfig
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation createUser($input: CreateUserInput!) {
|
||||
createUser(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mutation deleteAuthSigningKey($id: String!) {
|
||||
deleteAuthSigningKey(id: $id) {
|
||||
id
|
||||
status
|
||||
source
|
||||
createdAt
|
||||
retiredAt
|
||||
verifyUntil
|
||||
canDelete
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation deleteUser($id: String!) {
|
||||
deleteUser(id: $id) {
|
||||
success
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mutation disableUser($id: String!) {
|
||||
banUser(id: $id) {
|
||||
email
|
||||
disabled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mutation enableUser($id: String!) {
|
||||
enableUser(id: $id) {
|
||||
email
|
||||
disabled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
query getUserByEmail($email: String!) {
|
||||
userByEmail(email: $email) {
|
||||
id
|
||||
name
|
||||
email
|
||||
features
|
||||
hasPassword
|
||||
emailVerified
|
||||
avatarUrl
|
||||
disabled
|
||||
}
|
||||
}
|
||||
14
packages/common/graphql/src/graphql/admin/import-users.gql
Normal file
14
packages/common/graphql/src/graphql/admin/import-users.gql
Normal file
@@ -0,0 +1,14 @@
|
||||
mutation ImportUsers($input: ImportUsersInput!) {
|
||||
importUsers(input: $input) {
|
||||
__typename
|
||||
... on UserType {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
... on UserImportFailedType {
|
||||
email
|
||||
error
|
||||
}
|
||||
}
|
||||
}
|
||||
13
packages/common/graphql/src/graphql/admin/list-users.gql
Normal file
13
packages/common/graphql/src/graphql/admin/list-users.gql
Normal file
@@ -0,0 +1,13 @@
|
||||
query listUsers($filter: ListUserInput!) {
|
||||
users(filter: $filter) {
|
||||
id
|
||||
name
|
||||
email
|
||||
disabled
|
||||
features
|
||||
hasPassword
|
||||
emailVerified
|
||||
avatarUrl
|
||||
}
|
||||
usersCount(filter: $filter)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mutation rotateAuthSigningKey($expectedActiveKeyId: String!) {
|
||||
rotateAuthSigningKey(expectedActiveKeyId: $expectedActiveKeyId) {
|
||||
id
|
||||
status
|
||||
source
|
||||
createdAt
|
||||
retiredAt
|
||||
verifyUntil
|
||||
canDelete
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
mutation sendTestEmail(
|
||||
$name: String!
|
||||
$host: String!
|
||||
$port: Int!
|
||||
$sender: String!
|
||||
$username: String!
|
||||
$password: String!
|
||||
$ignoreTLS: Boolean!
|
||||
) {
|
||||
sendTestEmail(
|
||||
config: {
|
||||
name: $name
|
||||
host: $host
|
||||
port: $port
|
||||
sender: $sender
|
||||
username: $username
|
||||
password: $password
|
||||
ignoreTLS: $ignoreTLS
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation updateAccountFeatures($userId: String!, $features: [FeatureType!]!) {
|
||||
updateUserFeatures(id: $userId, features: $features)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation updateAccount($id: String!, $input: ManageUserInput!) {
|
||||
updateUser(id: $id, input: $input) {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation updateAppConfig($updates: [UpdateAppConfigInput!]!) {
|
||||
updateAppConfig(updates: $updates)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
query validateConfig($updates: [UpdateAppConfigInput!]!) {
|
||||
validateAppConfig(updates: $updates) {
|
||||
module
|
||||
key
|
||||
value
|
||||
valid
|
||||
error
|
||||
}
|
||||
}
|
||||
7
packages/common/graphql/src/graphql/blob-delete.gql
Normal file
7
packages/common/graphql/src/graphql/blob-delete.gql
Normal file
@@ -0,0 +1,7 @@
|
||||
mutation deleteBlob(
|
||||
$workspaceId: String!
|
||||
$key: String!
|
||||
$permanently: Boolean
|
||||
) {
|
||||
deleteBlob(workspaceId: $workspaceId, key: $key, permanently: $permanently)
|
||||
}
|
||||
10
packages/common/graphql/src/graphql/blob-list.gql
Normal file
10
packages/common/graphql/src/graphql/blob-list.gql
Normal file
@@ -0,0 +1,10 @@
|
||||
query listBlobs($workspaceId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
blobs {
|
||||
key
|
||||
size
|
||||
mime
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation releaseDeletedBlobs($workspaceId: String!) {
|
||||
releaseDeletedBlobs(workspaceId: $workspaceId)
|
||||
}
|
||||
3
packages/common/graphql/src/graphql/blob-set.gql
Normal file
3
packages/common/graphql/src/graphql/blob-set.gql
Normal file
@@ -0,0 +1,3 @@
|
||||
mutation setBlob($workspaceId: String!, $blob: Upload!) {
|
||||
setBlob(workspaceId: $workspaceId, blob: $blob)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation abortBlobUpload(
|
||||
$workspaceId: String!
|
||||
$key: String!
|
||||
$uploadId: String!
|
||||
) {
|
||||
abortBlobUpload(workspaceId: $workspaceId, key: $key, uploadId: $uploadId)
|
||||
}
|
||||
13
packages/common/graphql/src/graphql/blob-upload-complete.gql
Normal file
13
packages/common/graphql/src/graphql/blob-upload-complete.gql
Normal file
@@ -0,0 +1,13 @@
|
||||
mutation completeBlobUpload(
|
||||
$workspaceId: String!
|
||||
$key: String!
|
||||
$uploadId: String
|
||||
$parts: [BlobUploadPartInput!]
|
||||
) {
|
||||
completeBlobUpload(
|
||||
workspaceId: $workspaceId
|
||||
key: $key
|
||||
uploadId: $uploadId
|
||||
parts: $parts
|
||||
)
|
||||
}
|
||||
26
packages/common/graphql/src/graphql/blob-upload-create.gql
Normal file
26
packages/common/graphql/src/graphql/blob-upload-create.gql
Normal file
@@ -0,0 +1,26 @@
|
||||
mutation createBlobUpload(
|
||||
$workspaceId: String!
|
||||
$key: String!
|
||||
$size: Int!
|
||||
$mime: String!
|
||||
) {
|
||||
createBlobUpload(
|
||||
workspaceId: $workspaceId
|
||||
key: $key
|
||||
size: $size
|
||||
mime: $mime
|
||||
) {
|
||||
method
|
||||
blobKey
|
||||
alreadyUploaded
|
||||
uploadUrl
|
||||
headers
|
||||
expiresAt
|
||||
uploadId
|
||||
partSize
|
||||
uploadedParts {
|
||||
partNumber
|
||||
etag
|
||||
}
|
||||
}
|
||||
}
|
||||
14
packages/common/graphql/src/graphql/blob-upload-part-url.gql
Normal file
14
packages/common/graphql/src/graphql/blob-upload-part-url.gql
Normal file
@@ -0,0 +1,14 @@
|
||||
query getBlobUploadPartUrl(
|
||||
$workspaceId: String!
|
||||
$key: String!
|
||||
$uploadId: String!
|
||||
$partNumber: Int!
|
||||
) {
|
||||
workspace(id: $workspaceId) {
|
||||
blobUploadPartUrl(key: $key, uploadId: $uploadId, partNumber: $partNumber) {
|
||||
uploadUrl
|
||||
headers
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
query calendarAccounts {
|
||||
currentUser {
|
||||
calendarAccounts {
|
||||
id
|
||||
provider
|
||||
providerAccountId
|
||||
displayName
|
||||
email
|
||||
status
|
||||
lastError
|
||||
refreshIntervalMinutes
|
||||
calendarsCount
|
||||
createdAt
|
||||
updatedAt
|
||||
calendars {
|
||||
id
|
||||
accountId
|
||||
provider
|
||||
externalCalendarId
|
||||
displayName
|
||||
timezone
|
||||
color
|
||||
enabled
|
||||
lastSyncAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
query calendarEvents($workspaceId: String!, $from: DateTime!, $to: DateTime!) {
|
||||
workspace(id: $workspaceId) {
|
||||
calendars {
|
||||
id
|
||||
events(from: $from, to: $to) {
|
||||
id
|
||||
subscriptionId
|
||||
externalEventId
|
||||
recurrenceId
|
||||
status
|
||||
title
|
||||
description
|
||||
location
|
||||
startAtUtc
|
||||
endAtUtc
|
||||
originalTimezone
|
||||
allDay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
query calendarProviders {
|
||||
serverConfig {
|
||||
calendarCalDAVProviders {
|
||||
id
|
||||
label
|
||||
requiresAppPassword
|
||||
docsUrl
|
||||
}
|
||||
calendarProviders
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
mutation linkCalDavAccount($input: LinkCalDAVAccountInput!) {
|
||||
linkCalDAVAccount(input: $input) {
|
||||
id
|
||||
provider
|
||||
providerAccountId
|
||||
displayName
|
||||
email
|
||||
status
|
||||
lastError
|
||||
refreshIntervalMinutes
|
||||
calendarsCount
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation linkCalendarAccount($input: LinkCalendarAccountInput!) {
|
||||
linkCalendarAccount(input: $input)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation unlinkCalendarAccount($accountId: String!) {
|
||||
unlinkCalendarAccount(accountId: $accountId)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
mutation updateCalendarAccount(
|
||||
$accountId: String!
|
||||
$refreshIntervalMinutes: Int!
|
||||
) {
|
||||
updateCalendarAccount(
|
||||
accountId: $accountId
|
||||
refreshIntervalMinutes: $refreshIntervalMinutes
|
||||
) {
|
||||
id
|
||||
provider
|
||||
providerAccountId
|
||||
displayName
|
||||
email
|
||||
status
|
||||
lastError
|
||||
refreshIntervalMinutes
|
||||
calendarsCount
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
mutation updateWorkspaceCalendars($input: UpdateWorkspaceCalendarsInput!) {
|
||||
updateWorkspaceCalendars(input: $input) {
|
||||
id
|
||||
workspaceId
|
||||
createdByUserId
|
||||
displayNameOverride
|
||||
colorOverride
|
||||
enabled
|
||||
items {
|
||||
id
|
||||
subscriptionId
|
||||
sortOrder
|
||||
colorOverride
|
||||
enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
query workspaceCalendars($workspaceId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
calendars {
|
||||
id
|
||||
workspaceId
|
||||
createdByUserId
|
||||
displayNameOverride
|
||||
colorOverride
|
||||
enabled
|
||||
items {
|
||||
id
|
||||
subscriptionId
|
||||
sortOrder
|
||||
colorOverride
|
||||
enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/common/graphql/src/graphql/cancel-subscription.gql
Normal file
11
packages/common/graphql/src/graphql/cancel-subscription.gql
Normal file
@@ -0,0 +1,11 @@
|
||||
mutation cancelSubscription(
|
||||
$plan: SubscriptionPlan = Pro
|
||||
$workspaceId: String
|
||||
) {
|
||||
cancelSubscription(plan: $plan, workspaceId: $workspaceId) {
|
||||
id
|
||||
status
|
||||
nextBillAt
|
||||
canceledAt
|
||||
}
|
||||
}
|
||||
6
packages/common/graphql/src/graphql/change-email.gql
Normal file
6
packages/common/graphql/src/graphql/change-email.gql
Normal file
@@ -0,0 +1,6 @@
|
||||
mutation changeEmail($token: String!, $email: String!) {
|
||||
changeEmail(token: $token, email: $email) {
|
||||
id
|
||||
email
|
||||
}
|
||||
}
|
||||
7
packages/common/graphql/src/graphql/change-password.gql
Normal file
7
packages/common/graphql/src/graphql/change-password.gql
Normal file
@@ -0,0 +1,7 @@
|
||||
mutation changePassword(
|
||||
$token: String!
|
||||
$userId: String!
|
||||
$newPassword: String!
|
||||
) {
|
||||
changePassword(token: $token, userId: $userId, newPassword: $newPassword)
|
||||
}
|
||||
26
packages/common/graphql/src/graphql/comment-change-list.gql
Normal file
26
packages/common/graphql/src/graphql/comment-change-list.gql
Normal file
@@ -0,0 +1,26 @@
|
||||
query listCommentChanges(
|
||||
$workspaceId: String!
|
||||
$docId: String!
|
||||
$pagination: PaginationInput!
|
||||
) {
|
||||
workspace(id: $workspaceId) {
|
||||
commentChanges(docId: $docId, pagination: $pagination) {
|
||||
totalCount
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
action
|
||||
id
|
||||
commentId
|
||||
item
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
startCursor
|
||||
endCursor
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
packages/common/graphql/src/graphql/comment-create.gql
Normal file
26
packages/common/graphql/src/graphql/comment-create.gql
Normal file
@@ -0,0 +1,26 @@
|
||||
mutation createComment($input: CommentCreateInput!) {
|
||||
createComment(input: $input) {
|
||||
id
|
||||
content
|
||||
resolved
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
replies {
|
||||
commentId
|
||||
id
|
||||
content
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/common/graphql/src/graphql/comment-delete.gql
Normal file
3
packages/common/graphql/src/graphql/comment-delete.gql
Normal file
@@ -0,0 +1,3 @@
|
||||
mutation deleteComment($id: String!) {
|
||||
deleteComment(id: $id)
|
||||
}
|
||||
44
packages/common/graphql/src/graphql/comment-list.gql
Normal file
44
packages/common/graphql/src/graphql/comment-list.gql
Normal file
@@ -0,0 +1,44 @@
|
||||
query listComments(
|
||||
$workspaceId: String!
|
||||
$docId: String!
|
||||
$pagination: PaginationInput
|
||||
) {
|
||||
workspace(id: $workspaceId) {
|
||||
comments(docId: $docId, pagination: $pagination) {
|
||||
totalCount
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
content
|
||||
resolved
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
replies {
|
||||
commentId
|
||||
id
|
||||
content
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
startCursor
|
||||
endCursor
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
packages/common/graphql/src/graphql/comment-reply-create.gql
Normal file
14
packages/common/graphql/src/graphql/comment-reply-create.gql
Normal file
@@ -0,0 +1,14 @@
|
||||
mutation createReply($input: ReplyCreateInput!) {
|
||||
createReply(input: $input) {
|
||||
commentId
|
||||
id
|
||||
content
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation deleteReply($id: String!) {
|
||||
deleteReply(id: $id)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation updateReply($input: ReplyUpdateInput!) {
|
||||
updateReply(input: $input)
|
||||
}
|
||||
3
packages/common/graphql/src/graphql/comment-resolve.gql
Normal file
3
packages/common/graphql/src/graphql/comment-resolve.gql
Normal file
@@ -0,0 +1,3 @@
|
||||
mutation resolveComment($input: CommentResolveInput!) {
|
||||
resolveComment(input: $input)
|
||||
}
|
||||
3
packages/common/graphql/src/graphql/comment-update.gql
Normal file
3
packages/common/graphql/src/graphql/comment-update.gql
Normal file
@@ -0,0 +1,3 @@
|
||||
mutation updateComment($input: CommentUpdateInput!) {
|
||||
updateComment(input: $input)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mutation uploadCommentAttachment(
|
||||
$workspaceId: String!
|
||||
$docId: String!
|
||||
$attachment: Upload!
|
||||
) {
|
||||
uploadCommentAttachment(
|
||||
workspaceId: $workspaceId
|
||||
docId: $docId
|
||||
attachment: $attachment
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
query getCopilotHistoryIds(
|
||||
$workspaceId: String!
|
||||
$pagination: PaginationInput!
|
||||
$docId: String
|
||||
$options: QueryChatHistoriesInput
|
||||
) {
|
||||
currentUser {
|
||||
copilot(workspaceId: $workspaceId) {
|
||||
chats(pagination: $pagination, docId: $docId, options: $options) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
sessionId
|
||||
pinned
|
||||
messages {
|
||||
id
|
||||
role
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#import "./fragments/paginated-copilot-chats.gql"
|
||||
|
||||
query getCopilotDocSessions(
|
||||
$workspaceId: String!
|
||||
$docId: String!
|
||||
$pagination: PaginationInput!
|
||||
$options: QueryChatHistoriesInput
|
||||
) {
|
||||
currentUser {
|
||||
copilot(workspaceId: $workspaceId) {
|
||||
chats(pagination: $pagination, docId: $docId, options: $options) {
|
||||
...PaginatedCopilotChats
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#import "./fragments/paginated-copilot-chats.gql"
|
||||
|
||||
query getCopilotPinnedSessions(
|
||||
$workspaceId: String!
|
||||
$docId: String
|
||||
$messageOrder: ChatHistoryOrder
|
||||
$withPrompt: Boolean
|
||||
) {
|
||||
currentUser {
|
||||
copilot(workspaceId: $workspaceId) {
|
||||
chats(
|
||||
pagination: { first: 1 }
|
||||
docId: $docId
|
||||
options: {
|
||||
pinned: true
|
||||
messageOrder: $messageOrder
|
||||
withPrompt: $withPrompt
|
||||
}
|
||||
) {
|
||||
...PaginatedCopilotChats
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#import "./fragments/paginated-copilot-chats.gql"
|
||||
|
||||
query getCopilotWorkspaceSessions(
|
||||
$workspaceId: String!
|
||||
$pagination: PaginationInput!
|
||||
$options: QueryChatHistoriesInput
|
||||
) {
|
||||
currentUser {
|
||||
copilot(workspaceId: $workspaceId) {
|
||||
chats(pagination: $pagination, docId: null, options: $options) {
|
||||
...PaginatedCopilotChats
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
packages/common/graphql/src/graphql/copilot-history-list.gql
Normal file
16
packages/common/graphql/src/graphql/copilot-history-list.gql
Normal file
@@ -0,0 +1,16 @@
|
||||
#import "./fragments/paginated-copilot-chats.gql"
|
||||
|
||||
query getCopilotHistories(
|
||||
$workspaceId: String!
|
||||
$pagination: PaginationInput!
|
||||
$docId: String
|
||||
$options: QueryChatHistoriesInput
|
||||
) {
|
||||
currentUser {
|
||||
copilot(workspaceId: $workspaceId) {
|
||||
chats(pagination: $pagination, docId: $docId, options: $options) {
|
||||
...PaginatedCopilotChats
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation createCopilotMessage($options: CreateChatMessageInput!) {
|
||||
createCopilotMessage(options: $options)
|
||||
}
|
||||
10
packages/common/graphql/src/graphql/copilot-quota.gql
Normal file
10
packages/common/graphql/src/graphql/copilot-quota.gql
Normal file
@@ -0,0 +1,10 @@
|
||||
query copilotQuota {
|
||||
currentUser {
|
||||
copilot {
|
||||
quota {
|
||||
limit
|
||||
used
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
query getCopilotRouteOptions($promptName: String!) {
|
||||
currentUser {
|
||||
copilot {
|
||||
routeOptions(promptName: $promptName) {
|
||||
routeId
|
||||
defaultTargetId
|
||||
choices {
|
||||
id
|
||||
displayName
|
||||
minimumTier
|
||||
available
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation cleanupCopilotSession($input: DeleteSessionInput!) {
|
||||
cleanupCopilotSession(options: $input)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#import "./fragments/copilot-chat-history.gql"
|
||||
|
||||
mutation createCopilotSessionWithHistory($options: CreateChatSessionInput!) {
|
||||
createCopilotSessionWithHistory(options: $options) {
|
||||
...CopilotChatHistory
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation createCopilotSession($options: CreateChatSessionInput!) {
|
||||
createCopilotSession(options: $options)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation forkCopilotSession($options: ForkChatSessionInput!) {
|
||||
forkCopilotSession(options: $options)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#import "./fragments/paginated-copilot-chats.gql"
|
||||
|
||||
query getCopilotLatestDocSession($workspaceId: String!, $docId: String!) {
|
||||
currentUser {
|
||||
copilot(workspaceId: $workspaceId) {
|
||||
chats(
|
||||
pagination: { first: 1 }
|
||||
docId: $docId
|
||||
options: {
|
||||
sessionOrder: desc
|
||||
action: false
|
||||
fork: false
|
||||
withMessages: true
|
||||
}
|
||||
) {
|
||||
...PaginatedCopilotChats
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/common/graphql/src/graphql/copilot-session-get.gql
Normal file
11
packages/common/graphql/src/graphql/copilot-session-get.gql
Normal file
@@ -0,0 +1,11 @@
|
||||
#import "./fragments/paginated-copilot-chats.gql"
|
||||
|
||||
query getCopilotSession($workspaceId: String!, $sessionId: String!) {
|
||||
currentUser {
|
||||
copilot(workspaceId: $workspaceId) {
|
||||
chats(pagination: { first: 1 }, options: { sessionId: $sessionId }) {
|
||||
...PaginatedCopilotChats
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user