feat(server): add doc title and summary to database (#10505)
close CLOUD-152
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "workspace_pages" ADD COLUMN "summary" VARCHAR,
|
||||||
|
ADD COLUMN "title" VARCHAR;
|
||||||
@@ -126,6 +126,8 @@ model WorkspaceDoc {
|
|||||||
mode Int @default(0) @db.SmallInt
|
mode Int @default(0) @db.SmallInt
|
||||||
// Whether the doc is blocked
|
// Whether the doc is blocked
|
||||||
blocked Boolean @default(false)
|
blocked Boolean @default(false)
|
||||||
|
title String? @db.VarChar
|
||||||
|
summary String? @db.VarChar
|
||||||
|
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
|||||||
@@ -532,4 +532,130 @@ test('should get public docs of a workspace', async t => {
|
|||||||
t.deepEqual(docs.map(d => d.docId).sort(), [docId1, docId2]);
|
t.deepEqual(docs.map(d => d.docId).sort(), [docId1, docId2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should update title and summary', async t => {
|
||||||
|
const docId = randomUUID();
|
||||||
|
const snapshot = {
|
||||||
|
spaceId: workspace.id,
|
||||||
|
docId,
|
||||||
|
blob: Buffer.from('blob1'),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
editorId: user.id,
|
||||||
|
};
|
||||||
|
await t.context.doc.upsert(snapshot);
|
||||||
|
const content = {
|
||||||
|
title: 'test title',
|
||||||
|
summary: 'test summary',
|
||||||
|
};
|
||||||
|
await t.context.doc.upsertMeta(workspace.id, docId, content);
|
||||||
|
const foundContent = await t.context.doc.getMeta(workspace.id, docId, {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
summary: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
t.deepEqual(foundContent, content);
|
||||||
|
const updatedContent = {
|
||||||
|
title: 'test title 2',
|
||||||
|
summary: 'test summary 2',
|
||||||
|
};
|
||||||
|
await t.context.doc.upsertMeta(workspace.id, docId, updatedContent);
|
||||||
|
const foundUpdatedContent = await t.context.doc.getMeta(workspace.id, docId, {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
summary: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
t.deepEqual(foundUpdatedContent, updatedContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should find metas by workspaceIds and docIds', async t => {
|
||||||
|
const docId1 = randomUUID();
|
||||||
|
const docId2 = randomUUID();
|
||||||
|
const docId3 = randomUUID();
|
||||||
|
const snapshot1 = {
|
||||||
|
spaceId: workspace.id,
|
||||||
|
docId: docId1,
|
||||||
|
blob: Buffer.from('blob1'),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
editorId: user.id,
|
||||||
|
};
|
||||||
|
const snapshot2 = {
|
||||||
|
spaceId: workspace.id,
|
||||||
|
docId: docId2,
|
||||||
|
blob: Buffer.from('blob2'),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
editorId: user.id,
|
||||||
|
};
|
||||||
|
const snapshot3 = {
|
||||||
|
spaceId: workspace.id,
|
||||||
|
docId: docId3,
|
||||||
|
blob: Buffer.from('blob3'),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
editorId: user.id,
|
||||||
|
};
|
||||||
|
await t.context.doc.upsert(snapshot1);
|
||||||
|
await t.context.doc.upsert(snapshot2);
|
||||||
|
await t.context.doc.upsert(snapshot3);
|
||||||
|
const content1 = {
|
||||||
|
title: 'test title',
|
||||||
|
summary: 'test summary',
|
||||||
|
};
|
||||||
|
const content2 = {
|
||||||
|
title: 'test title 2',
|
||||||
|
summary: 'test summary 2',
|
||||||
|
};
|
||||||
|
await t.context.doc.upsertMeta(workspace.id, docId1, content1);
|
||||||
|
await t.context.doc.upsertMeta(workspace.id, docId2, content2);
|
||||||
|
let contents = await t.context.doc.findMetas([
|
||||||
|
{ workspaceId: workspace.id, docId: docId1 },
|
||||||
|
{ workspaceId: workspace.id, docId: randomUUID() },
|
||||||
|
{ workspaceId: randomUUID(), docId: docId1 },
|
||||||
|
{ workspaceId: workspace.id, docId: docId2 },
|
||||||
|
{ workspaceId: randomUUID(), docId: randomUUID() },
|
||||||
|
]);
|
||||||
|
t.deepEqual(
|
||||||
|
contents.map(c =>
|
||||||
|
c
|
||||||
|
? {
|
||||||
|
title: c.title,
|
||||||
|
summary: c.summary,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
),
|
||||||
|
[content1, null, null, content2, null]
|
||||||
|
);
|
||||||
|
contents = await t.context.doc.findMetas([
|
||||||
|
{ workspaceId: workspace.id, docId: docId1 },
|
||||||
|
{ workspaceId: workspace.id, docId: docId2 },
|
||||||
|
]);
|
||||||
|
t.deepEqual(
|
||||||
|
contents.map(c =>
|
||||||
|
c
|
||||||
|
? {
|
||||||
|
title: c.title,
|
||||||
|
summary: c.summary,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
),
|
||||||
|
[content1, content2]
|
||||||
|
);
|
||||||
|
// docId3 don't have meta
|
||||||
|
contents = await t.context.doc.findMetas([
|
||||||
|
{ workspaceId: workspace.id, docId: docId1 },
|
||||||
|
{ workspaceId: workspace.id, docId: docId2 },
|
||||||
|
{ workspaceId: workspace.id, docId: docId3 },
|
||||||
|
]);
|
||||||
|
t.deepEqual(
|
||||||
|
contents.map(c =>
|
||||||
|
c
|
||||||
|
? {
|
||||||
|
title: c.title,
|
||||||
|
summary: c.summary,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
),
|
||||||
|
[content1, content2, null]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|||||||
120
packages/backend/server/src/core/doc/__tests__/event.spec.ts
Normal file
120
packages/backend/server/src/core/doc/__tests__/event.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { mock } from 'node:test';
|
||||||
|
|
||||||
|
import ava, { TestFn } from 'ava';
|
||||||
|
import Sinon from 'sinon';
|
||||||
|
import { Doc as YDoc } from 'yjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTestingModule,
|
||||||
|
type TestingModule,
|
||||||
|
} from '../../../__tests__/utils';
|
||||||
|
import { Models, User, Workspace } from '../../../models';
|
||||||
|
import { DocReader, PgWorkspaceDocStorageAdapter as Adapter } from '..';
|
||||||
|
import { DocEventsListener } from '../event';
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
module: TestingModule;
|
||||||
|
docReader: DocReader;
|
||||||
|
adapter: Adapter;
|
||||||
|
models: Models;
|
||||||
|
listener: DocEventsListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
const test = ava as TestFn<Context>;
|
||||||
|
|
||||||
|
test.before(async t => {
|
||||||
|
const module = await createTestingModule();
|
||||||
|
t.context.module = module;
|
||||||
|
t.context.models = module.get(Models);
|
||||||
|
t.context.docReader = module.get(DocReader);
|
||||||
|
t.context.adapter = module.get(Adapter);
|
||||||
|
t.context.listener = module.get(DocEventsListener);
|
||||||
|
});
|
||||||
|
|
||||||
|
let owner: User;
|
||||||
|
let workspace: Workspace;
|
||||||
|
|
||||||
|
test.beforeEach(async t => {
|
||||||
|
await t.context.module.initTestingDB();
|
||||||
|
owner = await t.context.models.user.create({
|
||||||
|
email: `${randomUUID()}@affine.pro`,
|
||||||
|
});
|
||||||
|
workspace = await t.context.models.workspace.create(owner.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach.always(() => {
|
||||||
|
mock.reset();
|
||||||
|
Sinon.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after.always(async t => {
|
||||||
|
await t.context.module.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update doc content to database when doc is updated', async t => {
|
||||||
|
const { docReader, models, adapter, listener } = t.context;
|
||||||
|
const updates: Buffer[] = [];
|
||||||
|
{
|
||||||
|
const doc = new YDoc();
|
||||||
|
doc.on('update', data => {
|
||||||
|
updates.push(Buffer.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = doc.getText('content');
|
||||||
|
text.insert(0, 'hello');
|
||||||
|
text.insert(5, 'world');
|
||||||
|
}
|
||||||
|
|
||||||
|
const docId = randomUUID();
|
||||||
|
await adapter.pushDocUpdates(workspace.id, docId, updates);
|
||||||
|
await adapter.getDoc(workspace.id, docId);
|
||||||
|
|
||||||
|
mock.method(docReader, 'parseDocContent', () => {
|
||||||
|
return {
|
||||||
|
title: 'test title',
|
||||||
|
summary: 'test summary',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const spy = Sinon.spy(models.doc, 'upsertMeta');
|
||||||
|
await listener.markDocContentCacheStale({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
docId,
|
||||||
|
blob: Buffer.from([]),
|
||||||
|
});
|
||||||
|
t.is(spy.callCount, 1);
|
||||||
|
const content = await models.doc.getMeta(workspace.id, docId);
|
||||||
|
t.truthy(content);
|
||||||
|
t.is(content!.title, 'test title');
|
||||||
|
t.is(content!.summary, 'test summary');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should ignore update doc content to database when snapshot parse failed', async t => {
|
||||||
|
const { models, adapter, listener } = t.context;
|
||||||
|
const updates: Buffer[] = [];
|
||||||
|
{
|
||||||
|
const doc = new YDoc();
|
||||||
|
doc.on('update', data => {
|
||||||
|
updates.push(Buffer.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = doc.getText('content');
|
||||||
|
text.insert(0, 'hello');
|
||||||
|
text.insert(5, 'world');
|
||||||
|
}
|
||||||
|
|
||||||
|
const docId = randomUUID();
|
||||||
|
await adapter.pushDocUpdates(workspace.id, docId, updates);
|
||||||
|
const doc = await adapter.getDoc(workspace.id, docId);
|
||||||
|
|
||||||
|
const spy = Sinon.spy(models.doc, 'upsertMeta');
|
||||||
|
await listener.markDocContentCacheStale({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
docId,
|
||||||
|
blob: Buffer.from(doc!.bin),
|
||||||
|
});
|
||||||
|
t.is(spy.callCount, 0);
|
||||||
|
const content = await models.doc.getMeta(workspace.id, docId);
|
||||||
|
t.is(content, null);
|
||||||
|
});
|
||||||
@@ -193,7 +193,6 @@ test('should get workspace content with default avatar', async t => {
|
|||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
// @ts-expect-error parseWorkspaceContent is private
|
|
||||||
const track = mock.method(docReader, 'parseWorkspaceContent', () => ({
|
const track = mock.method(docReader, 'parseWorkspaceContent', () => ({
|
||||||
name: 'Test Workspace',
|
name: 'Test Workspace',
|
||||||
avatarKey: '',
|
avatarKey: '',
|
||||||
@@ -240,7 +239,6 @@ test('should get workspace content with custom avatar', async t => {
|
|||||||
Buffer.from('mock avatar image data here')
|
Buffer.from('mock avatar image data here')
|
||||||
);
|
);
|
||||||
|
|
||||||
// @ts-expect-error parseWorkspaceContent is private
|
|
||||||
const track = mock.method(docReader, 'parseWorkspaceContent', () => ({
|
const track = mock.method(docReader, 'parseWorkspaceContent', () => ({
|
||||||
name: 'Test Workspace',
|
name: 'Test Workspace',
|
||||||
avatarKey,
|
avatarKey,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ declare global {
|
|||||||
'doc.snapshot.updated': {
|
'doc.snapshot.updated': {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
docId: string;
|
docId: string;
|
||||||
|
blob: Buffer;
|
||||||
};
|
};
|
||||||
'doc.created': {
|
'doc.created': {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -335,10 +336,11 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const blob = Buffer.from(snapshot.bin);
|
||||||
const updatedSnapshot = await this.models.doc.upsert({
|
const updatedSnapshot = await this.models.doc.upsert({
|
||||||
spaceId: snapshot.spaceId,
|
spaceId: snapshot.spaceId,
|
||||||
docId: snapshot.docId,
|
docId: snapshot.docId,
|
||||||
blob: Buffer.from(snapshot.bin),
|
blob,
|
||||||
timestamp: snapshot.timestamp,
|
timestamp: snapshot.timestamp,
|
||||||
editorId: snapshot.editor,
|
editorId: snapshot.editor,
|
||||||
});
|
});
|
||||||
@@ -347,6 +349,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
|||||||
this.event.emit('doc.snapshot.updated', {
|
this.event.emit('doc.snapshot.updated', {
|
||||||
workspaceId: snapshot.spaceId,
|
workspaceId: snapshot.spaceId,
|
||||||
docId: snapshot.docId,
|
docId: snapshot.docId,
|
||||||
|
blob,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { OnEvent } from '../../base';
|
import { OnEvent } from '../../base';
|
||||||
|
import { Models } from '../../models';
|
||||||
import { DocReader } from './reader';
|
import { DocReader } from './reader';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DocEventsListener {
|
export class DocEventsListener {
|
||||||
constructor(private readonly doc: DocReader) {}
|
constructor(
|
||||||
|
private readonly docReader: DocReader,
|
||||||
|
private readonly models: Models
|
||||||
|
) {}
|
||||||
|
|
||||||
@OnEvent('doc.snapshot.updated')
|
@OnEvent('doc.snapshot.updated')
|
||||||
async markDocContentCacheStale({
|
async markDocContentCacheStale({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
docId,
|
docId,
|
||||||
|
blob,
|
||||||
}: Events['doc.snapshot.updated']) {
|
}: Events['doc.snapshot.updated']) {
|
||||||
await this.doc.markDocContentCacheStale(workspaceId, docId);
|
await this.docReader.markDocContentCacheStale(workspaceId, docId);
|
||||||
|
const isDoc = workspaceId !== docId;
|
||||||
|
// update doc content to database
|
||||||
|
if (isDoc) {
|
||||||
|
const content = this.docReader.parseDocContent(blob);
|
||||||
|
if (!content) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.models.doc.upsertMeta(workspaceId, docId, content);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ export interface WorkspaceDocInfo {
|
|||||||
export abstract class DocReader {
|
export abstract class DocReader {
|
||||||
constructor(protected readonly cache: Cache) {}
|
constructor(protected readonly cache: Cache) {}
|
||||||
|
|
||||||
|
parseDocContent(bin: Uint8Array) {
|
||||||
|
const doc = new YDoc();
|
||||||
|
applyUpdate(doc, bin);
|
||||||
|
return parsePageDoc(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseWorkspaceContent(bin: Uint8Array) {
|
||||||
|
const doc = new YDoc();
|
||||||
|
applyUpdate(doc, bin);
|
||||||
|
return parseWorkspaceDoc(doc);
|
||||||
|
}
|
||||||
|
|
||||||
abstract getDoc(
|
abstract getDoc(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
docId: string
|
docId: string
|
||||||
@@ -47,6 +59,7 @@ export abstract class DocReader {
|
|||||||
stateVector?: Uint8Array
|
stateVector?: Uint8Array
|
||||||
): Promise<DocDiff | null>;
|
): Promise<DocDiff | null>;
|
||||||
|
|
||||||
|
// TODO(@fengmk2): should remove this method after frontend support doc content update
|
||||||
async getDocContent(
|
async getDocContent(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
docId: string
|
docId: string
|
||||||
@@ -149,9 +162,7 @@ export class DatabaseDocReader extends DocReader {
|
|||||||
if (!docRecord) {
|
if (!docRecord) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const doc = new YDoc();
|
return this.parseDocContent(docRecord.bin);
|
||||||
applyUpdate(doc, docRecord.bin);
|
|
||||||
return parsePageDoc(doc);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async getWorkspaceContentWithoutCache(
|
protected override async getWorkspaceContentWithoutCache(
|
||||||
@@ -178,12 +189,6 @@ export class DatabaseDocReader extends DocReader {
|
|||||||
avatarUrl,
|
avatarUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseWorkspaceContent(bin: Uint8Array) {
|
|
||||||
const doc = new YDoc();
|
|
||||||
applyUpdate(doc, bin);
|
|
||||||
return parseWorkspaceDoc(doc);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { DocIsNotPublic } from '../base/error';
|
|||||||
import { BaseModel } from './base';
|
import { BaseModel } from './base';
|
||||||
import { Doc, DocRole, PublicDocMode, publicUserSelect } from './common';
|
import { Doc, DocRole, PublicDocMode, publicUserSelect } from './common';
|
||||||
|
|
||||||
export interface DocRecord extends Doc {}
|
|
||||||
|
|
||||||
export type DocMetaUpsertInput = Omit<
|
export type DocMetaUpsertInput = Omit<
|
||||||
Prisma.WorkspaceDocUncheckedCreateInput,
|
Prisma.WorkspaceDocUncheckedCreateInput,
|
||||||
'workspaceId' | 'docId'
|
'workspaceId' | 'docId'
|
||||||
@@ -27,7 +25,7 @@ export type DocMetaUpsertInput = Omit<
|
|||||||
export class DocModel extends BaseModel {
|
export class DocModel extends BaseModel {
|
||||||
// #region Update
|
// #region Update
|
||||||
|
|
||||||
private updateToDocRecord(row: Update): DocRecord {
|
private updateToDocRecord(row: Update): Doc {
|
||||||
return {
|
return {
|
||||||
spaceId: row.workspaceId,
|
spaceId: row.workspaceId,
|
||||||
docId: row.id,
|
docId: row.id,
|
||||||
@@ -37,7 +35,7 @@ export class DocModel extends BaseModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private docRecordToUpdate(record: DocRecord): Update {
|
private docRecordToUpdate(record: Doc): Update {
|
||||||
return {
|
return {
|
||||||
workspaceId: record.spaceId,
|
workspaceId: record.spaceId,
|
||||||
id: record.docId,
|
id: record.docId,
|
||||||
@@ -48,7 +46,7 @@ export class DocModel extends BaseModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUpdates(updates: DocRecord[]) {
|
async createUpdates(updates: Doc[]) {
|
||||||
return await this.db.update.createMany({
|
return await this.db.update.createMany({
|
||||||
data: updates.map(r => this.docRecordToUpdate(r)),
|
data: updates.map(r => this.docRecordToUpdate(r)),
|
||||||
});
|
});
|
||||||
@@ -57,7 +55,7 @@ export class DocModel extends BaseModel {
|
|||||||
/**
|
/**
|
||||||
* Find updates by workspaceId and docId.
|
* Find updates by workspaceId and docId.
|
||||||
*/
|
*/
|
||||||
async findUpdates(workspaceId: string, docId: string): Promise<DocRecord[]> {
|
async findUpdates(workspaceId: string, docId: string): Promise<Doc[]> {
|
||||||
const rows = await this.db.update.findMany({
|
const rows = await this.db.update.findMany({
|
||||||
where: {
|
where: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -349,6 +347,21 @@ export class DocModel extends BaseModel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findMetas(ids: { workspaceId: string; docId: string }[]) {
|
||||||
|
const rows = await this.db.workspaceDoc.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId: { in: ids.map(id => id.workspaceId) },
|
||||||
|
docId: { in: ids.map(id => id.docId) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const resultMap = new Map(
|
||||||
|
rows.map(row => [`${row.workspaceId}-${row.docId}`, row])
|
||||||
|
);
|
||||||
|
return ids.map(
|
||||||
|
id => resultMap.get(`${id.workspaceId}-${id.docId}`) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the workspace public doc metas.
|
* Find the workspace public doc metas.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ enum ErrorNames {
|
|||||||
MEMBER_NOT_FOUND_IN_SPACE
|
MEMBER_NOT_FOUND_IN_SPACE
|
||||||
MEMBER_QUOTA_EXCEEDED
|
MEMBER_QUOTA_EXCEEDED
|
||||||
MISSING_OAUTH_QUERY_PARAMETER
|
MISSING_OAUTH_QUERY_PARAMETER
|
||||||
|
NETWORK_ERROR
|
||||||
NOT_FOUND
|
NOT_FOUND
|
||||||
NOT_IN_SPACE
|
NOT_IN_SPACE
|
||||||
NO_COPILOT_PROVIDER_AVAILABLE
|
NO_COPILOT_PROVIDER_AVAILABLE
|
||||||
|
|||||||
Reference in New Issue
Block a user