From 63418d67a1d5eb7543570093d4fd36c079bc6883 Mon Sep 17 00:00:00 2001 From: Alexander Onnikov <aonnikov@hardcoreeng.com> Date: Tue, 10 Dec 2024 14:45:08 +0700 Subject: [PATCH 1/6] UBERF-8842 Improve datalake performance logs (#7406) Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com> --- workers/datalake/src/blob.ts | 88 ++++++++------ workers/datalake/src/db.ts | 112 +++++++++++------- workers/datalake/src/image.ts | 10 +- workers/datalake/src/index.ts | 44 ++++--- .../datalake/src/{measure.ts => metrics.ts} | 95 ++++++++------- workers/datalake/src/multipart.ts | 18 +-- workers/datalake/src/s3.ts | 16 ++- workers/datalake/src/sign.ts | 23 +++- workers/datalake/wrangler.toml | 2 +- 9 files changed, 246 insertions(+), 162 deletions(-) rename workers/datalake/src/{measure.ts => metrics.ts} (67%) diff --git a/workers/datalake/src/blob.ts b/workers/datalake/src/blob.ts index 3fd6fcd16f..1c97a1675b 100644 --- a/workers/datalake/src/blob.ts +++ b/workers/datalake/src/blob.ts @@ -14,15 +14,14 @@ // import { error, json } from 'itty-router' -import { type Sql } from 'postgres' -import db, { withPostgres } from './db' +import { type BlobDB, withPostgres } from './db' import { cacheControl, hashLimit } from './const' import { toUUID } from './encodings' import { getSha256 } from './hash' import { selectStorage } from './storage' import { type BlobRequest, type WorkspaceRequest, type UUID } from './types' import { copyVideo, deleteVideo } from './video' -import { measure, LoggedCache } from './measure' +import { type MetricsContext, LoggedCache } from './metrics' interface BlobMetadata { lastModified: number @@ -36,20 +35,24 @@ export function getBlobURL (request: Request, workspace: string, name: string): return new URL(path, request.url).toString() } -export async function handleBlobGet (request: BlobRequest, env: Env, ctx: ExecutionContext): Promise<Response> { +export async function handleBlobGet ( + request: BlobRequest, + env: Env, + ctx: ExecutionContext, + metrics: MetricsContext +): Promise<Response> { const { workspace, name } = request - const cache = new LoggedCache(caches.default) + const cache = new LoggedCache(caches.default, metrics) const cached = await cache.match(request) if (cached !== undefined) { - console.log({ message: 'cache hit' }) return cached } const { bucket } = selectStorage(env, workspace) - const blob = await withPostgres(env, ctx, (sql) => { - return db.getBlob(sql, { workspace, name }) + const blob = await withPostgres(env, ctx, metrics, (db) => { + return db.getBlob({ workspace, name }) }) if (blob === null || blob.deleted) { return error(404) @@ -72,19 +75,25 @@ export async function handleBlobGet (request: BlobRequest, env: Env, ctx: Execut const response = new Response(object?.body, { headers, status }) if (response.status === 200) { - ctx.waitUntil(cache.put(request, response.clone())) + const clone = metrics.withSync('response.clone', () => response.clone()) + ctx.waitUntil(cache.put(request, clone)) } return response } -export async function handleBlobHead (request: BlobRequest, env: Env, ctx: ExecutionContext): Promise<Response> { +export async function handleBlobHead ( + request: BlobRequest, + env: Env, + ctx: ExecutionContext, + metrics: MetricsContext +): Promise<Response> { const { workspace, name } = request const { bucket } = selectStorage(env, workspace) - const blob = await withPostgres(env, ctx, (sql) => { - return db.getBlob(sql, { workspace, name }) + const blob = await withPostgres(env, ctx, metrics, (db) => { + return db.getBlob({ workspace, name }) }) if (blob === null || blob.deleted) { return error(404) @@ -99,12 +108,17 @@ export async function handleBlobHead (request: BlobRequest, env: Env, ctx: Execu return new Response(null, { headers, status: 200 }) } -export async function handleBlobDelete (request: BlobRequest, env: Env, ctx: ExecutionContext): Promise<Response> { +export async function handleBlobDelete ( + request: BlobRequest, + env: Env, + ctx: ExecutionContext, + metrics: MetricsContext +): Promise<Response> { const { workspace, name } = request try { - await withPostgres(env, ctx, (sql) => { - return Promise.all([db.deleteBlob(sql, { workspace, name }), deleteVideo(env, workspace, name)]) + await withPostgres(env, ctx, metrics, (db) => { + return Promise.all([db.deleteBlob({ workspace, name }), deleteVideo(env, workspace, name)]) }) return new Response(null, { status: 204 }) @@ -118,7 +132,8 @@ export async function handleBlobDelete (request: BlobRequest, env: Env, ctx: Exe export async function handleUploadFormData ( request: WorkspaceRequest, env: Env, - ctx: ExecutionContext + ctx: ExecutionContext, + metrics: MetricsContext ): Promise<Response> { const contentType = request.headers.get('Content-Type') if (contentType === null || !contentType.includes('multipart/form-data')) { @@ -130,7 +145,7 @@ export async function handleUploadFormData ( let formData: FormData try { - formData = await measure('fetch formdata', () => request.formData()) + formData = await metrics.with('request.formData', () => request.formData()) } catch (err: any) { const message = err instanceof Error ? err.message : String(err) console.error({ error: 'failed to parse form data', message }) @@ -146,8 +161,8 @@ export async function handleUploadFormData ( files.map(async ([file, key]) => { const { name, type, lastModified } = file try { - const metadata = await withPostgres(env, ctx, (sql) => { - return saveBlob(env, sql, file.stream(), file.size, type, workspace, name, lastModified) + const metadata = await withPostgres(env, ctx, metrics, (db) => { + return saveBlob(env, db, file.stream(), file.size, type, workspace, name, lastModified) }) // TODO this probably should happen via queue, let it be here for now @@ -170,7 +185,7 @@ export async function handleUploadFormData ( export async function saveBlob ( env: Env, - sql: Sql, + db: BlobDB, stream: ReadableStream, size: number, type: string, @@ -187,17 +202,15 @@ export async function saveBlob ( const [hashStream, uploadStream] = stream.tee() const hash = await getSha256(hashStream) - const data = await db.getData(sql, { hash, location }) + const data = await db.getData({ hash, location }) if (data !== null) { // Lucky boy, nothing to upload, use existing blob - await db.createBlob(sql, { workspace, name, hash, location }) + await db.createBlob({ workspace, name, hash, location }) } else { await bucket.put(filename, uploadStream, { httpMetadata }) - await sql.begin((sql) => [ - db.createData(sql, { hash, location, filename, type, size }), - db.createBlob(sql, { workspace, name, hash, location }) - ]) + await db.createData({ hash, location, filename, type, size }) + await db.createBlob({ workspace, name, hash, location }) } return { type, size, lastModified, name } @@ -205,17 +218,15 @@ export async function saveBlob ( // For large files we cannot calculate checksum beforehead // upload file with unique filename and then obtain checksum const { hash } = await uploadLargeFile(bucket, stream, filename, { httpMetadata }) - const data = await db.getData(sql, { hash, location }) + const data = await db.getData({ hash, location }) if (data !== null) { // We found an existing blob with the same hash // we can safely remove the existing blob from storage - await Promise.all([bucket.delete(filename), db.createBlob(sql, { workspace, name, hash, location })]) + await Promise.all([bucket.delete(filename), db.createBlob({ workspace, name, hash, location })]) } else { // Otherwise register a new hash and blob - await sql.begin((sql) => [ - db.createData(sql, { hash, location, filename, type, size }), - db.createBlob(sql, { workspace, name, hash, location }) - ]) + await db.createData({ hash, location, filename, type, size }) + await db.createBlob({ workspace, name, hash, location }) } return { type, size, lastModified, name } @@ -225,6 +236,7 @@ export async function saveBlob ( export async function handleBlobUploaded ( env: Env, ctx: ExecutionContext, + metrics: MetricsContext, workspace: string, name: string, filename: UUID @@ -238,18 +250,16 @@ export async function handleBlobUploaded ( const hash = object.checksums.md5 !== undefined ? digestToUUID(object.checksums.md5) : (crypto.randomUUID() as UUID) - await withPostgres(env, ctx, async (sql) => { - const data = await db.getData(sql, { hash, location }) + await withPostgres(env, ctx, metrics, async (db) => { + const data = await db.getData({ hash, location }) if (data !== null) { - await Promise.all([bucket.delete(filename), db.createBlob(sql, { workspace, name, hash, location })]) + await Promise.all([bucket.delete(filename), db.createBlob({ workspace, name, hash, location })]) } else { const size = object.size const type = object.httpMetadata?.contentType ?? 'application/octet-stream' - await sql.begin((sql) => [ - db.createData(sql, { hash, location, filename, type, size }), - db.createBlob(sql, { workspace, name, hash, location }) - ]) + await db.createData({ hash, location, filename, type, size }) + await db.createBlob({ workspace, name, hash, location }) } }) } diff --git a/workers/datalake/src/db.ts b/workers/datalake/src/db.ts index 6aad3e3be8..8af838fdb9 100644 --- a/workers/datalake/src/db.ts +++ b/workers/datalake/src/db.ts @@ -14,7 +14,7 @@ // import postgres from 'postgres' -import { measure, measureSync } from './measure' +import { type MetricsContext } from './metrics' import { type Location, type UUID } from './types' export interface BlobDataId { @@ -46,78 +46,91 @@ export interface BlobRecordWithFilename extends BlobRecord { export async function withPostgres<T> ( env: Env, ctx: ExecutionContext, - fn: (sql: postgres.Sql) => Promise<T> + metrics: MetricsContext, + fn: (db: BlobDB) => Promise<T> ): Promise<T> { - const sql = measureSync('db.connect', () => { - return postgres(env.HYPERDRIVE.connectionString) + const sql = metrics.withSync('db.connect', () => { + return postgres(env.HYPERDRIVE.connectionString, { + connection: { + application_name: 'datalake' + } + }) }) + const db = new LoggedDB(new PostgresDB(sql), metrics) + try { - return await fn(sql) + return await fn(db) } finally { - measureSync('db.close', () => { + metrics.withSync('db.disconnect', () => { ctx.waitUntil(sql.end({ timeout: 0 })) }) } } export interface BlobDB { - getData: (sql: postgres.Sql, dataId: BlobDataId) => Promise<BlobDataRecord | null> - createData: (sql: postgres.Sql, data: BlobDataRecord) => Promise<void> - getBlob: (sql: postgres.Sql, blobId: BlobId) => Promise<BlobRecordWithFilename | null> - createBlob: (sql: postgres.Sql, blob: Omit<BlobRecord, 'filename' | 'deleted'>) => Promise<void> - deleteBlob: (sql: postgres.Sql, blob: BlobId) => Promise<void> + getData: (dataId: BlobDataId) => Promise<BlobDataRecord | null> + createData: (data: BlobDataRecord) => Promise<void> + getBlob: (blobId: BlobId) => Promise<BlobRecordWithFilename | null> + createBlob: (blob: Omit<BlobRecord, 'filename' | 'deleted'>) => Promise<void> + deleteBlob: (blob: BlobId) => Promise<void> } -const db: BlobDB = { - async getData (sql: postgres.Sql, dataId: BlobDataId): Promise<BlobDataRecord | null> { +export class PostgresDB implements BlobDB { + constructor (private readonly sql: postgres.Sql) {} + + async getData (dataId: BlobDataId): Promise<BlobDataRecord | null> { const { hash, location } = dataId - const rows = await sql<BlobDataRecord[]>` + const rows = await this.sql<BlobDataRecord[]>` SELECT hash, location, filename, size, type FROM blob.data WHERE hash = ${hash} AND location = ${location} ` return rows.length > 0 ? rows[0] : null - }, + } - async createData (sql: postgres.Sql, data: BlobDataRecord): Promise<void> { + async createData (data: BlobDataRecord): Promise<void> { const { hash, location, filename, size, type } = data - await sql` + await this.sql` UPSERT INTO blob.data (hash, location, filename, size, type) VALUES (${hash}, ${location}, ${filename}, ${size}, ${type}) ` - }, + } - async getBlob (sql: postgres.Sql, blobId: BlobId): Promise<BlobRecordWithFilename | null> { + async getBlob (blobId: BlobId): Promise<BlobRecordWithFilename | null> { const { workspace, name } = blobId - const rows = await sql<BlobRecordWithFilename[]>` - SELECT b.workspace, b.name, b.hash, b.location, b.deleted, d.filename - FROM blob.blob AS b - JOIN blob.data AS d ON b.hash = d.hash AND b.location = d.location - WHERE b.workspace = ${workspace} AND b.name = ${name} - ` + try { + const rows = await this.sql<BlobRecordWithFilename[]>` + SELECT b.workspace, b.name, b.hash, b.location, b.deleted, d.filename + FROM blob.blob AS b + JOIN blob.data AS d ON b.hash = d.hash AND b.location = d.location + WHERE b.workspace = ${workspace} AND b.name = ${name} + ` - if (rows.length > 0) { - return rows[0] + if (rows.length > 0) { + return rows[0] + } + } catch (err) { + console.error(err) } return null - }, + } - async createBlob (sql: postgres.Sql, blob: Omit<BlobRecord, 'filename' | 'deleted'>): Promise<void> { + async createBlob (blob: Omit<BlobRecord, 'filename' | 'deleted'>): Promise<void> { const { workspace, name, hash, location } = blob - await sql` + await this.sql` UPSERT INTO blob.blob (workspace, name, hash, location, deleted) VALUES (${workspace}, ${name}, ${hash}, ${location}, false) ` - }, + } - async deleteBlob (sql: postgres.Sql, blob: BlobId): Promise<void> { + async deleteBlob (blob: BlobId): Promise<void> { const { workspace, name } = blob - await sql` + await this.sql` UPDATE blob.blob SET deleted = true WHERE workspace = ${workspace} AND name = ${name} @@ -125,12 +138,29 @@ const db: BlobDB = { } } -export const measuredDb: BlobDB = { - getData: (sql, dataId) => measure('db.getData', () => db.getData(sql, dataId)), - createData: (sql, data) => measure('db.createData', () => db.createData(sql, data)), - getBlob: (sql, blobId) => measure('db.getBlob', () => db.getBlob(sql, blobId)), - createBlob: (sql, blob) => measure('db.createBlob', () => db.createBlob(sql, blob)), - deleteBlob: (sql, blob) => measure('db.deleteBlob', () => db.deleteBlob(sql, blob)) -} +export class LoggedDB implements BlobDB { + constructor ( + private readonly db: BlobDB, + private readonly ctx: MetricsContext + ) {} -export default measuredDb + async getData (dataId: BlobDataId): Promise<BlobDataRecord | null> { + return await this.ctx.with('db.getData', () => this.db.getData(dataId)) + } + + async createData (data: BlobDataRecord): Promise<void> { + await this.ctx.with('db.createData', () => this.db.createData(data)) + } + + async getBlob (blobId: BlobId): Promise<BlobRecordWithFilename | null> { + return await this.ctx.with('db.getBlob', () => this.db.getBlob(blobId)) + } + + async createBlob (blob: Omit<BlobRecord, 'filename' | 'deleted'>): Promise<void> { + await this.ctx.with('db.createBlob', () => this.db.createBlob(blob)) + } + + async deleteBlob (blob: BlobId): Promise<void> { + await this.ctx.with('db.deleteBlob', () => this.db.deleteBlob(blob)) + } +} diff --git a/workers/datalake/src/image.ts b/workers/datalake/src/image.ts index 1293e5c89f..e39646a3fc 100644 --- a/workers/datalake/src/image.ts +++ b/workers/datalake/src/image.ts @@ -14,11 +14,17 @@ // import { getBlobURL } from './blob' +import { type MetricsContext } from './metrics' import { type BlobRequest } from './types' const prefferedImageFormats = ['webp', 'avif', 'jpeg', 'png'] -export async function handleImageGet (request: BlobRequest): Promise<Response> { +export async function handleImageGet ( + request: BlobRequest, + env: Env, + ctx: ExecutionContext, + metrics: MetricsContext +): Promise<Response> { const { workspace, name, @@ -48,5 +54,5 @@ export async function handleImageGet (request: BlobRequest): Promise<Response> { const blobURL = getBlobURL(request, workspace, name) const imageRequest = new Request(blobURL, { headers: { Accept } }) - return await fetch(imageRequest, { cf: { image, cacheTtl: 3600 } }) + return await metrics.with('image.transform', () => fetch(imageRequest, { cf: { image, cacheTtl: 3600 } })) } diff --git a/workers/datalake/src/index.ts b/workers/datalake/src/index.ts index 4dcf33df06..054ee11cae 100644 --- a/workers/datalake/src/index.ts +++ b/workers/datalake/src/index.ts @@ -14,11 +14,11 @@ // import { WorkerEntrypoint } from 'cloudflare:workers' -import { type IRequestStrict, type RequestHandler, Router, error, html } from 'itty-router' +import { type IRequest, type IRequestStrict, type RequestHandler, Router, error, html } from 'itty-router' import { handleBlobDelete, handleBlobGet, handleBlobHead, handleUploadFormData } from './blob' import { cors } from './cors' -import { LoggedKVNamespace, LoggedR2Bucket, requestTimeAfter, requestTimeBefore } from './measure' +import { LoggedKVNamespace, LoggedR2Bucket, MetricsContext } from './metrics' import { handleImageGet } from './image' import { handleS3Blob } from './s3' import { handleVideoMetaGet } from './video' @@ -36,8 +36,8 @@ const { preflight, corsify } = cors({ }) const router = Router<IRequestStrict, [Env, ExecutionContext], Response>({ - before: [preflight, requestTimeBefore], - finally: [corsify, requestTimeAfter] + before: [preflight], + finally: [corsify] }) const withWorkspace: RequestHandler<WorkspaceRequest> = (request: WorkspaceRequest) => { @@ -88,21 +88,29 @@ router .all('*', () => error(404)) export default class DatalakeWorker extends WorkerEntrypoint<Env> { - constructor (ctx: ExecutionContext, env: Env) { - env = { - ...env, - datalake_blobs: new LoggedKVNamespace(env.datalake_blobs), - DATALAKE_APAC: new LoggedR2Bucket(env.DATALAKE_APAC), - DATALAKE_EEUR: new LoggedR2Bucket(env.DATALAKE_EEUR), - DATALAKE_WEUR: new LoggedR2Bucket(env.DATALAKE_WEUR), - DATALAKE_ENAM: new LoggedR2Bucket(env.DATALAKE_ENAM), - DATALAKE_WNAM: new LoggedR2Bucket(env.DATALAKE_WNAM) - } - super(ctx, env) - } + async fetch (request: IRequest): Promise<Response> { + const start = performance.now() + const context = new MetricsContext() - async fetch (request: Request): Promise<Response> { - return await router.fetch(request, this.env, this.ctx).catch(error) + const env = { + ...this.env, + datalake_blobs: new LoggedKVNamespace(this.env.datalake_blobs, context), + DATALAKE_APAC: new LoggedR2Bucket(this.env.DATALAKE_APAC, context), + DATALAKE_EEUR: new LoggedR2Bucket(this.env.DATALAKE_EEUR, context), + DATALAKE_WEUR: new LoggedR2Bucket(this.env.DATALAKE_WEUR, context), + DATALAKE_ENAM: new LoggedR2Bucket(this.env.DATALAKE_ENAM, context), + DATALAKE_WNAM: new LoggedR2Bucket(this.env.DATALAKE_WNAM, context) + } + + try { + return await router.fetch(request, env, this.ctx, context).catch(error) + } finally { + const total = performance.now() - start + const ops = context.metrics + const url = `${request.method} ${request.url}` + const message = `total=${total} ` + context.toString() + console.log({ message, total, ops, url }) + } } async getBlob (workspace: string, name: string): Promise<ArrayBuffer> { diff --git a/workers/datalake/src/measure.ts b/workers/datalake/src/metrics.ts similarity index 67% rename from workers/datalake/src/measure.ts rename to workers/datalake/src/metrics.ts index 385f3b44e8..589bbb396f 100644 --- a/workers/datalake/src/measure.ts +++ b/workers/datalake/src/metrics.ts @@ -13,42 +13,47 @@ // limitations under the License. // -import { type IRequest, type ResponseHandler, type RequestHandler } from 'itty-router' +export interface MetricsData { + name: string + time: number +} -export async function measure<T> (label: string, fn: () => Promise<T>): Promise<T> { - const start = performance.now() - try { - return await fn() - } finally { - const duration = performance.now() - start - console.log({ stage: label, duration }) +export class MetricsContext { + metrics: Array<MetricsData> = [] + + async with<T>(name: string, fn: () => Promise<T>): Promise<T> { + const start = performance.now() + try { + return await fn() + } finally { + const time = performance.now() - start + this.metrics.push({ name, time }) + } } -} -export function measureSync<T> (label: string, fn: () => T): T { - const start = performance.now() - try { - return fn() - } finally { - const duration = performance.now() - start - console.log({ stage: label, duration }) + withSync<T>(name: string, fn: () => T): T { + const start = performance.now() + try { + return fn() + } finally { + const time = performance.now() - start + this.metrics.push({ name, time }) + } } -} -export const requestTimeBefore: RequestHandler<IRequest> = async (request: IRequest) => { - request.startTime = performance.now() -} - -export const requestTimeAfter: ResponseHandler<Response> = async (response: Response, request: IRequest) => { - const duration = performance.now() - request.startTime - console.log({ stage: 'total', duration }) + toString (): string { + return this.metrics.map((p) => `${p.name}=${p.time}`).join(' ') + } } export class LoggedR2Bucket implements R2Bucket { - constructor (private readonly bucket: R2Bucket) {} + constructor ( + private readonly bucket: R2Bucket, + private readonly ctx: MetricsContext + ) {} async head (key: string): Promise<R2Object | null> { - return await measure('r2.head', () => this.bucket.head(key)) + return await this.ctx.with('r2.head', () => this.bucket.head(key)) } async get ( @@ -57,7 +62,7 @@ export class LoggedR2Bucket implements R2Bucket { onlyIf?: R2Conditional | Headers } ): Promise<R2ObjectBody | null> { - return await measure('r2.get', () => this.bucket.get(key, options)) + return await this.ctx.with('r2.get', () => this.bucket.get(key, options)) } async put ( @@ -67,28 +72,31 @@ export class LoggedR2Bucket implements R2Bucket { onlyIf?: R2Conditional | Headers } ): Promise<R2Object> { - return await measure('r2.put', () => this.bucket.put(key, value, options)) + return await this.ctx.with('r2.put', () => this.bucket.put(key, value, options)) } async createMultipartUpload (key: string, options?: R2MultipartOptions): Promise<R2MultipartUpload> { - return await measure('r2.createMultipartUpload', () => this.bucket.createMultipartUpload(key, options)) + return await this.ctx.with('r2.createMultipartUpload', () => this.bucket.createMultipartUpload(key, options)) } resumeMultipartUpload (key: string, uploadId: string): R2MultipartUpload { - return measureSync('r2.resumeMultipartUpload', () => this.bucket.resumeMultipartUpload(key, uploadId)) + return this.ctx.withSync('r2.resumeMultipartUpload', () => this.bucket.resumeMultipartUpload(key, uploadId)) } async delete (keys: string | string[]): Promise<void> { - await measure('r2.delete', () => this.bucket.delete(keys)) + await this.ctx.with('r2.delete', () => this.bucket.delete(keys)) } async list (options?: R2ListOptions): Promise<R2Objects> { - return await measure('r2.list', () => this.bucket.list(options)) + return await this.ctx.with('r2.list', () => this.bucket.list(options)) } } export class LoggedKVNamespace implements KVNamespace { - constructor (private readonly kv: KVNamespace) {} + constructor ( + private readonly kv: KVNamespace, + private readonly ctx: MetricsContext + ) {} get (key: string, options?: Partial<KVNamespaceGetOptions<undefined>>): Promise<string | null> get (key: string, type: 'text'): Promise<string | null> @@ -100,7 +108,7 @@ export class LoggedKVNamespace implements KVNamespace { get (key: string, options?: KVNamespaceGetOptions<'arrayBuffer'>): Promise<ArrayBuffer | null> get (key: string, options?: KVNamespaceGetOptions<'stream'>): Promise<ReadableStream | null> async get (key: string, options?: any): Promise<any> { - return await measure('kv.get', () => this.kv.get(key, options)) + return await this.ctx.with('kv.get', () => this.kv.get(key, options)) } getWithMetadata<Metadata = unknown>( @@ -140,11 +148,11 @@ export class LoggedKVNamespace implements KVNamespace { options?: KVNamespaceGetOptions<'stream'> ): Promise<KVNamespaceGetWithMetadataResult<ReadableStream, Metadata>> async getWithMetadata (key: string, options?: any): Promise<any> { - return await measure('kv.getWithMetadata', () => this.kv.getWithMetadata(key, options)) + return await this.ctx.with('kv.getWithMetadata', () => this.kv.getWithMetadata(key, options)) } async list<Metadata = unknown>(options?: KVNamespaceListOptions): Promise<KVNamespaceListResult<Metadata, string>> { - return await measure('kv.list', () => this.kv.list(options)) + return await this.ctx.with('kv.list', () => this.kv.list(options)) } async put ( @@ -152,26 +160,29 @@ export class LoggedKVNamespace implements KVNamespace { value: string | ArrayBuffer | ArrayBufferView | ReadableStream, options?: KVNamespacePutOptions ): Promise<void> { - await measure('kv.put', () => this.kv.put(key, value)) + await this.ctx.with('kv.put', () => this.kv.put(key, value)) } async delete (key: string): Promise<void> { - await measure('kv.delete', () => this.kv.delete(key)) + await this.ctx.with('kv.delete', () => this.kv.delete(key)) } } export class LoggedCache implements Cache { - constructor (private readonly cache: Cache) {} + constructor ( + private readonly cache: Cache, + private readonly ctx: MetricsContext + ) {} async match (request: RequestInfo, options?: CacheQueryOptions): Promise<Response | undefined> { - return await measure('cache.match', () => this.cache.match(request, options)) + return await this.ctx.with('cache.match', () => this.cache.match(request, options)) } async delete (request: RequestInfo, options?: CacheQueryOptions): Promise<boolean> { - return await measure('cache.delete', () => this.cache.delete(request, options)) + return await this.ctx.with('cache.delete', () => this.cache.delete(request, options)) } async put (request: RequestInfo, response: Response): Promise<void> { - await measure('cache.put', () => this.cache.put(request, response)) + await this.ctx.with('cache.put', () => this.cache.put(request, response)) } } diff --git a/workers/datalake/src/multipart.ts b/workers/datalake/src/multipart.ts index 6b5558a816..92993be97e 100644 --- a/workers/datalake/src/multipart.ts +++ b/workers/datalake/src/multipart.ts @@ -14,9 +14,10 @@ // import { error, json } from 'itty-router' -import db, { withPostgres } from './db' +import { withPostgres } from './db' import { cacheControl } from './const' import { toUUID } from './encodings' +import { type MetricsContext } from './metrics' import { selectStorage } from './storage' import { type BlobRequest, type UUID } from './types' @@ -82,7 +83,8 @@ export async function handleMultipartUploadPart ( export async function handleMultipartUploadComplete ( request: BlobRequest, env: Env, - ctx: ExecutionContext + ctx: ExecutionContext, + metrics: MetricsContext ): Promise<Response> { const { workspace, name } = request @@ -105,17 +107,15 @@ export async function handleMultipartUploadComplete ( const size = object.size ?? 0 const filename = multipartKey as UUID - await withPostgres(env, ctx, async (sql) => { - const data = await db.getData(sql, { hash, location }) + await withPostgres(env, ctx, metrics, async (db) => { + const data = await db.getData({ hash, location }) if (data !== null) { // blob already exists - await Promise.all([bucket.delete(filename), db.createBlob(sql, { workspace, name, hash, location })]) + await Promise.all([bucket.delete(filename), db.createBlob({ workspace, name, hash, location })]) } else { // Otherwise register a new hash and blob - await sql.begin((sql) => [ - db.createData(sql, { hash, location, filename, type, size }), - db.createBlob(sql, { workspace, name, hash, location }) - ]) + await db.createData({ hash, location, filename, type, size }) + await db.createBlob({ workspace, name, hash, location }) } }) diff --git a/workers/datalake/src/s3.ts b/workers/datalake/src/s3.ts index 062b7714e3..2f1e1d1f47 100644 --- a/workers/datalake/src/s3.ts +++ b/workers/datalake/src/s3.ts @@ -15,8 +15,9 @@ import { AwsClient } from 'aws4fetch' import { error, json } from 'itty-router' -import db, { withPostgres } from './db' +import { withPostgres } from './db' import { saveBlob } from './blob' +import { type MetricsContext } from './metrics' import { type BlobRequest } from './types' export interface S3UploadPayload { @@ -35,16 +36,21 @@ function getS3Client (payload: S3UploadPayload): AwsClient { }) } -export async function handleS3Blob (request: BlobRequest, env: Env, ctx: ExecutionContext): Promise<Response> { +export async function handleS3Blob ( + request: BlobRequest, + env: Env, + ctx: ExecutionContext, + metrics: MetricsContext +): Promise<Response> { const { workspace, name } = request const payload = await request.json<S3UploadPayload>() const client = getS3Client(payload) - return await withPostgres(env, ctx, async (sql) => { + return await withPostgres(env, ctx, metrics, async (db) => { // Ensure the blob does not exist - const blob = await db.getBlob(sql, { workspace, name }) + const blob = await db.getBlob({ workspace, name }) if (blob !== null) { return new Response(null, { status: 200 }) } @@ -65,7 +71,7 @@ export async function handleS3Blob (request: BlobRequest, env: Env, ctx: Executi const contentLength = Number.parseInt(contentLengthHeader) const lastModified = lastModifiedHeader !== null ? new Date(lastModifiedHeader).getTime() : Date.now() - const result = await saveBlob(env, sql, object.body, contentLength, contentType, workspace, name, lastModified) + const result = await saveBlob(env, db, object.body, contentLength, contentType, workspace, name, lastModified) return json(result) }) } diff --git a/workers/datalake/src/sign.ts b/workers/datalake/src/sign.ts index 1ac7c6206c..be6fcfa8d9 100644 --- a/workers/datalake/src/sign.ts +++ b/workers/datalake/src/sign.ts @@ -17,8 +17,9 @@ import { AwsClient } from 'aws4fetch' import { error } from 'itty-router' import { handleBlobUploaded } from './blob' +import { type MetricsContext } from './metrics' +import { type Storage, selectStorage } from './storage' import { type BlobRequest, type UUID } from './types' -import { selectStorage, type Storage } from './storage' const S3_SIGNED_LINK_TTL = 3600 @@ -39,7 +40,12 @@ function getS3Client (storage: Storage): AwsClient { }) } -export async function handleSignCreate (request: BlobRequest, env: Env, ctx: ExecutionContext): Promise<Response> { +export async function handleSignCreate ( + request: BlobRequest, + env: Env, + ctx: ExecutionContext, + metrics: MetricsContext +): Promise<Response> { const { workspace, name } = request const storage = selectStorage(env, workspace) const accountId = env.R2_ACCOUNT_ID @@ -57,7 +63,9 @@ export async function handleSignCreate (request: BlobRequest, env: Env, ctx: Exe try { const client = getS3Client(storage) - signed = await client.sign(new Request(url, { method: 'PUT' }), { aws: { signQuery: true } }) + signed = await metrics.with('s3.sign', () => { + return client.sign(new Request(url, { method: 'PUT' }), { aws: { signQuery: true } }) + }) } catch (err: any) { console.error({ error: 'failed to generate signed url', message: `${err}` }) return error(500, 'failed to generate signed url') @@ -73,7 +81,12 @@ export async function handleSignCreate (request: BlobRequest, env: Env, ctx: Exe return new Response(signed.url, { status: 200, headers }) } -export async function handleSignComplete (request: BlobRequest, env: Env, ctx: ExecutionContext): Promise<Response> { +export async function handleSignComplete ( + request: BlobRequest, + env: Env, + ctx: ExecutionContext, + metrics: MetricsContext +): Promise<Response> { const { workspace, name } = request const { bucket } = selectStorage(env, workspace) @@ -96,7 +109,7 @@ export async function handleSignComplete (request: BlobRequest, env: Env, ctx: E } try { - await handleBlobUploaded(env, ctx, workspace, name, uuid) + await handleBlobUploaded(env, ctx, metrics, workspace, name, uuid) } catch (err) { const message = err instanceof Error ? err.message : String(err) console.error({ error: message, workspace, name, uuid }) diff --git a/workers/datalake/wrangler.toml b/workers/datalake/wrangler.toml index 0b6989c4b0..1008edbbe5 100644 --- a/workers/datalake/wrangler.toml +++ b/workers/datalake/wrangler.toml @@ -95,7 +95,7 @@ r2_buckets = [ ] hyperdrive = [ - { binding = "HYPERDRIVE", id = "055e968f3067414eaa30467d8a9c5021" } + { binding = "HYPERDRIVE", id = "055e968f3067414eaa30467d8a9c5021", localConnectionString = "postgresql://root:roach@localhost:26257/datalake" } ] [env.dev.vars] From a4a458b06caf71b26a31d7a650c5f8c54b0fc7dc Mon Sep 17 00:00:00 2001 From: Alexander Onnikov <aonnikov@hardcoreeng.com> Date: Tue, 10 Dec 2024 14:45:43 +0700 Subject: [PATCH 2/6] qfix: export markup type from api client (#7410) Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com> --- packages/api-client/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index fbd40e2faf..2dd51ba8f5 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -14,5 +14,6 @@ // export * from './client' +export * from './markup/types' export * from './socket' export * from './types' From f1dbf211460cba91f0f6fc9728554f1526fd9c87 Mon Sep 17 00:00:00 2001 From: Chunosov <Chunosov.N@gmail.com> Date: Tue, 10 Dec 2024 14:46:30 +0700 Subject: [PATCH 3/6] Fix drawing bugs and process touch events (#7412) Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com> --- .../src/components/DrawingBoard.svelte | 62 ++-- .../src/components/DrawingBoardToolbar.svelte | 14 + packages/presentation/src/drawing.ts | 342 ++++++++++++------ .../src/components/DrawingBoardEditor.svelte | 107 ++++-- .../components/DrawingBoardNodeView.svelte | 70 +++- 5 files changed, 424 insertions(+), 171 deletions(-) diff --git a/packages/presentation/src/components/DrawingBoard.svelte b/packages/presentation/src/components/DrawingBoard.svelte index 11395bf3b7..1a3aa40b29 100644 --- a/packages/presentation/src/components/DrawingBoard.svelte +++ b/packages/presentation/src/components/DrawingBoard.svelte @@ -16,7 +16,14 @@ import { Analytics } from '@hcengineering/analytics' import { resizeObserver } from '@hcengineering/ui' import { onDestroy } from 'svelte' - import { drawing, type DrawingCmd, type DrawingData, type DrawingTool, type DrawTextCmd } from '../drawing' + import { + drawing, + makeCommandId, + type DrawingCmd, + type DrawingData, + type DrawingTool, + type DrawTextCmd + } from '../drawing' import DrawingBoardToolbar from './DrawingBoardToolbar.svelte' export let active = false @@ -38,7 +45,8 @@ let oldReadonly: boolean let oldDrawings: DrawingData[] let modified = false - let changingCmdIndex: number | undefined + let changingCmdId: string | undefined + let cmdEditor: HTMLDivElement | undefined $: updateToolbarPosition(readonly, board, toolbar) $: updateEditableState(drawings, readonly) @@ -63,14 +71,15 @@ commands = [] } else { // Edit current content as a new drawing - commands = [...commands] + commands = commands.map((cmd) => ({ ...cmd, id: cmd.id ?? makeCommandId() })) } modified = false } } else { commands = undefined } - changingCmdIndex = undefined + changingCmdId = undefined + cmdEditor = undefined oldDrawings = drawings oldReadonly = readonly } @@ -105,33 +114,40 @@ function addCommand (cmd: DrawingCmd): void { if (commands !== undefined) { commands = [...commands, cmd] - changingCmdIndex = undefined + changingCmdId = undefined + cmdEditor = undefined modified = true } } - function showCommandProps (index: number): void { - changingCmdIndex = index - const anyCmd = commands?.[index] - if (anyCmd?.type === 'text') { - const cmd = anyCmd as DrawTextCmd - penColor = cmd.color - fontSize = cmd.fontSize + function showCommandProps (id: string): void { + changingCmdId = id + for (const cmd of commands ?? []) { + if (cmd.id === id) { + if (cmd.type === 'text') { + const textCmd = cmd as DrawTextCmd + penColor = textCmd.color + fontSize = textCmd.fontSize + } + break + } } } - function changeCommand (index: number, cmd: DrawingCmd): void { + function changeCommand (cmd: DrawingCmd): void { if (commands !== undefined) { - commands = commands.map((c, i) => (i === index ? cmd : c)) - changingCmdIndex = undefined + commands = commands.map((c) => (c.id === cmd.id ? cmd : c)) + changingCmdId = undefined + cmdEditor = undefined modified = true } } - function deleteCommand (index: number): void { + function deleteCommand (id: string): void { if (commands !== undefined) { - commands = commands.filter((_, i) => i !== index) - changingCmdIndex = undefined + commands = commands.filter((c) => c.id !== id) + changingCmdId = undefined + cmdEditor = undefined modified = true } } @@ -159,19 +175,23 @@ penWidth, eraserWidth, fontSize, - changingCmdIndex, + changingCmdId, cmdAdded: addCommand, cmdChanging: showCommandProps, cmdChanged: changeCommand, cmdUnchanged: () => { - changingCmdIndex = undefined + changingCmdId = undefined }, - cmdDeleted: deleteCommand + cmdDeleted: deleteCommand, + editorCreated: (editor) => { + cmdEditor = editor + } }} > {#if !readonly} <DrawingBoardToolbar placeInside={toolbarInside} + {cmdEditor} bind:toolbar bind:tool bind:penColor diff --git a/packages/presentation/src/components/DrawingBoardToolbar.svelte b/packages/presentation/src/components/DrawingBoardToolbar.svelte index 8106dad2f6..b6026ed196 100644 --- a/packages/presentation/src/components/DrawingBoardToolbar.svelte +++ b/packages/presentation/src/components/DrawingBoardToolbar.svelte @@ -53,6 +53,7 @@ export let placeInside = false export let showPanTool = false export let toolbar: HTMLDivElement | undefined + export let cmdEditor: HTMLDivElement | undefined let colorSelector: HTMLInputElement let penColors: string[] = defaultColors @@ -91,12 +92,14 @@ penColors = penColors.filter((c: string) => c !== penColor) localStorage.setItem(storageKey.colors, JSON.stringify(penColors)) selectColor(penColors[0]) + focusEditor() break } case 'reset-colors': { penColors = defaultColors localStorage.removeItem(storageKey.colors) selectColor(penColors[0]) + focusEditor() break } case undefined: { @@ -115,6 +118,7 @@ penColors = [...penColors, penColor] localStorage.setItem(storageKey.colors, JSON.stringify(penColors)) } + focusEditor() } function selectColor (color: string): void { @@ -148,6 +152,15 @@ function updateFontSize (): void { localStorage.setItem(storageKey.fontSize, fontSize.toString()) + focusEditor() + } + + function focusEditor (): void { + setTimeout(() => { + if (cmdEditor !== undefined) { + cmdEditor.focus() + } + }, 100) } </script> @@ -243,6 +256,7 @@ tool = 'pen' } selectColor(color) + focusEditor() }} > <div slot="content" class="colorIcon" style:background={color} /> diff --git a/packages/presentation/src/drawing.ts b/packages/presentation/src/drawing.ts index 8d0ca8eac9..59e20f1232 100644 --- a/packages/presentation/src/drawing.ts +++ b/packages/presentation/src/drawing.ts @@ -30,16 +30,18 @@ export interface DrawingProps { eraserWidth?: number fontSize?: number defaultCursor?: string - changingCmdIndex?: number + changingCmdId?: string cmdAdded?: (cmd: DrawingCmd) => void - cmdChanging?: (index: number) => void - cmdUnchanged?: (index: number) => void - cmdChanged?: (index: number, cmd: DrawingCmd) => void - cmdDeleted?: (index: number) => void + cmdChanging?: (id: string) => void + cmdUnchanged?: (id: string) => void + cmdChanged?: (cmd: DrawingCmd) => void + cmdDeleted?: (id: string) => void + editorCreated?: (editor: HTMLDivElement) => void panned?: (offset: Point) => void } export interface DrawingCmd { + id: string type: 'line' | 'text' } @@ -71,6 +73,10 @@ function avgPoint (p1: Point, p2: Point): Point { const maxTextLength = 500 +export const makeCommandId = (): string => { + return crypto.randomUUID().toString() +} + const crossSvg = `<svg height="8" width="8" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <path d="m1.29 2.71 5.3 5.29-5.3 5.29c-.92.92.49 2.34 1.41 1.41l5.3-5.29 5.29 5.3c.92.92 2.34-.49 1.41-1.41l-5.29-5.3 5.3-5.29c.92-.93-.49-2.34-1.42-1.42l-5.29 5.3-5.29-5.3c-.93-.92-2.34.49-1.42 1.42z"/> </svg>` @@ -294,13 +300,15 @@ export function drawing ( draw.eraserWidth = props.eraserWidth ?? draw.eraserWidth draw.fontSize = props.fontSize ?? draw.fontSize draw.offset = props.offset ?? draw.offset + updateCanvasCursor() + updateCanvasTouchAction() interface LiveTextBox { pos: Point box: HTMLDivElement editor: HTMLDivElement - cmdIndex: number + cmdId: string } let liveTextBox: LiveTextBox | undefined @@ -328,6 +336,61 @@ export function drawing ( }) resizeObserver.observe(canvas) + let touchId: number | undefined + + function findTouch (touches: TouchList, id: number | undefined = touchId): Touch | undefined { + for (let i = 0; i < touches.length; i++) { + const touch = touches[i] + if (touch.identifier === id) { + return touch + } + } + } + + function touchToNodePoint (touch: Touch, node: HTMLElement): Point { + const rect = node.getBoundingClientRect() + return { + x: touch.clientX - rect.left, + y: touch.clientY - rect.top + } + } + + function pointerToNodePoint (e: PointerEvent): Point { + return { x: e.offsetX, y: e.offsetY } + } + + canvas.ontouchstart = (e) => { + if (readonly) { + return + } + const touch = e.changedTouches[0] + touchId = touch.identifier + drawStart(touchToNodePoint(touch, canvas)) + } + + canvas.ontouchmove = (e) => { + if (readonly) { + return + } + const touch = findTouch(e.changedTouches) + if (touch !== undefined) { + drawContinue(touchToNodePoint(touch, canvas)) + } + } + + canvas.ontouchend = (e) => { + if (readonly) { + return + } + const touch = findTouch(e.changedTouches) + if (touch !== undefined) { + drawEnd(touchToNodePoint(touch, canvas)) + } + touchId = undefined + } + + canvas.ontouchcancel = canvas.ontouchend + canvas.onpointerdown = (e) => { if (readonly) { return @@ -337,16 +400,7 @@ export function drawing ( } e.preventDefault() canvas.setPointerCapture(e.pointerId) - - const x = e.offsetX - const y = e.offsetY - - draw.on = true - draw.points = [] - prevPos = { x, y } - if (draw.isDrawingTool()) { - draw.addPoint(x, y) - } + drawStart(pointerToNodePoint(e)) } canvas.onpointermove = (e) => { @@ -354,35 +408,7 @@ export function drawing ( return } e.preventDefault() - - const x = e.offsetX - const y = e.offsetY - - if (draw.isDrawingTool()) { - const w = draw.cursorWidth() - canvasCursor.style.left = `${x - w / 2}px` - canvasCursor.style.top = `${y - w / 2}px` - if (draw.on) { - if (Math.hypot(prevPos.x - x, prevPos.y - y) < draw.minLineLength) { - return - } - draw.drawLive(x, y) - prevPos = { x, y } - } - } - - if (draw.on && draw.tool === 'pan') { - requestAnimationFrame(() => { - draw.offset.x += x - prevPos.x - draw.offset.y += y - prevPos.y - replayCommands() - prevPos = { x, y } - }) - } - - if (draw.on && draw.tool === 'text') { - prevPos = { x, y } - } + drawContinue(pointerToNodePoint(e)) } canvas.onpointerup = (e) => { @@ -391,24 +417,11 @@ export function drawing ( } e.preventDefault() canvas.releasePointerCapture(e.pointerId) - if (draw.on) { - if (draw.isDrawingTool()) { - draw.drawLive(e.offsetX, e.offsetY, true) - storeLineCommand() - } else if (draw.tool === 'pan') { - props.panned?.(draw.offset) - } else if (draw.tool === 'text') { - if (liveTextBox !== undefined) { - storeTextCommand() - } else { - const cmdIndex = findTextCommand(prevPos) - props.cmdChanging?.(cmdIndex) - } - } - draw.on = false - } + drawEnd(pointerToNodePoint(e)) } + canvas.onpointercancel = canvas.onpointerup + canvas.onpointerenter = () => { if (!readonly && draw.isDrawingTool()) { canvasCursor.style.visibility = 'visible' @@ -421,26 +434,86 @@ export function drawing ( } } - function findTextCommand (mousePos: Point): number { + function drawStart (p: Point): void { + draw.on = true + draw.points = [] + prevPos = p + if (draw.isDrawingTool()) { + draw.addPoint(p.x, p.y) + } + } + + function drawContinue (p: Point): void { + if (draw.isDrawingTool()) { + const w = draw.cursorWidth() + canvasCursor.style.left = `${p.x - w / 2}px` + canvasCursor.style.top = `${p.y - w / 2}px` + if (draw.on) { + if (Math.hypot(prevPos.x - p.x, prevPos.y - p.y) < draw.minLineLength) { + return + } + draw.drawLive(p.x, p.y) + prevPos = p + } + } + + if (draw.on && draw.tool === 'pan') { + requestAnimationFrame(() => { + draw.offset.x += p.x - prevPos.x + draw.offset.y += p.y - prevPos.y + replayCommands() + prevPos = p + }) + } + + if (draw.on && draw.tool === 'text') { + prevPos = p + } + } + + function drawEnd (p: Point): void { + if (draw.on) { + if (draw.isDrawingTool()) { + draw.drawLive(p.x, p.y, true) + storeLineCommand() + } else if (draw.tool === 'pan') { + props.panned?.(draw.offset) + } else if (draw.tool === 'text') { + if (liveTextBox !== undefined) { + storeTextCommand() + closeLiveTextBox() + } else { + const cmd = findTextCommand(prevPos) + props.cmdChanging?.(cmd?.id ?? '') + } + } + draw.on = false + } + } + + function findTextCommand (mousePos: Point): DrawTextCmd | undefined { const pos = draw.mouseToCanvasPoint(mousePos) for (let i = commands.length - 1; i >= 0; i--) { const anyCmd = commands[i] if (anyCmd.type === 'text') { const cmd = anyCmd as DrawTextCmd if (draw.isPointInText(pos, cmd)) { - return i + return cmd } } } - return -1 + return undefined } - function makeLiveTextBox (cmdIndex: number): void { + function makeLiveTextBox (cmdId: string): void { let pos = prevPos let existingCmd: DrawTextCmd | undefined - if (cmdIndex >= 0 && commands[cmdIndex]?.type === 'text') { - existingCmd = commands[cmdIndex] as DrawTextCmd - pos = draw.canvasToMousePoint(existingCmd.pos) + for (const cmd of commands) { + if (cmd.id === cmdId && cmd.type === 'text') { + existingCmd = cmd as DrawTextCmd + pos = draw.canvasToMousePoint(existingCmd.pos) + break + } } const padding = 6 @@ -455,6 +528,7 @@ export function drawing ( box.style.borderRadius = 'var(--small-BorderRadius)' box.style.padding = `${padding}px` box.style.background = 'var(--theme-popup-header)' + box.style.touchAction = 'none' box.addEventListener('mousedown', (e) => { e.stopPropagation() }) @@ -513,19 +587,18 @@ export function drawing ( if (e.key === 'Escape') { e.preventDefault() if (liveTextBox !== undefined) { - const cmdIndex = liveTextBox.cmdIndex - if (cmdIndex >= 0) { - // reset changingCmdIndex in clients - setTimeout(() => { - props.cmdUnchanged?.(cmdIndex) - }, 0) - } + const cmdId = liveTextBox.cmdId + // reset changingCmdId in clients + setTimeout(() => { + props.cmdUnchanged?.(cmdId) + }, 0) } closeLiveTextBox() replayCommands() } else if (e.key === 'Enter' && e.ctrlKey) { e.preventDefault() storeTextCommand() + closeLiveTextBox() } }) box.appendChild(editor) @@ -562,44 +635,43 @@ export function drawing ( return handle } + const moveTextBox = (dx: number, dy: number): void => { + let newX = box.offsetLeft + dx + let newY = box.offsetTop + dy + // For screenshots the canvas always has the same size as the underlying image + // and we should not be able to drag the text box outside of the screenshot + if (props.autoSize !== true) { + newX = Math.max(0, newX) + newY = Math.max(0, newY) + if (newX + box.offsetWidth > node.clientWidth) { + newX = node.clientWidth - box.offsetWidth + } + if (newY + box.offsetHeight > node.clientHeight) { + newY = node.clientHeight - box.offsetHeight + } + } + box.style.left = `${newX}px` + box.style.top = `${newY}px` + if (liveTextBox !== undefined) { + liveTextBox.pos.x = newX + padding + liveTextBox.pos.y = newY + padding + } + } + const dragHandle = makeHandle() dragHandle.style.left = `-${handleSize / 2}px` dragHandle.style.cursor = 'grab' + dragHandle.style.touchAction = 'none' dragHandle.addEventListener('pointerdown', (e) => { e.preventDefault() dragHandle.style.cursor = 'grabbing' dragHandle.setPointerCapture(e.pointerId) - const x = e.clientX - const y = e.clientY - const dragStart = { x, y } + let prevPos = { x: e.clientX, y: e.clientY } const pointerMove = (e: PointerEvent): void => { e.preventDefault() - const x = e.clientX - const y = e.clientY - const dx = x - dragStart.x - const dy = y - dragStart.y - dragStart.x = x - dragStart.y = y - let newX = box.offsetLeft + dx - let newY = box.offsetTop + dy - // For screenshots the canvas always has the same size as the underlying image - // and we should not be able to drag the text box outside of the screenshot - if (props.autoSize !== true) { - newX = Math.max(0, newX) - newY = Math.max(0, newY) - if (newX + box.offsetWidth > node.clientWidth) { - newX = node.clientWidth - box.offsetWidth - } - if (newY + box.offsetHeight > node.clientHeight) { - newY = node.clientHeight - box.offsetHeight - } - } - box.style.left = `${newX}px` - box.style.top = `${newY}px` - if (liveTextBox !== undefined) { - liveTextBox.pos.x = newX + padding - liveTextBox.pos.y = newY + padding - } + const p = { x: e.clientX, y: e.clientY } + moveTextBox(p.x - prevPos.x, p.y - prevPos.y) + prevPos = p } const pointerUp = (e: PointerEvent): void => { setTimeout(() => { @@ -610,9 +682,37 @@ export function drawing ( dragHandle.releasePointerCapture(e.pointerId) dragHandle.removeEventListener('pointermove', pointerMove) dragHandle.removeEventListener('pointerup', pointerUp) + dragHandle.removeEventListener('pointercancel', pointerUp) } dragHandle.addEventListener('pointermove', pointerMove) dragHandle.addEventListener('pointerup', pointerUp) + dragHandle.addEventListener('pointercancel', pointerUp) + }) + dragHandle.addEventListener('touchstart', (e) => { + dragHandle.style.cursor = 'grabbing' + const touch = e.changedTouches[0] + const touchId = touch.identifier + let prevPos = touchToNodePoint(touch, dragHandle) + const touchMove = (e: TouchEvent): void => { + const touch = findTouch(e.changedTouches, touchId) + if (touch !== undefined) { + const p = touchToNodePoint(touch, dragHandle) + moveTextBox(p.x - prevPos.x, p.y - prevPos.y) + prevPos = p + } + } + const touchEnd = (e: TouchEvent): void => { + setTimeout(() => { + editor.focus() + }, 100) + dragHandle.style.cursor = 'grab' + dragHandle.removeEventListener('touchmove', touchMove) + dragHandle.removeEventListener('touchend', touchEnd) + dragHandle.removeEventListener('touchcancel', touchEnd) + } + dragHandle.addEventListener('touchmove', touchMove) + dragHandle.addEventListener('touchend', touchEnd) + dragHandle.addEventListener('touchcancel', touchEnd) }) box.appendChild(dragHandle) @@ -622,20 +722,21 @@ export function drawing ( deleteButton.innerHTML = crossSvg deleteButton.addEventListener('click', () => { node.removeChild(box) - if (liveTextBox?.cmdIndex !== undefined) { - props.cmdDeleted?.(liveTextBox.cmdIndex) + if (liveTextBox?.cmdId !== undefined) { + props.cmdDeleted?.(liveTextBox.cmdId) } liveTextBox = undefined }) box.appendChild(deleteButton) node.appendChild(box) - liveTextBox = { box, editor, pos, cmdIndex } + liveTextBox = { box, editor, pos, cmdId } updateLiveTextBox() setTimeout(() => { editor.focus() }, 100) selectAll() + props.editorCreated?.(editor) } function updateLiveTextBox (): void { @@ -658,7 +759,9 @@ export function drawing ( if (liveTextBox !== undefined) { const text = (liveTextBox.editor.innerText ?? '').trim() if (text !== '') { + const cmdId = liveTextBox.cmdId const cmd: DrawTextCmd = { + id: cmdId === '' ? makeCommandId() : cmdId, type: 'text', text, pos: draw.mouseToCanvasPoint(liveTextBox.pos), @@ -666,10 +769,9 @@ export function drawing ( fontFace: draw.fontFace, color: draw.penColor } - const cmdIndex = liveTextBox.cmdIndex const notify = (): void => { - if (cmdIndex >= 0) { - props.cmdChanged?.(cmdIndex, cmd) + if (cmdId !== '') { + props.cmdChanged?.(cmd) } else { props.cmdAdded?.(cmd) } @@ -680,7 +782,7 @@ export function drawing ( notify() } } else { - props.cmdUnchanged?.(liveTextBox.cmdIndex) + props.cmdUnchanged?.(liveTextBox.cmdId) } } } @@ -689,6 +791,7 @@ export function drawing ( if (draw.points.length > 0) { const erasing = draw.tool === 'erase' const cmd: DrawLineCmd = { + id: makeCommandId(), type: 'line', lineWidth: erasing ? draw.eraserWidth : draw.penWidth, erasing, @@ -726,13 +829,17 @@ export function drawing ( } } + function updateCanvasTouchAction (): void { + canvas.style.touchAction = readonly ? 'unset' : 'none' + } + function replayCommands (): void { draw.ctx.reset() - for (let i = 0; i < commands.length; i++) { - if (liveTextBox?.cmdIndex === i) { + for (const cmd of commands) { + if (cmd.id !== undefined && liveTextBox?.cmdId === cmd.id) { continue } - draw.drawCommand(commands[i]) + draw.drawCommand(cmd) } } @@ -774,9 +881,10 @@ export function drawing ( } if (props.readonly !== readonly) { readonly = props.readonly ?? false + updateCanvasTouchAction() updateCursor = true } - if (props.changingCmdIndex === undefined) { + if (props.changingCmdId === undefined) { if (liveTextBox !== undefined) { storeTextCommand(true) closeLiveTextBox() @@ -784,9 +892,9 @@ export function drawing ( } } else { if (liveTextBox === undefined) { - makeLiveTextBox(props.changingCmdIndex) + makeLiveTextBox(props.changingCmdId) replay = true - } else if (liveTextBox.cmdIndex !== props.changingCmdIndex) { + } else if (liveTextBox.cmdId !== props.changingCmdId) { storeTextCommand(true) closeLiveTextBox() replay = true diff --git a/plugins/text-editor-resources/src/components/DrawingBoardEditor.svelte b/plugins/text-editor-resources/src/components/DrawingBoardEditor.svelte index 99ab7799e4..53dd84f654 100644 --- a/plugins/text-editor-resources/src/components/DrawingBoardEditor.svelte +++ b/plugins/text-editor-resources/src/components/DrawingBoardEditor.svelte @@ -13,7 +13,14 @@ // limitations under the License. --> <script lang="ts"> - import { DrawingBoardToolbar, DrawingCmd, DrawingTool, DrawTextCmd, drawing } from '@hcengineering/presentation' + import { + DrawingBoardToolbar, + DrawingCmd, + DrawingTool, + DrawTextCmd, + drawing, + makeCommandId + } from '@hcengineering/presentation' import { Loading } from '@hcengineering/ui' import { onMount, onDestroy } from 'svelte' import { Array as YArray, Map as YMap } from 'yjs' @@ -34,11 +41,14 @@ let fontSize: number let commands: DrawingCmd[] = [] let offset: { x: number, y: number } = { x: 0, y: 0 } - let changingCmdIndex: number | undefined + let changingCmdId: string | undefined + let cmdEditor: HTMLDivElement | undefined let toolbar: HTMLDivElement let oldSelected = false + let oldReadonly = false $: onSelectedChanged(selected) + $: onReadonlyChanged(readonly) function listenSavedCommands (): void { commands = savedCmds.toArray() @@ -50,25 +60,79 @@ // offset = savedProps.get('offset') } - function showCommandProps (index: number): void { - changingCmdIndex = index - const anyCmd = commands[index] - if (anyCmd?.type === 'text') { - const cmd = anyCmd as DrawTextCmd - penColor = cmd.color - fontSize = cmd.fontSize + function showCommandProps (id: string): void { + changingCmdId = id + for (const cmd of commands) { + if (cmd.id === id) { + if (cmd.type === 'text') { + const textCmd = cmd as DrawTextCmd + penColor = textCmd.color + fontSize = textCmd.fontSize + } + break + } } } + function changeCommand (cmd: DrawingCmd): void { + let index = -1 + for (let i = 0; i < savedCmds.length; i++) { + if (savedCmds.get(i).id === cmd.id) { + savedCmds.delete(i) + index = i + break + } + } + if (index >= 0) { + savedCmds.insert(index, [cmd]) + } else { + savedCmds.push([cmd]) + } + changingCmdId = undefined + cmdEditor = undefined + } + + function deleteCommand (id: string): void { + for (let i = 0; i < savedCmds.length; i++) { + if (savedCmds.get(i).id === id) { + savedCmds.delete(i) + break + } + } + changingCmdId = undefined + cmdEditor = undefined + } + function onSelectedChanged (selected: boolean): void { if (oldSelected !== selected) { - if (oldSelected && !selected && changingCmdIndex !== undefined) { - changingCmdIndex = undefined + if (oldSelected && !selected && changingCmdId !== undefined) { + changingCmdId = undefined + cmdEditor = undefined } oldSelected = selected } } + function onReadonlyChanged (readonly: boolean): void { + if (oldReadonly !== readonly) { + if (!readonly) { + let allHaveIds = true + for (let i = 0; i < savedCmds.length; i++) { + if (savedCmds.get(i).id === undefined) { + allHaveIds = false + break + } + } + if (!allHaveIds) { + const cmds = savedCmds.toArray() + savedCmds.delete(0, savedCmds.length) + savedCmds.push(cmds.map((cmd) => ({ ...cmd, id: cmd.id ?? makeCommandId() }))) + } + } + oldReadonly = readonly + } + } + onMount(() => { commands = savedCmds.toArray() // offset = savedProps.get('offset') @@ -109,27 +173,23 @@ penWidth, eraserWidth, fontSize, - changingCmdIndex, + changingCmdId, cmdAdded: (cmd) => { savedCmds.push([cmd]) - changingCmdIndex = undefined + changingCmdId = undefined }, cmdChanging: showCommandProps, - cmdChanged: (index, cmd) => { - savedCmds.delete(index) - savedCmds.insert(index, [cmd]) - changingCmdIndex = undefined - }, + cmdChanged: changeCommand, cmdUnchanged: () => { - changingCmdIndex = undefined - }, - cmdDeleted: (index) => { - savedCmds.delete(index) - changingCmdIndex = undefined + changingCmdId = undefined }, + cmdDeleted: deleteCommand, panned: (newOffset) => { offset = newOffset // savedProps.set('offset', offset) + }, + editorCreated: (editor) => { + cmdEditor = editor } }} > @@ -142,6 +202,7 @@ <DrawingBoardToolbar placeInside={true} showPanTool={true} + {cmdEditor} bind:toolbar bind:tool bind:penColor diff --git a/plugins/text-editor-resources/src/components/DrawingBoardNodeView.svelte b/plugins/text-editor-resources/src/components/DrawingBoardNodeView.svelte index b0987dffbb..5b04ef2641 100644 --- a/plugins/text-editor-resources/src/components/DrawingBoardNodeView.svelte +++ b/plugins/text-editor-resources/src/components/DrawingBoardNodeView.svelte @@ -35,23 +35,43 @@ let resizer: HTMLElement let startY: number let resizedHeight: number | undefined + let resizerTouchId: number | undefined let loading = true let loadingTimer: any + function resizeStart (y: number): void { + const height = node.attrs.height ?? defaultHeight + startY = y - height + resizedHeight = height + } + + function resizeContinue (y: number): void { + resizedHeight = Math.max(minHeight, y - startY) + resizedHeight = Math.min(maxHeight, resizedHeight) + } + + function resizeFinish (): void { + if (resizedHeight !== undefined) { + if (typeof getPos === 'function') { + const tr = editor.state.tr.setNodeMarkup(getPos(), undefined, { ...node.attrs, height: resizedHeight }) + editor.view.dispatch(tr) + } + resizedHeight = undefined + } + } + function onResizerPointerDown (e: PointerEvent): void { e.preventDefault() - const height = node.attrs.height ?? defaultHeight - startY = e.clientY - height - resizedHeight = height resizer.setPointerCapture(e.pointerId) resizer.addEventListener('pointermove', onResizerPointerMove) resizer.addEventListener('pointerup', onResizerPointerUp) + resizer.addEventListener('pointercancel', onResizerPointerUp) + resizeStart(e.clientY) } function onResizerPointerMove (e: PointerEvent): void { e.preventDefault() - resizedHeight = Math.max(minHeight, e.clientY - startY) - resizedHeight = Math.min(maxHeight, resizedHeight) + resizeContinue(e.clientY) } function onResizerPointerUp (e: PointerEvent): void { @@ -59,11 +79,35 @@ resizer.releasePointerCapture(e.pointerId) resizer.removeEventListener('pointermove', onResizerPointerMove) resizer.removeEventListener('pointerup', onResizerPointerUp) - if (typeof getPos === 'function') { - const tr = editor.state.tr.setNodeMarkup(getPos(), undefined, { ...node.attrs, height: resizedHeight }) - editor.view.dispatch(tr) + resizer.removeEventListener('pointercancel', onResizerPointerUp) + resizeFinish() + } + + function onResizerTouchStart (e: TouchEvent): void { + const touch = e.changedTouches[0] + resizerTouchId = touch.identifier + resizer.addEventListener('touchmove', onResizerTouchMove) + resizer.addEventListener('touchend', onResizerTouchEnd) + resizer.addEventListener('touchcancel', onResizerTouchEnd) + resizeStart(touch.clientY) + } + + function onResizerTouchMove (e: TouchEvent): void { + for (let i = 0; i < e.changedTouches.length; i++) { + const touch = e.changedTouches[i] + if (touch.identifier === resizerTouchId) { + resizeContinue(touch.clientY) + return + } } - resizedHeight = undefined + } + + function onResizerTouchEnd (): void { + resizer.removeEventListener('touchmove', onResizerTouchMove) + resizer.removeEventListener('touchend', onResizerTouchEnd) + resizer.removeEventListener('touchcancel', onResizerTouchEnd) + resizerTouchId = undefined + resizeFinish() } onMount(() => { @@ -110,7 +154,12 @@ /> </div> {#if selected} - <div class="handle resizer" bind:this={resizer} on:pointerdown={onResizerPointerDown}> + <div + class="handle resizer" + bind:this={resizer} + on:pointerdown={onResizerPointerDown} + on:touchstart={onResizerTouchStart} + > <svg viewBox="0 0 60 4" height="4" width="60" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <path d="m60 2a2 2 0 0 1 -2 2 2 2 0 0 1 -2-2 2 2 0 0 1 2-2 2 2 0 0 1 2 2zm-8 0a2 2 0 0 1 -2 2 2 2 0 0 1 -2-2 2 2 0 0 1 2-2 2 2 0 0 1 2 2zm-8 0a2 2 0 0 1 -2 2 2 2 0 0 1 -2-2 2 2 0 0 1 2-2 2 2 0 0 1 2 2zm-8 0a2 2 0 0 1 -2 2 2 2 0 0 1 -2-2 2 2 0 0 1 2-2 2 2 0 0 1 2 2zm-8 0a2 2 0 0 1 -2 2 2 2 0 0 1 -2-2 2 2 0 0 1 2-2 2 2 0 0 1 2 2zm-8 0a2 2 0 0 1 -2 2 2 2 0 0 1 -2-2 2 2 0 0 1 2-2 2 2 0 0 1 2 2zm-8 0a2 2 0 0 1 -2 2 2 2 0 0 1 -2-2 2 2 0 0 1 2-2 2 2 0 0 1 2 2zm-8 0a2 2 0 0 1 -2 2 2 2 0 0 1 -2-2 2 2 0 0 1 2-2 2 2 0 0 1 2 2z" @@ -147,6 +196,7 @@ align-items: center; justify-content: center; opacity: 0.5; + touch-action: none; &:hover { opacity: 1; From cb9720f340baa19966bdf8dccfbcbc0f36922299 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev <haiodo@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:37:59 +0700 Subject: [PATCH 4/6] UBERF-8856: Fix space security query and schema update (#7413) Signed-off-by: Andrey Sobolev <haiodo@gmail.com> --- dev/tool/src/clean.ts | 12 ++++-- models/activity/src/migration.ts | 6 --- models/attachment/src/migration.ts | 6 --- models/chunter/src/migration.ts | 6 --- models/contact/src/migration.ts | 9 +--- models/core/src/migration.ts | 8 ++-- models/document/src/migration.ts | 6 --- models/drive/src/migration.ts | 6 --- models/notification/src/migration.ts | 6 --- models/task/src/migration.ts | 6 --- models/time/src/migration.ts | 6 --- models/view/src/migration.ts | 6 --- packages/core/src/client.ts | 6 ++- pods/backup/package.json | 1 + server/middleware/src/spaceSecurity.ts | 57 +++++++++++++------------- server/mongo/src/storage.ts | 7 ++-- server/postgres/src/schemas.ts | 42 ++++++++++++++++++- server/postgres/src/storage.ts | 11 ++--- server/postgres/src/utils.ts | 2 +- server/rpc/src/rpc.ts | 8 +++- 20 files changed, 106 insertions(+), 111 deletions(-) diff --git a/dev/tool/src/clean.ts b/dev/tool/src/clean.ts index d049f81491..da7d4f6c4a 100644 --- a/dev/tool/src/clean.ts +++ b/dev/tool/src/clean.ts @@ -1152,7 +1152,9 @@ function isPersonAccount (tx: TxCUD<Doc>): boolean { } async function update<T extends Doc> (h: Hierarchy, db: Db, doc: T, update: DocumentUpdate<T>): Promise<void> { - await db.collection(h.getDomain(doc._class)).updateOne({ _id: doc._id }, { $set: { ...update, '%hash%': null } }) + await db + .collection(h.getDomain(doc._class)) + .updateOne({ _id: doc._id }, { $set: { ...update, '%hash%': Date.now().toString(16) } }) } async function updateId ( @@ -1173,12 +1175,14 @@ async function updateId ( const newId = generateId() // update txes - await db.collection(DOMAIN_TX).updateMany({ objectId: doc._id }, { $set: { objectId: newId, '%hash%': null } }) + await db + .collection(DOMAIN_TX) + .updateMany({ objectId: doc._id }, { $set: { objectId: newId, '%hash%': Date.now().toString(16) } }) // update nested txes await db .collection(DOMAIN_TX) - .updateMany({ 'tx.objectId': doc._id }, { $set: { 'tx.objectId': newId, '%hash%': null } }) + .updateMany({ 'tx.objectId': doc._id }, { $set: { 'tx.objectId': newId, '%hash%': Date.now().toString(16) } }) // we have generated ids for calendar, let's update in if (h.isDerived(doc._class, core.class.Account)) { @@ -1232,7 +1236,7 @@ async function updateId ( await db.collection(domain).insertOne({ ...raw, _id: newId as any, - '%hash%': null + '%hash%': Date.now().toString(16) }) await db.collection(domain).deleteOne({ _id: doc._id }) } diff --git a/models/activity/src/migration.ts b/models/activity/src/migration.ts index 898984596f..34c1ff8b34 100644 --- a/models/activity/src/migration.ts +++ b/models/activity/src/migration.ts @@ -217,12 +217,6 @@ export const activityOperation: MigrateOperation = { state: 'migrate-activity-markup', func: migrateActivityMarkup }, - { - state: 'fix-rename-backups', - func: async (client: MigrationClient): Promise<void> => { - await client.update(DOMAIN_ACTIVITY, { '%hash%': { $exists: true } }, { $set: { '%hash%': null } }) - } - }, { state: 'move-reactions', func: async (client: MigrationClient): Promise<void> => { diff --git a/models/attachment/src/migration.ts b/models/attachment/src/migration.ts index 73b55dd12b..df8c59ae94 100644 --- a/models/attachment/src/migration.ts +++ b/models/attachment/src/migration.ts @@ -24,12 +24,6 @@ import attachment, { attachmentId, DOMAIN_ATTACHMENT } from '.' export const attachmentOperation: MigrateOperation = { async migrate (client: MigrationClient): Promise<void> { await tryMigrate(client, attachmentId, [ - { - state: 'fix-rename-backups', - func: async (client: MigrationClient): Promise<void> => { - await client.update(DOMAIN_ATTACHMENT, { '%hash%': { $exists: true } }, { $set: { '%hash%': null } }) - } - }, { state: 'fix-attachedTo', func: async (client: MigrationClient): Promise<void> => { diff --git a/models/chunter/src/migration.ts b/models/chunter/src/migration.ts index 65438a982b..70d2c2151c 100644 --- a/models/chunter/src/migration.ts +++ b/models/chunter/src/migration.ts @@ -362,12 +362,6 @@ export const chunterOperation: MigrateOperation = { 'attributeUpdates.attrKey': 'members' }) } - }, - { - state: 'fix-rename-backups', - func: async (client: MigrationClient): Promise<void> => { - await client.update(DOMAIN_CHUNTER, { '%hash%': { $exists: true } }, { $set: { '%hash%': null } }) - } } ]) }, diff --git a/models/contact/src/migration.ts b/models/contact/src/migration.ts index a109668aa8..d78df3c00e 100644 --- a/models/contact/src/migration.ts +++ b/models/contact/src/migration.ts @@ -26,7 +26,7 @@ import activity, { DOMAIN_ACTIVITY } from '@hcengineering/model-activity' import core, { DOMAIN_SPACE } from '@hcengineering/model-core' import { DOMAIN_VIEW } from '@hcengineering/model-view' -import contact, { contactId, DOMAIN_CHANNEL, DOMAIN_CONTACT } from './index' +import contact, { contactId, DOMAIN_CONTACT } from './index' async function createEmployeeEmail (client: TxOperations): Promise<void> { const employees = await client.findAll(contact.mixin.Employee, {}) @@ -300,13 +300,6 @@ export const contactOperation: MigrateOperation = { { state: 'create-person-spaces-v1', func: createPersonSpaces - }, - { - state: 'fix-rename-backups', - func: async (client: MigrationClient): Promise<void> => { - await client.update(DOMAIN_CONTACT, { '%hash%': { $exists: true } }, { $set: { '%hash%': null } }) - await client.update(DOMAIN_CHANNEL, { '%hash%': { $exists: true } }, { $set: { '%hash%': null } }) - } } ]) }, diff --git a/models/core/src/migration.ts b/models/core/src/migration.ts index ccebe20b86..f922129f28 100644 --- a/models/core/src/migration.ts +++ b/models/core/src/migration.ts @@ -424,10 +424,12 @@ export const coreOperation: MigrateOperation = { func: migrateCollaborativeContentToStorage }, { - state: 'fix-rename-backups', + state: 'fix-backups-hash-timestamp', func: async (client: MigrationClient): Promise<void> => { - await client.update(DOMAIN_TX, { '%hash%': { $exists: true } }, { $set: { '%hash%': null } }) - await client.update(DOMAIN_SPACE, { '%hash%': { $exists: true } }, { $set: { '%hash%': null } }) + const now = Date.now().toString(16) + for (const d of client.hierarchy.domains()) { + await client.update(d, { '%hash%': { $in: [null, ''] } }, { $set: { '%hash%': now } }) + } } }, { diff --git a/models/document/src/migration.ts b/models/document/src/migration.ts index 6bc4efb4a4..79dcd9fd4a 100644 --- a/models/document/src/migration.ts +++ b/models/document/src/migration.ts @@ -335,12 +335,6 @@ export const documentOperation: MigrateOperation = { state: 'renameFields', func: renameFields }, - { - state: 'fix-rename-backups', - func: async (client: MigrationClient): Promise<void> => { - await client.update(DOMAIN_DOCUMENT, { '%hash%': { $exists: true } }, { $set: { '%hash%': null } }) - } - }, { state: 'renameFieldsRevert', func: renameFieldsRevert diff --git a/models/drive/src/migration.ts b/models/drive/src/migration.ts index 6ea69d90d2..e2762c8b13 100644 --- a/models/drive/src/migration.ts +++ b/models/drive/src/migration.ts @@ -132,12 +132,6 @@ export const driveOperation: MigrateOperation = { { state: 'renameFields', func: renameFields - }, - { - state: 'fix-rename-backups', - func: async (client: MigrationClient): Promise<void> => { - await client.update(DOMAIN_DRIVE, { '%hash%': { $exists: true } }, { $set: { '%hash%': null } }) - } } ]) }, diff --git a/models/notification/src/migration.ts b/models/notification/src/migration.ts index 56fe3affe2..972d3f77a2 100644 --- a/models/notification/src/migration.ts +++ b/models/notification/src/migration.ts @@ -393,12 +393,6 @@ export const notificationOperation: MigrateOperation = { ) } }, - { - state: 'fix-rename-backups', - func: async (client: MigrationClient): Promise<void> => { - await client.update(DOMAIN_DOC_NOTIFY, { '%hash%': { $exists: true } }, { $set: { '%hash%': null } }) - } - }, { state: 'remove-update-txes-docnotify-ctx-v2', func: async (client) => { diff --git a/models/task/src/migration.ts b/models/task/src/migration.ts index ffb516d8de..03c06f7c69 100644 --- a/models/task/src/migration.ts +++ b/models/task/src/migration.ts @@ -569,12 +569,6 @@ export const taskOperation: MigrateOperation = { await migrateSpace(client, task.space.Sequence, core.space.Workspace, [DOMAIN_KANBAN]) } }, - { - state: 'fix-rename-backups', - func: async (client: MigrationClient): Promise<void> => { - await client.update(DOMAIN_TASK, { '%hash%': { $exists: true } }, { $set: { '%hash%': null } }) - } - }, { state: 'migrateRanks', func: migrateRanks diff --git a/models/time/src/migration.ts b/models/time/src/migration.ts index 481e28dfe0..91e282515c 100644 --- a/models/time/src/migration.ts +++ b/models/time/src/migration.ts @@ -172,12 +172,6 @@ export const timeOperation: MigrateOperation = { func: async (client) => { await fillProps(client) } - }, - { - state: 'fix-rename-backups', - func: async (client: MigrationClient): Promise<void> => { - await client.update(DOMAIN_TIME, { '%hash%': { $exists: true } }, { $set: { '%hash%': null } }) - } } ]) }, diff --git a/models/view/src/migration.ts b/models/view/src/migration.ts index 572a60d8d7..f5587c0547 100644 --- a/models/view/src/migration.ts +++ b/models/view/src/migration.ts @@ -86,12 +86,6 @@ export const viewOperation: MigrateOperation = { { state: 'remove-done-state-filter', func: removeDoneStateFilter - }, - { - state: 'fix-rename-backups', - func: async (client: MigrationClient): Promise<void> => { - await client.update(DOMAIN_VIEW, { '%hash%': { $exists: true } }, { $set: { '%hash%': null } }) - } } ]) }, diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index fc7e458ee3..8d56516f27 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -236,7 +236,7 @@ export async function createClient ( let hierarchy = new Hierarchy() let model = new ModelDb(hierarchy) - let lastTx: number + let lastTx: number = 0 function txHandler (...tx: Tx[]): void { if (tx == null || tx.length === 0) { @@ -295,6 +295,10 @@ export async function createClient ( } // We need to look for last {transactionThreshold} transactions and if it is more since lastTx one we receive, we need to perform full refresh. + if (lastTx === 0) { + await oldOnConnect?.(ClientConnectEvent.Refresh, data) + return + } const atxes = await ctx.with('find-atx', {}, () => conn.findAll( core.class.Tx, diff --git a/pods/backup/package.json b/pods/backup/package.json index 6b1e15c3a2..06d542cb3b 100644 --- a/pods/backup/package.json +++ b/pods/backup/package.json @@ -18,6 +18,7 @@ "bundle": "rushx get-model && node ../../common/scripts/esbuild.js --entry=src/index.ts --keep-names=true --bundle=true --sourcemap=external --external=*.node", "docker:build": "../../common/scripts/docker_build.sh hardcoreeng/backup", "docker:tbuild": "docker build -t hardcoreeng/backup . --platform=linux/amd64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/backup", + "docker:abuild": "docker build -t hardcoreeng/backup . --platform=linux/arm64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/backup", "docker:staging": "../../common/scripts/docker_tag.sh hardcoreeng/backup staging", "docker:push": "../../common/scripts/docker_tag.sh hardcoreeng/backup", "run-local": "cross-env ACCOUNTS_URL=http://localhost:3000/ SECRET=secret MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_ENDPOINT=localhost BUCKET_NAME=backups INTERVAL=30 ts-node src/index.ts", diff --git a/server/middleware/src/spaceSecurity.ts b/server/middleware/src/spaceSecurity.ts index 6c792db300..5cbe879627 100644 --- a/server/middleware/src/spaceSecurity.ts +++ b/server/middleware/src/spaceSecurity.ts @@ -40,6 +40,7 @@ import core, { TxUpdateDoc, TxWorkspaceEvent, WorkspaceEvent, + clone, generateId, systemAccountEmail, toFindResult, @@ -69,14 +70,14 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar wasInit: Promise<void> | boolean = false - private readonly mainSpaces = [ + private readonly mainSpaces = new Set([ core.space.Configuration, core.space.DerivedTx, core.space.Model, core.space.Space, core.space.Workspace, core.space.Tx - ] + ]) private constructor ( private readonly skipFindCheck: boolean, @@ -424,7 +425,7 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar ctx.contextData.broadcast.targets.spaceSec = (tx) => { const space = this.spacesMap.get(tx.objectSpace) if (space === undefined) return undefined - if (this.systemSpaces.has(space._id) || this.mainSpaces.includes(space._id)) return undefined + if (this.systemSpaces.has(space._id) || this.mainSpaces.has(space._id)) return undefined return space.members.length === 0 ? undefined : this.getTargets(space?.members) } @@ -455,12 +456,12 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar ctx: MeasureContext, domain: Domain, spaces: Ref<Space>[] - ): Promise<{ result: Ref<Space>[], allDomainSpaces: boolean, domainSpaces: Set<Ref<Space>> }> { + ): Promise<{ result: Set<Ref<Space>>, allDomainSpaces: boolean, domainSpaces: Set<Ref<Space>> }> { const domainSpaces = await this.getDomainSpaces(ctx, domain) - const result = spaces.filter((p) => domainSpaces.has(p)) + const result = new Set(spaces.filter((p) => domainSpaces.has(p))) return { - result: spaces.filter((p) => domainSpaces.has(p)), - allDomainSpaces: result.length === domainSpaces.size, + result, + allDomainSpaces: result.size === domainSpaces.size, domainSpaces } } @@ -477,14 +478,14 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar if (spaces.allDomainSpaces) { return undefined } - return { $in: spaces.result } + return { $in: Array.from(spaces.result) } } if (typeof query === 'string') { - if (!spaces.result.includes(query)) { + if (!spaces.result.has(query)) { return { $in: [] } } } else if (query.$in != null) { - query.$in = query.$in.filter((p) => spaces.result.includes(p)) + query.$in = query.$in.filter((p) => spaces.result.has(p)) if (query.$in.length === spaces.domainSpaces.size) { // all domain spaces delete query.$in @@ -493,7 +494,7 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar if (spaces.allDomainSpaces) { delete query.$in } else { - query.$in = spaces.result + query.$in = Array.from(spaces.result) } } if (Object.keys(query).length === 0) { @@ -515,7 +516,7 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar await this.init(ctx) const domain = this.context.hierarchy.getDomain(_class) - const newQuery = { ...query } + const newQuery = clone(query) const account = ctx.contextData.account const isSpace = this.context.hierarchy.isDerived(_class, core.class.Space) const field = this.getKey(domain) @@ -528,12 +529,12 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar const res = await this.mergeQuery(ctx, account, query[field], domain, isSpace) if (res === undefined) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete (newQuery as any)[field] + delete newQuery[field] } else { - ;(newQuery as any)[field] = res + newQuery[field] = res if (typeof res === 'object') { if (Array.isArray(res.$in) && res.$in.length === 1 && Object.keys(res).length === 1) { - ;(newQuery as any)[field] = res.$in[0] + newQuery[field] = res.$in[0] } } } @@ -541,25 +542,25 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar const spaces = await this.filterByDomain(ctx, domain, this.getAllAllowedSpaces(account, !isSpace)) if (spaces.allDomainSpaces) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete (newQuery as any)[field] - } else if (spaces.result.length === 1) { - ;(newQuery as any)[field] = spaces.result[0] + delete newQuery[field] + } else if (spaces.result.size === 1) { + newQuery[field] = Array.from(spaces.result)[0] if (options !== undefined) { - options.allowedSpaces = spaces.result + options.allowedSpaces = Array.from(spaces.result) } else { - options = { allowedSpaces: spaces.result } + options = { allowedSpaces: Array.from(spaces.result) } } } else { // Check if spaces > 85% of all domain spaces, in this case return all and filter on client. - if (spaces.result.length / spaces.domainSpaces.size > 0.85 && options?.limit === undefined) { - clientFilterSpaces = new Set(spaces.result) + if (spaces.result.size / spaces.domainSpaces.size > 0.85 && options?.limit === undefined) { + clientFilterSpaces = spaces.result delete newQuery.space } else { - ;(newQuery as any)[field] = { $in: spaces.result } + newQuery[field] = { $in: Array.from(spaces.result) } if (options !== undefined) { - options.allowedSpaces = spaces.result + options.allowedSpaces = Array.from(spaces.result) } else { - options = { allowedSpaces: spaces.result } + options = { allowedSpaces: Array.from(spaces.result) } } } } @@ -625,19 +626,19 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar if (Object.keys(lookup).length === 0) return const account = ctx.contextData.account if (isSystem(account, ctx)) return - const allowedSpaces = this.getAllAllowedSpaces(account, true) + const allowedSpaces = new Set(this.getAllAllowedSpaces(account, true)) for (const key in lookup) { const val = lookup[key] if (Array.isArray(val)) { const arr: AttachedDoc[] = [] for (const value of val) { - if (allowedSpaces.includes(value.space)) { + if (allowedSpaces.has(value.space)) { arr.push(value) } } lookup[key] = arr as any } else if (val !== undefined) { - if (!allowedSpaces.includes(val.space)) { + if (!allowedSpaces.has(val.space)) { lookup[key] = undefined } } diff --git a/server/mongo/src/storage.ts b/server/mongo/src/storage.ts index fa8cb11239..de49e832fc 100644 --- a/server/mongo/src/storage.ts +++ b/server/mongo/src/storage.ts @@ -1025,7 +1025,10 @@ abstract class MongoAdapterBase implements DbAdapter { return Date.now().toString(16) // Current hash value } - strimSize (str: string): string { + strimSize (str?: string): string { + if (str == null) { + return '' + } const pos = str.indexOf('|') if (pos > 0) { return str.substring(0, pos) @@ -1041,8 +1044,6 @@ abstract class MongoAdapterBase implements DbAdapter { return { next: async () => { if (iterator === undefined) { - await coll.updateMany({ '%hash%': { $in: [null, ''] } }, { $set: { '%hash%': this.curHash() } }) - iterator = coll.find( {}, { diff --git a/server/postgres/src/schemas.ts b/server/postgres/src/schemas.ts index f41d54a8a3..d676413005 100644 --- a/server/postgres/src/schemas.ts +++ b/server/postgres/src/schemas.ts @@ -152,6 +152,11 @@ const docIndexStateSchema: Schema = { type: 'bool', notNull: true, index: true + }, + objectClass: { + type: 'text', + notNull: true, + index: false } } @@ -212,6 +217,40 @@ const eventSchema: Schema = { } } +const docSyncInfo: Schema = { + ...baseSchema, + needSync: { + type: 'text', + notNull: false, + index: false + }, + externalVersion: { + type: 'text', + notNull: false, + index: false + }, + repository: { + type: 'text', + notNull: false, + index: false + }, + url: { + type: 'text', + notNull: false, + index: false + }, + objectClass: { + type: 'text', + notNull: false, + index: false + }, + deleted: { + type: 'bool', + notNull: false, + index: false + } +} + export function addSchema (domain: string, schema: Schema): void { domainSchemas[translateDomain(domain)] = schema domainSchemaFields.set(domain, createSchemaFields(schema)) @@ -231,7 +270,8 @@ export const domainSchemas: Record<string, Schema> = { [translateDomain(DOMAIN_DOC_INDEX_STATE)]: docIndexStateSchema, notification: notificationSchema, [translateDomain('notification-dnc')]: dncSchema, - [translateDomain('notification-user')]: userNotificationSchema + [translateDomain('notification-user')]: userNotificationSchema, + github: docSyncInfo } export function getSchema (domain: string): Schema { diff --git a/server/postgres/src/storage.ts b/server/postgres/src/storage.ts index 8bc5951e15..2b4ecb8272 100644 --- a/server/postgres/src/storage.ts +++ b/server/postgres/src/storage.ts @@ -1276,7 +1276,10 @@ abstract class PostgresAdapterBase implements DbAdapter { return [] } - strimSize (str: string): string { + strimSize (str?: string): string { + if (str == null) { + return '' + } const pos = str.indexOf('|') if (pos > 0) { return str.substring(0, pos) @@ -1308,12 +1311,6 @@ abstract class PostgresAdapterBase implements DbAdapter { if (client === undefined) { client = await this.client.reserve() } - - // We need update hash to be set properly - await client.unsafe( - `UPDATE ${tdomain} SET "%hash%" = '${this.curHash()}' WHERE "workspaceId" = '${this.workspaceId.name}' AND "%hash%" IS NULL OR "%hash%" = ''` - ) - initialized = true bulk = createBulk('_id, "%hash%"') } diff --git a/server/postgres/src/utils.ts b/server/postgres/src/utils.ts index 58666f8b2e..040f569ec6 100644 --- a/server/postgres/src/utils.ts +++ b/server/postgres/src/utils.ts @@ -283,7 +283,7 @@ export function convertDoc<T extends Doc> ( modifiedOn: doc.modifiedOn, createdOn: doc.createdOn ?? doc.modifiedOn, _class: doc._class, - '%hash%': (doc as any)['%hash%'] ?? null + '%hash%': (doc as any)['%hash%'] ?? Date.now().toString(16) } const remainingData: Partial<T> = {} diff --git a/server/rpc/src/rpc.ts b/server/rpc/src/rpc.ts index 56809a9519..5ae60ae81f 100644 --- a/server/rpc/src/rpc.ts +++ b/server/rpc/src/rpc.ts @@ -106,7 +106,13 @@ export class RPCHandler { const decoder = new TextDecoder() _data = decoder.decode(_data) } - return JSON.parse(_data.toString(), receiver) + try { + return JSON.parse(_data.toString(), receiver) + } catch (err: any) { + if (((err.message as string) ?? '').includes('Unexpected token')) { + return this.packr.unpack(new Uint8Array(data)) + } + } } return this.packr.unpack(new Uint8Array(data)) } From 9421887f018b68ea6756d83001cbed56cefe6592 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev <haiodo@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:04:43 +0700 Subject: [PATCH 5/6] UBERF-8877: Fix indexer concurrency (#7416) Signed-off-by: Andrey Sobolev <haiodo@gmail.com> --- .vscode/launch.json | 1 - packages/query/src/index.ts | 6 ++++-- packages/query/src/types.ts | 2 +- server/indexer/src/indexer/indexer.ts | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 96d8043c23..b80e833e9b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -85,7 +85,6 @@ "SERVER_SECRET": "secret", "REKONI_URL": "http://localhost:4004", "MODEL_JSON": "${workspaceRoot}/models/all/bundle/model.json", - "REGION":"pg", "ELASTIC_INDEX_NAME": "local_storage_index", "STATS_URL":"http://host.docker.internal:4900", "ACCOUNTS_URL": "http://localhost:3000", diff --git a/packages/query/src/index.ts b/packages/query/src/index.ts index 14ab8d8f67..d24b6ea13c 100644 --- a/packages/query/src/index.ts +++ b/packages/query/src/index.ts @@ -364,7 +364,8 @@ export class LiveQuery implements WithTx, Client { total: 0, options: options as FindOptions<Doc>, callbacks: new Map(), - refresh: reduceCalls(() => this.doRefresh(q)) + refresh: reduceCalls(() => this.doRefresh(q)), + refreshId: 0 } if (callback !== undefined) { q.callbacks.set(callback.callbackId, callback.callback as unknown as Callback) @@ -787,8 +788,9 @@ export class LiveQuery implements WithTx, Client { } private async doRefresh (q: Query): Promise<void> { + const qid = ++q.refreshId const res = await this.client.findAll(q._class, q.query, q.options) - if (!deepEqual(res, q.result) || (res.total !== q.total && q.options?.total === true)) { + if (q.refreshId === qid && (!deepEqual(res, q.result) || (res.total !== q.total && q.options?.total === true))) { q.result = new ResultArray(res, this.getHierarchy()) q.total = res.total await this.callback(q) diff --git a/packages/query/src/types.ts b/packages/query/src/types.ts index 3628782acd..5dec89ed02 100644 --- a/packages/query/src/types.ts +++ b/packages/query/src/types.ts @@ -12,6 +12,6 @@ export interface Query { options?: FindOptions<Doc> total: number callbacks: Map<string, Callback> - refresh: () => Promise<void> + refreshId: number } diff --git a/server/indexer/src/indexer/indexer.ts b/server/indexer/src/indexer/indexer.ts index 772d3bdccd..2f70cf00f6 100644 --- a/server/indexer/src/indexer/indexer.ts +++ b/server/indexer/src/indexer/indexer.ts @@ -402,7 +402,7 @@ export class FullTextIndexPipeline implements FullTextPipeline { ): Promise<{ classUpdate: Ref<Class<Doc>>[], processed: number }> { const _classUpdate = new Set<Ref<Class<Doc>>>() let processed = 0 - await rateLimiter.add(async () => { + await rateLimiter.exec(async () => { let st = Date.now() let groupBy = await this.storage.groupBy(ctx, DOMAIN_DOC_INDEX_STATE, 'objectClass', { needIndex: true }) From 417a6ac084bac555b5b5bfe3eed4acb32da055e8 Mon Sep 17 00:00:00 2001 From: Chunosov <Chunosov.N@gmail.com> Date: Tue, 10 Dec 2024 17:47:53 +0700 Subject: [PATCH 6/6] remove spaces from vacancy name (#7417) Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com> --- .../recruit-resources/src/components/CreateVacancy.svelte | 8 ++++---- .../recruit-resources/src/components/EditVacancy.svelte | 6 +++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/plugins/recruit-resources/src/components/CreateVacancy.svelte b/plugins/recruit-resources/src/components/CreateVacancy.svelte index 40a08eade5..a3c546c8c3 100644 --- a/plugins/recruit-resources/src/components/CreateVacancy.svelte +++ b/plugins/recruit-resources/src/components/CreateVacancy.svelte @@ -103,7 +103,7 @@ type: typeId as Ref<ProjectType> } export function canClose (): boolean { - return name === '' && typeId !== undefined + return name.trim() === '' && typeId !== undefined } const client = getClient() @@ -185,7 +185,7 @@ const resId: Ref<Issue> = generateId() const identifier = `${project?.identifier}-${number}` const data: AttachedData<Issue> = { - title: template.title + ` (${name})`, + title: template.title + ` (${name.trim()})`, description: null, assignee: template.assignee, component: template.component, @@ -240,7 +240,7 @@ const incResult = await client.update(sequence, { $inc: { sequence: 1 } }, true) const data: Data<Vacancy> = { ...vacancyData, - name, + name: name.trim(), description: template?.shortDescription ?? '', fullDescription: null, private: false, @@ -336,7 +336,7 @@ <Card label={recruit.string.CreateVacancy} okAction={createVacancy} - canSave={!!name} + canSave={name.trim() !== ''} gap={'gapV-4'} on:close={() => { dispatch('close') diff --git a/plugins/recruit-resources/src/components/EditVacancy.svelte b/plugins/recruit-resources/src/components/EditVacancy.svelte index 8ccc9d8907..b43f63d082 100644 --- a/plugins/recruit-resources/src/components/EditVacancy.svelte +++ b/plugins/recruit-resources/src/components/EditVacancy.svelte @@ -101,9 +101,13 @@ const updates: Partial<Data<Vacancy>> = {} const trimmedName = rawName.trim() + const trimmedNameOld = object.name?.trim() - if (trimmedName.length > 0 && trimmedName !== object.name?.trim()) { + if (trimmedName.length > 0 && (trimmedName !== trimmedNameOld || trimmedNameOld !== object.name)) { updates.name = trimmedName + rawName = trimmedName + } else { + rawName = object.name } if (rawDesc !== object.description) {