feat(storage): binding jwst storage to node (#2808)

This commit is contained in:
liuyi 2023-06-29 09:45:45 +08:00 committed by GitHub
parent 86616e152d
commit 2c95bfcc3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 5621 additions and 98 deletions

View File

@ -21,7 +21,8 @@
"native",
"templates",
"y-indexeddb",
"debug"
"debug",
"storage"
]
]
}

View File

@ -27,11 +27,11 @@ runs:
.cargo-cache
target/${{ inputs.target }}
key: stable-${{ inputs.target }}-cargo-cache
- name: Build
if: ${{ inputs.target != 'x86_64-unknown-linux-gnu' && inputs.target != 'aarch64-unknown-linux-gnu' }}
shell: bash
run: yarn nx build @affine/native --target ${{ inputs.target }}
run: |
yarn nx build @affine/native --target ${{ inputs.target }}
env:
NX_CLOUD_ACCESS_TOKEN: ${{ inputs.nx_token }}
@ -41,10 +41,10 @@ runs:
with:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian
options: --user 0:0 -v ${{ github.workspace }}/.cargo-cache/git/db:/usr/local/cargo/git/db -v ${{ github.workspace }}/.cargo/registry/cache:/usr/local/cargo/registry/cache -v ${{ github.workspace }}/.cargo/registry/index:/usr/local/cargo/registry/index -v ${{ github.workspace }}:/build -w /build -e NX_CLOUD_ACCESS_TOKEN=${{ inputs.nx_token }}
run: >-
export CC=x86_64-unknown-linux-gnu-gcc &&
export CC_x86_64_unknown_linux_gnu=x86_64-unknown-linux-gnu-gcc &&
yarn nx build @affine/native --target ${{ inputs.target }} &&
run: |
export CC=x86_64-unknown-linux-gnu-gcc
export CC_x86_64_unknown_linux_gnu=x86_64-unknown-linux-gnu-gcc
yarn nx build @affine/native --target ${{ inputs.target }}
chmod -R 777 node_modules/.cache
- name: Build
@ -53,6 +53,6 @@ runs:
with:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
options: --user 0:0 -v ${{ github.workspace }}/.cargo-cache/git/db:/usr/local/cargo/git/db -v ${{ github.workspace }}/.cargo/registry/cache:/usr/local/cargo/registry/cache -v ${{ github.workspace }}/.cargo/registry/index:/usr/local/cargo/registry/index -v ${{ github.workspace }}:/build -w /build -e NX_CLOUD_ACCESS_TOKEN=${{ inputs.nx_token }}
run: >-
run: |
yarn nx build @affine/native --target ${{ inputs.target }}
chmod -R 777 node_modules/.cache

31
.github/actions/setup-rust/action.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: 'AFFiNE Rust setup'
description: 'Rust setup, including cache configuration'
inputs:
target:
description: 'Cargo target'
required: true
toolchain:
description: 'Rustup toolchain'
required: false
default: 'stable'
runs:
using: 'composite'
steps:
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ inputs.toolchain }}
targets: ${{ inputs.target }}
- name: Cache cargo
uses: actions/cache@v3
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: cargo-cache-${{ runner.os }}-${{ inputs.toolchain }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-cache-${{ runner.os }}-${{ inputs.toolchain }}-

View File

@ -163,9 +163,14 @@ jobs:
working-directory: apps/server
env:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
target: 'x86_64-unknown-linux-gnu'
- name: Run server tests
run: yarn nx test:coverage @affine/server
env:
CARGO_TARGET_DIR: '${{ github.workspace }}/target'
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
- name: Upload server test coverage results
uses: codecov/codecov-action@v3

View File

@ -1,3 +1,23 @@
# Server
The latest server code of AFFiNE is at https://github.com/toeverything/OctoBase/tree/master/apps/cloud
## Get started
### Install dependencies
```bash
yarn
```
### Build Native binding
```bash
yarn workspace @affine/storage build
```
### Run server
```bash
yarn dev
```
now you can access the server GraphQL endpoint at http://localhost:3000/graphql

View File

@ -0,0 +1,52 @@
-- CreateTable
CREATE TABLE "blobs" (
"hash" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"blob" BYTEA NOT NULL,
"length" INTEGER NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "blobs_pkey" PRIMARY KEY ("hash")
);
-- CreateTable
CREATE TABLE "optimized_blobs" (
"hash" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"params" VARCHAR NOT NULL,
"blob" BYTEA NOT NULL,
"length" INTEGER NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "optimized_blobs_pkey" PRIMARY KEY ("hash")
);
-- CreateTable
CREATE TABLE "docs" (
"id" SERIAL NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"guid" VARCHAR NOT NULL,
"is_workspace" BOOLEAN NOT NULL DEFAULT true,
"blob" BYTEA NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "docs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "blobs_workspace_id_hash_key" ON "blobs"("workspace_id", "hash");
-- CreateIndex
CREATE UNIQUE INDEX "optimized_blobs_workspace_id_hash_params_key" ON "optimized_blobs"("workspace_id", "hash", "params");
-- CreateIndex
CREATE INDEX "docs_workspace_id_guid_idx" ON "docs"("workspace_id", "guid");
-- AddForeignKey
ALTER TABLE "blobs" ADD CONSTRAINT "blobs_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "optimized_blobs" ADD CONSTRAINT "optimized_blobs_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "docs" ADD CONSTRAINT "docs_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -15,6 +15,7 @@
"postinstall": "prisma generate"
},
"dependencies": {
"@affine/storage": "workspace:*",
"@apollo/server": "^4.7.4",
"@auth/prisma-adapter": "^1.0.0",
"@aws-sdk/client-s3": "^3.359.0",

View File

@ -8,10 +8,13 @@ datasource db {
}
model Workspace {
id String @id @default(uuid()) @db.VarChar
public Boolean
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
users UserWorkspacePermission[]
id String @id @default(uuid()) @db.VarChar
public Boolean
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
users UserWorkspacePermission[]
blobs Blob[]
docs Doc[]
optimizedBlobs OptimizedBlob[]
@@map("workspaces")
}
@ -86,3 +89,44 @@ model VerificationToken {
@@unique([identifier, token])
@@map("verificationtokens")
}
model Blob {
hash String @id @default(uuid()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
blob Bytes @db.ByteA
length Int
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId, hash])
@@map("blobs")
}
model OptimizedBlob {
hash String @id @default(uuid()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
params String @db.VarChar
blob Bytes @db.ByteA
length Int
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId, hash, params])
@@map("optimized_blobs")
}
model Doc {
id Int @id @default(autoincrement()) @db.Integer
workspaceId String @map("workspace_id") @db.VarChar
guid String @db.VarChar
is_workspace Boolean @default(true) @db.Boolean
blob Bytes @db.ByteA
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([workspaceId, guid])
@@map("docs")
}

View File

@ -1,16 +1,17 @@
/// <reference types="./global.d.ts" />
import { Module } from '@nestjs/common';
import { ConfigModule } from './config';
import { GqlModule } from './graphql.module';
import { BusinessModules } from './modules';
import { PrismaModule } from './prisma';
import { StorageModule } from './storage';
@Module({
imports: [
PrismaModule,
GqlModule,
ConfigModule.forRoot(),
StorageModule.forRoot(),
...BusinessModules,
],
})

View File

@ -132,6 +132,13 @@ export interface AFFiNEConfig {
*/
get origin(): string;
/**
* the database config
*/
db: {
url: string;
};
/**
* the apollo driver config
*/

View File

@ -52,6 +52,9 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
get baseUrl() {
return `${this.origin}${this.path}`;
},
db: {
url: '',
},
graphql: {
buildSchemaOptions: {
numberScalarMode: 'integer',
@ -81,3 +84,5 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
},
},
});
export { registerEnvs } from './env';

View File

@ -2,14 +2,16 @@ import { set } from 'lodash-es';
import { parseEnvValue } from './def';
for (const env in AFFiNE.ENV_MAP) {
const config = AFFiNE.ENV_MAP[env];
const [path, value] =
typeof config === 'string'
? [config, process.env[env]]
: [config[0], parseEnvValue(process.env[env], config[1])];
export function registerEnvs() {
for (const env in globalThis.AFFiNE.ENV_MAP) {
const config = globalThis.AFFiNE.ENV_MAP[env];
const [path, value] =
typeof config === 'string'
? [config, process.env[env]]
: [config[0], parseEnvValue(process.env[env], config[1])];
if (typeof value !== 'undefined') {
set(globalThis.AFFiNE, path, process.env[env]);
if (typeof value !== 'undefined') {
set(globalThis.AFFiNE, path, process.env[env]);
}
}
}

View File

@ -1,9 +1,12 @@
// eslint-disable-next-line simple-import-sort/imports
import type { DynamicModule, FactoryProvider } from '@nestjs/common';
import { merge } from 'lodash-es';
import type { DeepPartial } from '../utils/types';
import type { AFFiNEConfig } from './def';
import '../prelude';
type ConstructorOf<T> = {
new (): T;
};
@ -37,11 +40,14 @@ function createConfigProvider(
provide: Config,
useFactory: () => {
const wrapper = new Config();
const config = merge({}, AFFiNE, override);
const config = merge({}, globalThis.AFFiNE, override);
const proxy: Config = new Proxy(wrapper, {
get: (_target, property: keyof Config) => {
const desc = Object.getOwnPropertyDescriptor(AFFiNE, property);
const desc = Object.getOwnPropertyDescriptor(
globalThis.AFFiNE,
property
);
if (desc?.get) {
return desc.get.call(proxy);
}

View File

@ -1,5 +1,4 @@
import './prelude';
/// <reference types="./global.d.ts" />
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { static as staticMiddleware } from 'express';

View File

@ -0,0 +1,27 @@
import { Storage } from '@affine/storage';
import { Controller, Get, NotFoundException, Param, Res } from '@nestjs/common';
import type { Response } from 'express';
@Controller('/api/workspaces')
export class WorkspacesController {
constructor(private readonly storage: Storage) {}
@Get('/:id/blobs/:name')
async blob(
@Param('id') workspaceId: string,
@Param('name') name: string,
@Res() res: Response
) {
const blob = await this.storage.blob(workspaceId, name);
if (!blob) {
throw new NotFoundException('Blob not found');
}
res.setHeader('content-type', blob.contentType);
res.setHeader('last-modified', blob.lastModified);
res.setHeader('content-length', blob.size);
res.send(blob.data);
}
}

View File

@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { WorkspacesController } from './controller';
import { PermissionService } from './permission';
import { WorkspaceResolver } from './resolver';
@Module({
providers: [WorkspaceResolver, PermissionService],
providers: [WorkspaceResolver, PermissionService, WorkspacesController],
exports: [PermissionService],
})
export class WorkspaceModule {}

View File

@ -1,3 +1,4 @@
import { Storage } from '@affine/storage';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import {
Args,
@ -16,8 +17,11 @@ import {
Resolver,
} from '@nestjs/graphql';
import type { User, Workspace } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PrismaService } from '../../prisma';
import type { FileUpload } from '../../types';
import { Auth, CurrentUser } from '../auth';
import { UserType } from '../users/resolver';
import { PermissionService } from './permission';
@ -55,7 +59,8 @@ export class UpdateWorkspaceInput extends PickType(
export class WorkspaceResolver {
constructor(
private readonly prisma: PrismaService,
private readonly permissionProvider: PermissionService
private readonly permissionProvider: PermissionService,
private readonly storage: Storage
) {}
@ResolveField(() => Permission, {
@ -174,8 +179,25 @@ export class WorkspaceResolver {
@Mutation(() => WorkspaceType, {
description: 'Create a new workspace',
})
async createWorkspace(@CurrentUser() user: User) {
return this.prisma.workspace.create({
async createWorkspace(
@CurrentUser() user: User,
@Args({ name: 'init', type: () => GraphQLUpload })
update: FileUpload
) {
// convert stream to buffer
const buffer = await new Promise<Buffer>((resolve, reject) => {
const stream = update.createReadStream();
const chunks: Uint8Array[] = [];
stream.on('data', chunk => {
chunks.push(chunk);
});
stream.on('error', reject);
stream.on('end', () => {
resolve(Buffer.concat(chunks));
});
});
const workspace = await this.prisma.workspace.create({
data: {
public: false,
users: {
@ -191,6 +213,10 @@ export class WorkspaceResolver {
},
},
});
await this.storage.createWorkspace(workspace.id, buffer);
return workspace;
}
@Mutation(() => WorkspaceType, {
@ -221,8 +247,15 @@ export class WorkspaceResolver {
},
});
await this.prisma.userWorkspacePermission.deleteMany({
where: {
workspaceId: id,
},
});
// TODO:
// delete all related data, like websocket connections, blobs, etc.
await this.storage.deleteWorkspace(id);
return true;
}
@ -283,4 +316,28 @@ export class WorkspaceResolver {
return this.permissionProvider.revoke(workspaceId, user.id);
}
@Mutation(() => String)
async uploadBlob(
@CurrentUser() user: User,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'blob', type: () => GraphQLUpload })
blob: FileUpload
) {
await this.permissionProvider.check(workspaceId, user.id);
const buffer = await new Promise<Buffer>((resolve, reject) => {
const stream = blob.createReadStream();
const chunks: Uint8Array[] = [];
stream.on('data', chunk => {
chunks.push(chunk);
});
stream.on('error', reject);
stream.on('end', () => {
resolve(Buffer.concat(chunks));
});
});
return this.storage.uploadBlob(workspaceId, buffer);
}
}

View File

@ -1,6 +1,12 @@
import 'reflect-metadata';
import 'dotenv/config';
import { getDefaultAFFiNEConfig } from './config/default';
import { getDefaultAFFiNEConfig, registerEnvs } from './config/default';
globalThis.AFFiNE = getDefaultAFFiNEConfig();
globalThis.AFFiNE.ENV_MAP = {
DATABASE_URL: 'db.url',
};
registerEnvs();

View File

@ -106,7 +106,7 @@ type Mutation {
"""
Create a new workspace
"""
createWorkspace: WorkspaceType!
createWorkspace(init: Upload!): WorkspaceType!
"""
Update workspace
@ -121,6 +121,7 @@ type Mutation {
revoke(workspaceId: String!, userId: String!): Boolean!
acceptInvite(workspaceId: String!): Boolean!
leaveWorkspace(workspaceId: String!): Boolean!
uploadBlob(workspaceId: String!, blob: Upload!): String!
"""
Upload user avatar
@ -128,6 +129,11 @@ type Mutation {
uploadAvatar(id: String!, avatar: Upload!): UserType!
}
"""
The `Upload` scalar type represents a file upload.
"""
scalar Upload
input UpdateWorkspaceInput {
"""
is Public workspace
@ -135,8 +141,3 @@ input UpdateWorkspaceInput {
public: Boolean
id: ID!
}
"""
The `Upload` scalar type represents a file upload.
"""
scalar Upload

View File

@ -0,0 +1,23 @@
import { Storage } from '@affine/storage';
import { type DynamicModule, type FactoryProvider } from '@nestjs/common';
import { Config } from '../config';
export class StorageModule {
static forRoot(): DynamicModule {
const storageProvider: FactoryProvider = {
provide: Storage,
useFactory: async (config: Config) => {
return Storage.connect(config.db.url);
},
inject: [Config],
};
return {
global: true,
module: StorageModule,
providers: [storageProvider],
exports: [storageProvider],
};
}
}

View File

@ -12,12 +12,9 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../app';
import { getDefaultAFFiNEConfig } from '../config/default';
const gql = '/graphql';
globalThis.AFFiNE = getDefaultAFFiNEConfig();
describe('AppModule', () => {
let app: INestApplication;
@ -76,33 +73,14 @@ describe('AppModule', () => {
.auth(token, { type: 'bearer' })
.send({
query: `
mutation {
createWorkspace {
id
public
createdAt
}
query {
__typename
}
`,
})
.expect(200)
.expect(res => {
ok(
typeof res.body.data.createWorkspace === 'object',
'res.body.data.createWorkspace is not an object'
);
ok(
typeof res.body.data.createWorkspace.id === 'string',
'res.body.data.createWorkspace.id is not a string'
);
ok(
typeof res.body.data.createWorkspace.public === 'boolean',
'res.body.data.createWorkspace.public is not a boolean'
);
ok(
typeof res.body.data.createWorkspace.createdAt === 'string',
'res.body.data.createWorkspace.created_at is not a string'
);
ok(res.body.data.__typename === 'Query');
});
});

View File

@ -1,3 +1,4 @@
/// <reference types="../global.d.ts" />
import { ok } from 'node:assert';
import { beforeEach, test } from 'node:test';
@ -5,14 +6,11 @@ import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import { ConfigModule } from '../config';
import { getDefaultAFFiNEConfig } from '../config/default';
import { GqlModule } from '../graphql.module';
import { AuthModule } from '../modules/auth';
import { AuthService } from '../modules/auth/service';
import { PrismaModule } from '../prisma';
globalThis.AFFiNE = getDefaultAFFiNEConfig();
let auth: AuthService;
// cleanup database before each test

View File

@ -4,9 +4,6 @@ import { beforeEach, test } from 'node:test';
import { Test } from '@nestjs/testing';
import { Config, ConfigModule } from '../config';
import { getDefaultAFFiNEConfig } from '../config/default';
globalThis.AFFiNE = getDefaultAFFiNEConfig();
let config: Config;
beforeEach(async () => {

View File

@ -1,22 +1,21 @@
import { ok } from 'node:assert';
import { afterEach, beforeEach, describe, test } from 'node:test';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../app';
import { getDefaultAFFiNEConfig } from '../config/default';
import type { TokenType } from '../modules/auth';
import type { UserType } from '../modules/users';
import type { WorkspaceType } from '../modules/workspaces';
const gql = '/graphql';
globalThis.AFFiNE = getDefaultAFFiNEConfig();
describe('AppModule', () => {
describe('Workspace Module', () => {
let app: INestApplication;
// cleanup database before each test
@ -32,6 +31,12 @@ describe('AppModule', () => {
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
});
@ -63,15 +68,20 @@ describe('AppModule', () => {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
mutation {
createWorkspace {
.field(
'operations',
JSON.stringify({
name: 'createWorkspace',
query: `mutation createWorkspace($init: Upload!) {
createWorkspace(init: $init) {
id
}
}
`,
})
}`,
variables: { init: null },
})
)
.field('map', JSON.stringify({ '0': ['variables.init'] }))
.attach('0', Buffer.from([0, 0]), 'init.data')
.expect(200);
return res.body.data.createWorkspace;
}
@ -151,21 +161,21 @@ describe('AppModule', () => {
return res.body.data.revoke;
}
test('should register a user', async () => {
it('should register a user', async () => {
const user = await registerUser('u1', 'u1@affine.pro', '123456');
ok(typeof user.id === 'string', 'user.id is not a string');
ok(user.name === 'u1', 'user.name is not valid');
ok(user.email === 'u1@affine.pro', 'user.email is not valid');
});
test('should create a workspace', async () => {
it('should create a workspace', async () => {
const user = await registerUser('u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(user.token.token);
ok(typeof workspace.id === 'string', 'workspace.id is not a string');
});
test('should invite a user', async () => {
it('should invite a user', async () => {
const u1 = await registerUser('u1', 'u1@affine.pro', '1');
const u2 = await registerUser('u2', 'u2@affine.pro', '1');
@ -180,7 +190,7 @@ describe('AppModule', () => {
ok(invite === true, 'failed to invite user');
});
test('should accept an invite', async () => {
it('should accept an invite', async () => {
const u1 = await registerUser('u1', 'u1@affine.pro', '1');
const u2 = await registerUser('u2', 'u2@affine.pro', '1');
@ -191,7 +201,7 @@ describe('AppModule', () => {
ok(accept === true, 'failed to accept invite');
});
test('should leave a workspace', async () => {
it('should leave a workspace', async () => {
const u1 = await registerUser('u1', 'u1@affine.pro', '1');
const u2 = await registerUser('u2', 'u2@affine.pro', '1');
@ -203,7 +213,7 @@ describe('AppModule', () => {
ok(leave === true, 'failed to leave workspace');
});
test('should revoke a user', async () => {
it('should revoke a user', async () => {
const u1 = await registerUser('u1', 'u1@affine.pro', '1');
const u2 = await registerUser('u2', 'u2@affine.pro', '1');

View File

@ -20,6 +20,9 @@
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "../../packages/storage/tsconfig.json"
}
],
"ts-node": {

View File

@ -35,6 +35,7 @@
"{workspaceRoot}/apps/storybook/storybook-static",
"{workspaceRoot}/packages/i18n/src/i18n-generated.ts",
"{workspaceRoot}/packages/native/affine.*.node",
"{workspaceRoot}/packages/storage/*.node",
"{workspaceRoot}/affine.db",
"{workspaceRoot/apps/electron/dist"
],

View File

@ -1,5 +1,5 @@
mutation createWorkspace {
createWorkspace {
mutation createWorkspace($init: Upload!) {
createWorkspace(init: $init) {
id
public
createdAt

View File

@ -11,10 +11,10 @@ export const createWorkspaceMutation = {
id: 'createWorkspaceMutation' as const,
operationName: 'createWorkspace',
definitionName: 'createWorkspace',
containsFile: false,
containsFile: true,
query: `
mutation createWorkspace {
createWorkspace {
mutation createWorkspace($init: Upload!) {
createWorkspace(init: $init) {
id
public
createdAt

View File

@ -46,7 +46,9 @@ export interface UpdateWorkspaceInput {
public: InputMaybe<Scalars['Boolean']['input']>;
}
export type CreateWorkspaceMutationVariables = Exact<{ [key: string]: never }>;
export type CreateWorkspaceMutationVariables = Exact<{
init: Scalars['Upload']['input'];
}>;
export type CreateWorkspaceMutation = {
__typename?: 'Mutation';

4428
packages/storage/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
[package]
name = "affine_storage"
version = "1.0.0"
edition = "2021"
# used to avoid sys dep conflict sqlx -> libsqlite-sys
[workspace]
[lib]
crate-type = ["cdylib"]
[dependencies]
jwst = { git = "https://github.com/toeverything/OctoBase.git", branch = "master" }
jwst-storage = { git = "https://github.com/toeverything/OctoBase.git", branch = "master" }
napi = { version = "2", default-features = false, features = [
"napi5",
"async",
] }
napi-derive = { version = "2", features = ["type-def"] }
yrs = { version = "0.16.5" }
[build-dependencies]
napi-build = "2"
[patch.crates-io]
lib0 = { git = "https://github.com/toeverything/y-crdt", rev = "a700f09" }
yrs = { git = "https://github.com/toeverything/y-crdt", rev = "a700f09" }

View File

@ -0,0 +1,131 @@
import assert from 'node:assert';
import { beforeEach, describe, test } from 'node:test';
import { encoding } from 'lib0';
import * as Y from 'yjs';
import { Storage } from '../index.js';
// update binary by y.doc.text('content').insert('hello world')
// prettier-ignore
let init = Buffer.from([1,1,160,238,169,240,10,0,4,1,7,99,111,110,116,101,110,116,11,104,101,108,108,111,32,119,111,114,108,100,0])
describe('Test jwst storage binding', () => {
/** @type { Storage } */
let storage;
beforeEach(async () => {
storage = await Storage.connect('sqlite::memory:', true);
});
test('should be able to create workspace', async () => {
const workspace = await storage.createWorkspace('test-workspace', init);
assert(workspace.id === 'test-workspace');
assert.deepEqual(init, await storage.load(workspace.doc.guid));
});
test('should not create workspace with same id', async () => {
await storage.createWorkspace('test-workspace', init);
await assert.rejects(
storage.createWorkspace('test-workspace', init),
/Workspace [\w-]+ already exists/
);
});
test('should be able to delete workspace', async () => {
const workspace = await storage.createWorkspace('test-workspace', init);
await storage.deleteWorkspace(workspace.id);
await assert.rejects(
storage.load(workspace.doc.guid),
/Doc [\w-]+ not exists/
);
});
test('should be able to sync update', async () => {
const workspace = await storage.createWorkspace('test-workspace', init);
const update = await storage.load(workspace.doc.guid);
assert(update !== null);
const doc = new Y.Doc();
Y.applyUpdate(doc, update);
let text = doc.getText('content');
assert.equal(text.toJSON(), 'hello world');
const updates = [];
doc.on('update', async (/** @type { UInt8Array } */ update) => {
updates.push(Buffer.from(update));
});
text.insert(5, ' my');
text.insert(14, '!');
for (const update of updates) {
await storage.sync(workspace.id, workspace.doc.guid, update);
}
const update2 = await storage.load(workspace.doc.guid);
const doc2 = new Y.Doc();
Y.applyUpdate(doc2, update2);
text = doc2.getText('content');
assert.equal(text.toJSON(), 'hello my world!');
});
test('should be able to sync update with guid encoded', async () => {
const workspace = await storage.createWorkspace('test-workspace', init);
const update = await storage.load(workspace.doc.guid);
assert(update !== null);
const doc = new Y.Doc();
Y.applyUpdate(doc, update);
let text = doc.getText('content');
assert.equal(text.toJSON(), 'hello world');
const updates = [];
doc.on('update', async (/** @type { UInt8Array } */ update) => {
const prefix = encoding.encode(encoder => {
encoding.writeVarString(encoder, workspace.doc.guid);
});
updates.push(Buffer.concat([prefix, update]));
});
text.insert(5, ' my');
text.insert(14, '!');
for (const update of updates) {
await storage.syncWithGuid(workspace.id, update);
}
const update2 = await storage.load(workspace.doc.guid);
const doc2 = new Y.Doc();
Y.applyUpdate(doc2, update2);
text = doc2.getText('content');
assert.equal(text.toJSON(), 'hello my world!');
});
test('should be able to store blob', async () => {
let workspace = await storage.createWorkspace('test-workspace', init);
const blobId = await storage.uploadBlob(workspace.id, Buffer.from([1]));
assert(blobId !== null);
let blob = await storage.blob(workspace.id, blobId);
assert.deepEqual(blob.data, Buffer.from([1]));
assert.strictEqual(blob.size, 1);
assert.equal(blob.contentType, 'application/octet-stream');
await storage.uploadBlob(workspace.id, Buffer.from([1, 2, 3, 4, 5]));
const spaceTaken = await storage.blobsSize(workspace.id);
assert.equal(spaceTaken, 6);
});
});

View File

@ -0,0 +1,3 @@
fn main() {
napi_build::setup();
}

52
packages/storage/index.d.ts vendored Normal file
View File

@ -0,0 +1,52 @@
/* auto-generated by NAPI-RS */
/* eslint-disable */
export class Doc {
get guid(): string;
}
export class Storage {
/** Create a storage instance and establish connection to persist store. */
static connect(
database: string,
debugOnlyAutoMigrate?: boolean | undefined | null
): Promise<Storage>;
/** Get a workspace by id */
getWorkspace(workspaceId: string): Promise<Workspace | null>;
/** Create a new workspace with a init update. */
createWorkspace(workspaceId: string, init: Buffer): Promise<Workspace>;
/** Delete a workspace. */
deleteWorkspace(workspaceId: string): Promise<void>;
/** Sync doc updates. */
sync(workspaceId: string, guid: string, update: Buffer): Promise<void>;
/** Sync doc update with doc guid encoded. */
syncWithGuid(workspaceId: string, update: Buffer): Promise<void>;
/** Load doc as update buffer. */
load(guid: string): Promise<Buffer | null>;
/** Fetch a workspace blob. */
blob(workspaceId: string, name: string): Promise<Blob | null>;
/** Upload a blob into workspace storage. */
uploadBlob(workspaceId: string, blob: Buffer): Promise<string>;
/** Workspace size taken by blobs. */
blobsSize(workspaceId: string): Promise<number>;
}
export class Workspace {
get doc(): Doc;
isEmpty(): boolean;
get id(): string;
get clientId(): string;
search(query: string): Array<SearchResult>;
}
export interface Blob {
contentType: string;
lastModified: string;
size: number;
data: Buffer;
}
export interface SearchResult {
blockId: string;
score: number;
}

10
packages/storage/index.js Normal file
View File

@ -0,0 +1,10 @@
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
/** @type {import('.')} */
const binding = require('./storage.node');
export const Storage = binding.Storage;
export const Workspace = binding.Workspace;
export const Document = binding.Doc;

View File

@ -0,0 +1,43 @@
{
"name": "@affine/storage",
"version": "1.0.0",
"engines": {
"node": ">= 10.16.0 < 11 || >= 11.8.0"
},
"type": "module",
"main": "./index.js",
"module": "./index.js",
"types": "index.d.ts",
"exports": {
".": {
"require": "./storage.node",
"import": "./index.js",
"types": "./index.d.ts"
}
},
"napi": {
"binaryName": "storage",
"targets": [
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
"aarch64-pc-windows-msvc",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"x86_64-unknown-linux-gnu",
"universal-apple-darwin"
]
},
"scripts": {
"test": "node --test ./__tests__/**/*.spec.js",
"build": "napi build --release --strip",
"build:debug": "napi build",
"prepublishOnly": "napi prepublish -t npm",
"artifacts": "napi artifacts",
"version": "napi version"
},
"devDependencies": {
"@napi-rs/cli": "^3.0.0-alpha.4",
"lib0": "^0.2.78",
"yjs": "^13.6.6"
}
}

284
packages/storage/src/lib.rs Normal file
View File

@ -0,0 +1,284 @@
#![deny(clippy::all)]
use std::{
collections::HashMap,
fmt::{Debug, Display},
path::PathBuf,
};
use jwst::{BlobStorage, SearchResult as JwstSearchResult, Workspace as JwstWorkspace, DocStorage};
use jwst_storage::{JwstStorage, JwstStorageError};
use yrs::{Doc as YDoc, ReadTxn, StateVector, Transact};
use napi::{bindgen_prelude::*, Error, Result, Status};
#[macro_use]
extern crate napi_derive;
fn map_err_inner<T, E: Display + Debug>(v: std::result::Result<T, E>, status: Status) -> Result<T> {
match v {
Ok(val) => Ok(val),
Err(e) => {
dbg!(&e);
Err(Error::new(status, e.to_string()))
}
}
}
macro_rules! map_err {
($val: expr) => {
map_err_inner($val, Status::GenericFailure)
};
($val: expr, $stauts: ident) => {
map_err_inner($val, $stauts)
};
}
macro_rules! napi_wrap {
($( ($name: ident, $target: ident) ),*) => {
$(
#[napi]
pub struct $name($target);
impl std::ops::Deref for $name {
type Target = $target;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<$target> for $name {
fn from(t: $target) -> Self {
Self(t)
}
}
)*
};
}
napi_wrap!(
(Storage, JwstStorage),
(Workspace, JwstWorkspace),
(Doc, YDoc)
);
fn to_update_v1(doc: &YDoc) -> Result<Buffer> {
let trx = doc.transact();
map_err!(trx.encode_state_as_update_v1(&StateVector::default())).map(|update| update.into())
}
#[napi(object)]
pub struct Blob {
pub content_type: String,
pub last_modified: String,
pub size: i64,
pub data: Buffer,
}
#[napi(object)]
pub struct SearchResult {
pub block_id: String,
pub score: f64,
}
impl From<JwstSearchResult> for SearchResult {
fn from(r: JwstSearchResult) -> Self {
Self {
block_id: r.block_id,
score: r.score as f64,
}
}
}
#[napi]
impl Storage {
/// Create a storage instance and establish connection to persist store.
#[napi]
pub async fn connect(database: String, debug_only_auto_migrate: Option<bool>) -> Result<Storage> {
let inner = match if cfg!(debug_assertions) && debug_only_auto_migrate.unwrap_or(false) {
JwstStorage::new_with_migration(&database).await
} else {
JwstStorage::new(&database).await
} {
Ok(storage) => storage,
Err(JwstStorageError::Db(e)) => {
return Err(Error::new(
Status::GenericFailure,
format!("failed to connect to database: {}", e),
))
}
Err(e) => return Err(Error::new(Status::GenericFailure, e.to_string())),
};
Ok(inner.into())
}
/// Get a workspace by id
#[napi]
pub async fn get_workspace(&self, workspace_id: String) -> Result<Option<Workspace>> {
match self.0.get_workspace(workspace_id).await {
Ok(w) => Ok(Some(w.into())),
Err(JwstStorageError::WorkspaceNotFound(_)) => Ok(None),
Err(e) => Err(Error::new(Status::GenericFailure, e.to_string())),
}
}
/// Create a new workspace with a init update.
#[napi]
pub async fn create_workspace(&self, workspace_id: String, init: Buffer) -> Result<Workspace> {
if map_err!(self.0.docs().detect_workspace(&workspace_id).await)? {
return Err(Error::new(
Status::GenericFailure,
format!("Workspace {} already exists", workspace_id),
));
}
let workspace = map_err!(self.0.create_workspace(workspace_id).await)?;
let init = init.as_ref();
let guid = workspace.doc_guid().to_string();
map_err!(self.docs().update_doc(workspace.id(), guid, init).await)?;
Ok(workspace.into())
}
/// Delete a workspace.
#[napi]
pub async fn delete_workspace(&self, workspace_id: String) -> Result<()> {
map_err!(self.docs().delete_workspace(&workspace_id).await)?;
map_err!(self.blobs().delete_workspace(workspace_id).await)
}
/// Sync doc updates.
#[napi]
pub async fn sync(&self, workspace_id: String, guid: String, update: Buffer) -> Result<()> {
let update = update.as_ref();
map_err!(self.docs().update_doc(workspace_id, guid, update).await)
}
/// Sync doc update with doc guid encoded.
#[napi]
pub async fn sync_with_guid(&self, workspace_id: String, update: Buffer) -> Result<()> {
let update = update.as_ref();
map_err!(self.docs().update_doc_with_guid(workspace_id, update).await)
}
/// Load doc as update buffer.
#[napi]
pub async fn load(&self, guid: String) -> Result<Option<Buffer>> {
self.ensure_exists(&guid).await?;
if let Some(doc) = map_err!(self.docs().get_doc(guid).await)? {
Ok(Some(to_update_v1(&doc)?))
} else {
Ok(None)
}
}
/// Fetch a workspace blob.
#[napi]
pub async fn blob(&self, workspace_id: String, name: String) -> Result<Option<Blob>> {
let (id, params) = {
let path = PathBuf::from(name.clone());
let ext = path
.extension()
.and_then(|s| s.to_str().map(|s| s.to_string()));
let id = path
.file_stem()
.and_then(|s| s.to_str().map(|s| s.to_string()))
.unwrap_or(name);
(id, ext.map(|ext| HashMap::from([("format".into(), ext)])))
};
let Ok(meta) = self.blobs().get_metadata(Some(workspace_id.clone()), id.clone(), params.clone()).await else {
return Ok(None);
};
let Ok(file) = self.blobs().get_blob(Some(workspace_id), id, params).await else {
return Ok(None);
};
Ok(Some(Blob {
content_type: meta.content_type,
last_modified: format!("{:?}", meta.last_modified),
size: meta.size,
data: file.into(),
}))
}
/// Upload a blob into workspace storage.
#[napi]
pub async fn upload_blob(&self, workspace_id: String, blob: Buffer) -> Result<String> {
// TODO: can optimize, avoid copy
let blob = blob.as_ref().to_vec();
map_err!(self.blobs().put_blob(Some(workspace_id), blob).await)
}
/// Workspace size taken by blobs.
#[napi]
pub async fn blobs_size(&self, workspace_id: String) -> Result<i64> {
map_err!(self.blobs().get_blobs_size(workspace_id).await)
}
async fn ensure_exists(&self, guid: &str) -> Result<()> {
if map_err!(self.docs().detect_doc(guid).await)? {
Ok(())
} else {
Err(Error::new(
Status::GenericFailure,
format!("Doc {} not exists", guid),
))
}
}
}
#[napi]
impl Workspace {
#[napi(getter)]
pub fn doc(&self) -> Doc {
self.0.doc().into()
}
#[napi]
#[inline]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[napi(getter)]
#[inline]
pub fn id(&self) -> String {
self.0.id()
}
#[napi(getter)]
#[inline]
pub fn client_id(&self) -> String {
self.0.client_id().to_string()
}
#[napi]
pub fn search(&self, query: String) -> Result<Vec<SearchResult>> {
// TODO: search in all subdocs
let result = map_err!(self.0.search(&query))?;
Ok(
result
.into_inner()
.into_iter()
.map(Into::into)
.collect::<Vec<_>>(),
)
}
}
#[napi]
impl Doc {
#[napi(getter)]
pub fn guid(&self) -> String {
self.0.guid().to_string()
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "lib",
"composite": true
},
"include": ["index.d.ts"]
}

View File

@ -79,6 +79,7 @@
"@toeverything/plugin-infra/*": ["./packages/plugin-infra/src/*"],
"@affine/native": ["./packages/native/index.d.ts"],
"@affine/native/*": ["./packages/native/*"],
"@affine/storage": ["./packages/storage/index.d.ts"],
// Development only
"@affine/electron/*": ["./apps/electron/src/*"]

269
yarn.lock
View File

@ -387,6 +387,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@affine/server@workspace:apps/server"
dependencies:
"@affine/storage": "workspace:*"
"@apollo/server": ^4.7.4
"@auth/prisma-adapter": ^1.0.0
"@aws-sdk/client-s3": ^3.359.0
@ -426,6 +427,16 @@ __metadata:
languageName: unknown
linkType: soft
"@affine/storage@workspace:*, @affine/storage@workspace:packages/storage":
version: 0.0.0-use.local
resolution: "@affine/storage@workspace:packages/storage"
dependencies:
"@napi-rs/cli": ^3.0.0-alpha.4
lib0: ^0.2.78
yjs: ^13.6.6
languageName: unknown
linkType: soft
"@affine/storybook@workspace:apps/storybook":
version: 0.0.0-use.local
resolution: "@affine/storybook@workspace:apps/storybook"
@ -7422,6 +7433,25 @@ __metadata:
languageName: node
linkType: hard
"@napi-rs/cli@npm:^3.0.0-alpha.4":
version: 3.0.0-alpha.4
resolution: "@napi-rs/cli@npm:3.0.0-alpha.4"
dependencies:
"@octokit/rest": ^19.0.7
clipanion: ^3.2.0
colorette: ^2.0.19
debug: ^4.3.4
inquirer: ^9.1.5
js-yaml: ^4.1.0
lodash-es: ^4.17.21
typanion: ^3.12.1
bin:
napi: dist/cli.js
napi-raw: cli.mjs
checksum: 961d0f569c73c1dc84e362e2b4baf1fb090ef4fca9a0a53a9b149fd793eeb7e035b66d2cfb79e5c6fc02a1a5ff8732be6d72cca6b2a633b25b7d3cc47b1d983f
languageName: node
linkType: hard
"@napi-rs/image-android-arm-eabi@npm:1.6.1":
version: 1.6.1
resolution: "@napi-rs/image-android-arm-eabi@npm:1.6.1"
@ -8356,6 +8386,151 @@ __metadata:
languageName: node
linkType: hard
"@octokit/auth-token@npm:^3.0.0":
version: 3.0.4
resolution: "@octokit/auth-token@npm:3.0.4"
checksum: 42f533a873d4192e6df406b3176141c1f95287423ebdc4cf23a38bb77ee00ccbc0e60e3fbd5874234fc2ed2e67bbc6035e3b0561dacc1d078adb5c4ced3579e3
languageName: node
linkType: hard
"@octokit/core@npm:^4.2.1":
version: 4.2.1
resolution: "@octokit/core@npm:4.2.1"
dependencies:
"@octokit/auth-token": ^3.0.0
"@octokit/graphql": ^5.0.0
"@octokit/request": ^6.0.0
"@octokit/request-error": ^3.0.0
"@octokit/types": ^9.0.0
before-after-hook: ^2.2.0
universal-user-agent: ^6.0.0
checksum: f82d52e937e12da1c7c163341c845b8e27e7fa75678f5e5954e6fa017a94f1833d6e5c4e43f0be796fbfea9dc5e1137087f655dbd5acb3d57879e1b28568e0a9
languageName: node
linkType: hard
"@octokit/endpoint@npm:^7.0.0":
version: 7.0.6
resolution: "@octokit/endpoint@npm:7.0.6"
dependencies:
"@octokit/types": ^9.0.0
is-plain-object: ^5.0.0
universal-user-agent: ^6.0.0
checksum: 7caebf30ceec50eb7f253341ed419df355232f03d4638a95c178ee96620400db7e4a5e15d89773fe14db19b8653d4ab4cc81b2e93ca0c760b4e0f7eb7ad80301
languageName: node
linkType: hard
"@octokit/graphql@npm:^5.0.0":
version: 5.0.6
resolution: "@octokit/graphql@npm:5.0.6"
dependencies:
"@octokit/request": ^6.0.0
"@octokit/types": ^9.0.0
universal-user-agent: ^6.0.0
checksum: 7be545d348ef31dcab0a2478dd64d5746419a2f82f61459c774602bcf8a9b577989c18001f50b03f5f61a3d9e34203bdc021a4e4d75ff2d981e8c9c09cf8a65c
languageName: node
linkType: hard
"@octokit/openapi-types@npm:^18.0.0":
version: 18.0.0
resolution: "@octokit/openapi-types@npm:18.0.0"
checksum: d487d6c6c1965e583eee417d567e4fe3357a98953fc49bce1a88487e7908e9b5dbb3e98f60dfa340e23b1792725fbc006295aea071c5667a813b9c098185b56f
languageName: node
linkType: hard
"@octokit/plugin-paginate-rest@npm:^6.1.2":
version: 6.1.2
resolution: "@octokit/plugin-paginate-rest@npm:6.1.2"
dependencies:
"@octokit/tsconfig": ^1.0.2
"@octokit/types": ^9.2.3
peerDependencies:
"@octokit/core": ">=4"
checksum: a7b3e686c7cbd27ec07871cde6e0b1dc96337afbcef426bbe3067152a17b535abd480db1861ca28c88d93db5f7bfdbcadd0919ead19818c28a69d0e194038065
languageName: node
linkType: hard
"@octokit/plugin-request-log@npm:^1.0.4":
version: 1.0.4
resolution: "@octokit/plugin-request-log@npm:1.0.4"
peerDependencies:
"@octokit/core": ">=3"
checksum: 2086db00056aee0f8ebd79797b5b57149ae1014e757ea08985b71eec8c3d85dbb54533f4fd34b6b9ecaa760904ae6a7536be27d71e50a3782ab47809094bfc0c
languageName: node
linkType: hard
"@octokit/plugin-rest-endpoint-methods@npm:^7.1.2":
version: 7.2.3
resolution: "@octokit/plugin-rest-endpoint-methods@npm:7.2.3"
dependencies:
"@octokit/types": ^10.0.0
peerDependencies:
"@octokit/core": ">=3"
checksum: 21dfb98514dbe900c29cddb13b335bbce43d613800c6b17eba3c1fd31d17e69c1960f3067f7bf864bb38fdd5043391f4a23edee42729d8c7fbabd00569a80336
languageName: node
linkType: hard
"@octokit/request-error@npm:^3.0.0":
version: 3.0.3
resolution: "@octokit/request-error@npm:3.0.3"
dependencies:
"@octokit/types": ^9.0.0
deprecation: ^2.0.0
once: ^1.4.0
checksum: 5db0b514732686b627e6ed9ef1ccdbc10501f1b271a9b31f784783f01beee70083d7edcfeb35fbd7e569fa31fdd6762b1ff6b46101700d2d97e7e48e749520d0
languageName: node
linkType: hard
"@octokit/request@npm:^6.0.0":
version: 6.2.6
resolution: "@octokit/request@npm:6.2.6"
dependencies:
"@octokit/endpoint": ^7.0.0
"@octokit/request-error": ^3.0.0
"@octokit/types": ^9.0.0
is-plain-object: ^5.0.0
node-fetch: ^2.6.7
universal-user-agent: ^6.0.0
checksum: 0732fb60e20b0348fc61d856c9e234038e34f9ce062b04b968df18c2f106da094055d55c93f653284298b03e6dac2ea5a4b5c5a97686b529ed30447bd37f2773
languageName: node
linkType: hard
"@octokit/rest@npm:^19.0.7":
version: 19.0.11
resolution: "@octokit/rest@npm:19.0.11"
dependencies:
"@octokit/core": ^4.2.1
"@octokit/plugin-paginate-rest": ^6.1.2
"@octokit/plugin-request-log": ^1.0.4
"@octokit/plugin-rest-endpoint-methods": ^7.1.2
checksum: 147518ad51d214ead88adc717b5fdc4f33317949d58c124f4069bdf07d2e6b49fa66861036b9e233aed71fcb88ff367a6da0357653484e466175ab4fb7183b3b
languageName: node
linkType: hard
"@octokit/tsconfig@npm:^1.0.2":
version: 1.0.2
resolution: "@octokit/tsconfig@npm:1.0.2"
checksum: 74d56f3e9f326a8dd63700e9a51a7c75487180629c7a68bbafee97c612fbf57af8347369bfa6610b9268a3e8b833c19c1e4beb03f26db9a9dce31f6f7a19b5b1
languageName: node
linkType: hard
"@octokit/types@npm:^10.0.0":
version: 10.0.0
resolution: "@octokit/types@npm:10.0.0"
dependencies:
"@octokit/openapi-types": ^18.0.0
checksum: 8aafba2ff0cd2435fb70c291bf75ed071c0fa8a865cf6169648732068a35dec7b85a345851f18920ec5f3e94ee0e954988485caac0da09ec3f6781cc44fe153a
languageName: node
linkType: hard
"@octokit/types@npm:^9.0.0, @octokit/types@npm:^9.2.3":
version: 9.3.2
resolution: "@octokit/types@npm:9.3.2"
dependencies:
"@octokit/openapi-types": ^18.0.0
checksum: f55d096aaed3e04b8308d4422104fb888f355988056ba7b7ef0a4c397b8a3e54290d7827b06774dbe0c9ce55280b00db486286954f9c265aa6b03091026d9da8
languageName: node
linkType: hard
"@open-draft/until@npm:^1.0.3":
version: 1.0.3
resolution: "@open-draft/until@npm:1.0.3"
@ -13232,7 +13407,7 @@ __metadata:
languageName: node
linkType: hard
"ansi-escapes@npm:^4.2.1, ansi-escapes@npm:^4.3.0":
"ansi-escapes@npm:^4.2.1, ansi-escapes@npm:^4.3.0, ansi-escapes@npm:^4.3.2":
version: 4.3.2
resolution: "ansi-escapes@npm:4.3.2"
dependencies:
@ -14102,6 +14277,13 @@ __metadata:
languageName: node
linkType: hard
"before-after-hook@npm:^2.2.0":
version: 2.2.3
resolution: "before-after-hook@npm:2.2.3"
checksum: a1a2430976d9bdab4cd89cb50d27fa86b19e2b41812bf1315923b0cba03371ebca99449809226425dd3bcef20e010db61abdaff549278e111d6480034bebae87
languageName: node
linkType: hard
"better-opn@npm:^2.1.1":
version: 2.1.1
resolution: "better-opn@npm:2.1.1"
@ -15019,6 +15201,13 @@ __metadata:
languageName: node
linkType: hard
"cli-width@npm:^4.0.0":
version: 4.0.0
resolution: "cli-width@npm:4.0.0"
checksum: 1ec12311217cc8b2d018646a58b61424d2348def598fb58ba2c32e28f0bcb59a35cef168110311cefe3340abf00e5171b351de6c3e2c084bd1642e6e2a9e144e
languageName: node
linkType: hard
"client-only@npm:0.0.1, client-only@npm:^0.0.1":
version: 0.0.1
resolution: "client-only@npm:0.0.1"
@ -15026,7 +15215,7 @@ __metadata:
languageName: node
linkType: hard
"clipanion@npm:^3.1.0":
"clipanion@npm:^3.1.0, clipanion@npm:^3.2.0":
version: 3.2.1
resolution: "clipanion@npm:3.2.1"
dependencies:
@ -16148,6 +16337,13 @@ __metadata:
languageName: node
linkType: hard
"deprecation@npm:^2.0.0":
version: 2.3.1
resolution: "deprecation@npm:2.3.1"
checksum: f56a05e182c2c195071385455956b0c4106fe14e36245b00c689ceef8e8ab639235176a96977ba7c74afb173317fac2e0ec6ec7a1c6d1e6eaa401c586c714132
languageName: node
linkType: hard
"dequal@npm:^2.0.2, dequal@npm:^2.0.3":
version: 2.0.3
resolution: "dequal@npm:2.0.3"
@ -17450,6 +17646,13 @@ __metadata:
languageName: node
linkType: hard
"escape-string-regexp@npm:^5.0.0":
version: 5.0.0
resolution: "escape-string-regexp@npm:5.0.0"
checksum: 20daabe197f3cb198ec28546deebcf24b3dbb1a5a269184381b3116d12f0532e06007f4bc8da25669d6a7f8efb68db0758df4cd981f57bc5b57f521a3e12c59e
languageName: node
linkType: hard
"escodegen@npm:^2.0.0":
version: 2.0.0
resolution: "escodegen@npm:2.0.0"
@ -18360,6 +18563,16 @@ __metadata:
languageName: node
linkType: hard
"figures@npm:^5.0.0":
version: 5.0.0
resolution: "figures@npm:5.0.0"
dependencies:
escape-string-regexp: ^5.0.0
is-unicode-supported: ^1.2.0
checksum: e6e8b6d1df2f554d4effae4a5ceff5d796f9449f6d4e912d74dab7d5f25916ecda6c305b9084833157d56485a0c78b37164430ddc5675bcee1330e346710669e
languageName: node
linkType: hard
"file-entry-cache@npm:^6.0.1":
version: 6.0.1
resolution: "file-entry-cache@npm:6.0.1"
@ -20327,6 +20540,29 @@ __metadata:
languageName: node
linkType: hard
"inquirer@npm:^9.1.5":
version: 9.2.7
resolution: "inquirer@npm:9.2.7"
dependencies:
ansi-escapes: ^4.3.2
chalk: ^5.2.0
cli-cursor: ^3.1.0
cli-width: ^4.0.0
external-editor: ^3.0.3
figures: ^5.0.0
lodash: ^4.17.21
mute-stream: 1.0.0
ora: ^5.4.1
run-async: ^3.0.0
rxjs: ^7.8.1
string-width: ^4.2.3
strip-ansi: ^6.0.1
through: ^2.3.6
wrap-ansi: ^6.0.1
checksum: 5522fd4af72aec151d92a8156d24ff55c2e5f68177eac1e704a594004b31b3215ff92c59a3105691b8e237640995efd55df0aa2e3b47053fb27768ded760fbf5
languageName: node
linkType: hard
"internal-slot@npm:^1.0.3, internal-slot@npm:^1.0.4, internal-slot@npm:^1.0.5":
version: 1.0.5
resolution: "internal-slot@npm:1.0.5"
@ -20741,7 +20977,7 @@ __metadata:
languageName: node
linkType: hard
"is-plain-object@npm:5.0.0":
"is-plain-object@npm:5.0.0, is-plain-object@npm:^5.0.0":
version: 5.0.0
resolution: "is-plain-object@npm:5.0.0"
checksum: e32d27061eef62c0847d303125440a38660517e586f2f3db7c9d179ae5b6674ab0f469d519b2e25c147a1a3bc87156d0d5f4d8821e0ce4a9ee7fe1fcf11ce45c
@ -20906,7 +21142,7 @@ __metadata:
languageName: node
linkType: hard
"is-unicode-supported@npm:*":
"is-unicode-supported@npm:*, is-unicode-supported@npm:^1.2.0":
version: 1.3.0
resolution: "is-unicode-supported@npm:1.3.0"
checksum: 20a1fc161afafaf49243551a5ac33b6c4cf0bbcce369fcd8f2951fbdd000c30698ce320de3ee6830497310a8f41880f8066d440aa3eb0a853e2aa4836dd89abc
@ -23972,6 +24208,13 @@ __metadata:
languageName: node
linkType: hard
"mute-stream@npm:1.0.0":
version: 1.0.0
resolution: "mute-stream@npm:1.0.0"
checksum: 36fc968b0e9c9c63029d4f9dc63911950a3bdf55c9a87f58d3a266289b67180201cade911e7699f8b2fa596b34c9db43dad37649e3f7fdd13c3bb9edb0017ee7
languageName: node
linkType: hard
"mz@npm:^2.7.0":
version: 2.7.0
resolution: "mz@npm:2.7.0"
@ -27558,6 +27801,13 @@ __metadata:
languageName: node
linkType: hard
"run-async@npm:^3.0.0":
version: 3.0.0
resolution: "run-async@npm:3.0.0"
checksum: 280c03d5a88603f48103fc6fd69f07fb0c392a1e0d319c34ec96a2516030e07ba06f79231a563c78698b882649c2fc1fda601bc84705f57d50efcd1fa506cfc0
languageName: node
linkType: hard
"run-parallel@npm:^1.1.9":
version: 1.2.0
resolution: "run-parallel@npm:1.2.0"
@ -29597,7 +29847,7 @@ __metadata:
languageName: node
linkType: hard
"typanion@npm:^3.8.0":
"typanion@npm:^3.12.1, typanion@npm:^3.8.0":
version: 3.12.1
resolution: "typanion@npm:3.12.1"
checksum: a2e26fa216f8a1dbd2ffbaacb75b1e2dc042a0356e9702fba05a968cad95d9f661b24e37f6c6d8c3adad2c8582c99fca4826ff26a2d07cd2ae617ea87e6187eb
@ -30033,6 +30283,13 @@ __metadata:
languageName: node
linkType: hard
"universal-user-agent@npm:^6.0.0":
version: 6.0.0
resolution: "universal-user-agent@npm:6.0.0"
checksum: 5092bbc80dd0d583cef0b62c17df0043193b74f425112ea6c1f69bc5eda21eeec7a08d8c4f793a277eb2202ffe9b44bec852fa3faff971234cd209874d1b79ef
languageName: node
linkType: hard
"universalify@npm:^0.1.0":
version: 0.1.2
resolution: "universalify@npm:0.1.2"
@ -31038,7 +31295,7 @@ __metadata:
languageName: node
linkType: hard
"wrap-ansi@npm:^6.2.0":
"wrap-ansi@npm:^6.0.1, wrap-ansi@npm:^6.2.0":
version: 6.2.0
resolution: "wrap-ansi@npm:6.2.0"
dependencies: