mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-12 12:47:45 +02:00
@@ -0,0 +1,233 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import core, {
|
||||
type Account,
|
||||
type Arr,
|
||||
type AttachedDoc,
|
||||
type Class,
|
||||
ClassifierKind,
|
||||
type Data,
|
||||
type Doc,
|
||||
DOMAIN_DOC_INDEX_STATE,
|
||||
DOMAIN_MODEL,
|
||||
DOMAIN_TX,
|
||||
type Mixin,
|
||||
type Obj,
|
||||
type Ref,
|
||||
type TxCreateDoc,
|
||||
type TxCUD,
|
||||
TxFactory,
|
||||
AccountRole
|
||||
} from '@hcengineering/core'
|
||||
import type { IntlString, Plugin } from '@hcengineering/platform'
|
||||
import { plugin } from '@hcengineering/platform'
|
||||
|
||||
export const txFactory = new TxFactory(core.account.System)
|
||||
|
||||
export function createClass (_class: Ref<Class<Obj>>, attributes: Data<Class<Obj>>): TxCreateDoc<Doc> {
|
||||
return txFactory.createTxCreateDoc(core.class.Class, core.space.Model, attributes, _class)
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function createDoc<T extends Doc> (
|
||||
_class: Ref<Class<T>>,
|
||||
attributes: Data<T>,
|
||||
id?: Ref<T>,
|
||||
modifiedBy?: Ref<Account>
|
||||
): TxCreateDoc<Doc> {
|
||||
const result = txFactory.createTxCreateDoc(_class, core.space.Model, attributes, id)
|
||||
if (modifiedBy !== undefined) {
|
||||
result.modifiedBy = modifiedBy
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface TestMixin extends Doc {
|
||||
arr: Arr<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface AttachedComment extends AttachedDoc {
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const test = plugin('test' as Plugin, {
|
||||
mixin: {
|
||||
TestMixin: '' as Ref<Mixin<TestMixin>>
|
||||
},
|
||||
class: {
|
||||
TestComment: '' as Ref<Class<AttachedComment>>
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Generate minimal model for testing purposes.
|
||||
* @returns R
|
||||
*/
|
||||
export function genMinModel (): TxCUD<Doc>[] {
|
||||
const txes = []
|
||||
// Fill Tx'es with basic model classes.
|
||||
txes.push(createClass(core.class.Obj, { label: 'Obj' as IntlString, kind: ClassifierKind.CLASS }))
|
||||
txes.push(
|
||||
createClass(core.class.Doc, { label: 'Doc' as IntlString, extends: core.class.Obj, kind: ClassifierKind.CLASS })
|
||||
)
|
||||
txes.push(
|
||||
createClass(core.class.AttachedDoc, {
|
||||
label: 'AttachedDoc' as IntlString,
|
||||
extends: core.class.Doc,
|
||||
kind: ClassifierKind.MIXIN
|
||||
})
|
||||
)
|
||||
txes.push(
|
||||
createClass(core.class.Class, {
|
||||
label: 'Class' as IntlString,
|
||||
extends: core.class.Doc,
|
||||
kind: ClassifierKind.CLASS,
|
||||
domain: DOMAIN_MODEL
|
||||
})
|
||||
)
|
||||
txes.push(
|
||||
createClass(core.class.Space, {
|
||||
label: 'Space' as IntlString,
|
||||
extends: core.class.Doc,
|
||||
kind: ClassifierKind.CLASS,
|
||||
domain: DOMAIN_MODEL
|
||||
})
|
||||
)
|
||||
txes.push(
|
||||
createClass(core.class.DocIndexState, {
|
||||
label: 'DocIndexState' as IntlString,
|
||||
extends: core.class.Doc,
|
||||
kind: ClassifierKind.CLASS,
|
||||
domain: DOMAIN_DOC_INDEX_STATE
|
||||
})
|
||||
)
|
||||
|
||||
txes.push(
|
||||
createClass(core.class.Account, {
|
||||
label: 'Account' as IntlString,
|
||||
extends: core.class.Doc,
|
||||
kind: ClassifierKind.CLASS,
|
||||
domain: DOMAIN_MODEL
|
||||
})
|
||||
)
|
||||
|
||||
txes.push(
|
||||
createClass(core.class.Tx, {
|
||||
label: 'Tx' as IntlString,
|
||||
extends: core.class.Doc,
|
||||
kind: ClassifierKind.CLASS,
|
||||
domain: DOMAIN_TX
|
||||
})
|
||||
)
|
||||
txes.push(
|
||||
createClass(core.class.TxCUD, {
|
||||
label: 'TxCUD' as IntlString,
|
||||
extends: core.class.Tx,
|
||||
kind: ClassifierKind.CLASS,
|
||||
domain: DOMAIN_TX
|
||||
})
|
||||
)
|
||||
txes.push(
|
||||
createClass(core.class.TxCreateDoc, {
|
||||
label: 'TxCreateDoc' as IntlString,
|
||||
extends: core.class.TxCUD,
|
||||
kind: ClassifierKind.CLASS
|
||||
})
|
||||
)
|
||||
txes.push(
|
||||
createClass(core.class.TxUpdateDoc, {
|
||||
label: 'TxUpdateDoc' as IntlString,
|
||||
extends: core.class.TxCUD,
|
||||
kind: ClassifierKind.CLASS
|
||||
})
|
||||
)
|
||||
txes.push(
|
||||
createClass(core.class.TxRemoveDoc, {
|
||||
label: 'TxRemoveDoc' as IntlString,
|
||||
extends: core.class.TxCUD,
|
||||
kind: ClassifierKind.CLASS
|
||||
})
|
||||
)
|
||||
txes.push(
|
||||
createClass(core.class.TxCollectionCUD, {
|
||||
label: 'TxCollectionCUD' as IntlString,
|
||||
extends: core.class.TxCUD,
|
||||
kind: ClassifierKind.CLASS
|
||||
})
|
||||
)
|
||||
|
||||
txes.push(
|
||||
createClass(test.mixin.TestMixin, {
|
||||
label: 'TestMixin' as IntlString,
|
||||
extends: core.class.Doc,
|
||||
kind: ClassifierKind.MIXIN
|
||||
})
|
||||
)
|
||||
|
||||
txes.push(
|
||||
createClass(test.class.TestComment, {
|
||||
label: 'TestComment' as IntlString,
|
||||
extends: core.class.AttachedDoc,
|
||||
kind: ClassifierKind.CLASS
|
||||
})
|
||||
)
|
||||
|
||||
const u1 = 'User1' as Ref<Account>
|
||||
const u2 = 'User2' as Ref<Account>
|
||||
txes.push(
|
||||
createDoc(core.class.Account, { email: 'user1@site.com', role: AccountRole.User }, u1),
|
||||
createDoc(core.class.Account, { email: 'user2@site.com', role: AccountRole.User }, u2),
|
||||
createDoc(core.class.Space, {
|
||||
name: 'Sp1',
|
||||
description: '',
|
||||
private: false,
|
||||
archived: false,
|
||||
members: [u1, u2]
|
||||
})
|
||||
)
|
||||
|
||||
txes.push(
|
||||
createDoc(core.class.Space, {
|
||||
name: 'Sp2',
|
||||
description: '',
|
||||
private: false,
|
||||
archived: false,
|
||||
members: [u1]
|
||||
})
|
||||
)
|
||||
|
||||
txes.push(
|
||||
createClass(core.class.DomainIndexConfiguration, {
|
||||
label: 'DomainIndexConfiguration' as IntlString,
|
||||
extends: core.class.Doc,
|
||||
kind: ClassifierKind.CLASS,
|
||||
domain: DOMAIN_MODEL
|
||||
})
|
||||
)
|
||||
return txes
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
import core, {
|
||||
type Client,
|
||||
type ClientConnection,
|
||||
createClient,
|
||||
type Doc,
|
||||
type DocChunk,
|
||||
type Domain,
|
||||
generateId,
|
||||
getWorkspaceId,
|
||||
Hierarchy,
|
||||
MeasureMetricsContext,
|
||||
ModelDb,
|
||||
type Ref,
|
||||
SortingOrder,
|
||||
type Space,
|
||||
TxOperations
|
||||
} from '@hcengineering/core'
|
||||
import { type DbAdapter } from '@hcengineering/server-core'
|
||||
import { createPostgresAdapter, createPostgresTxAdapter } from '..'
|
||||
import { getDBClient, type PostgresClientReference, shutdown } from '../utils'
|
||||
import { genMinModel } from './minmodel'
|
||||
import { createTaskModel, type Task, type TaskComment, taskPlugin } from './tasks'
|
||||
|
||||
const txes = genMinModel()
|
||||
|
||||
createTaskModel(txes)
|
||||
|
||||
describe('postgres operations', () => {
|
||||
const baseDbUri: string = process.env.DB_URL ?? 'postgresql://postgres:example@localhost:5433'
|
||||
let dbId: string = 'pg_testdb_' + generateId()
|
||||
let dbUri: string = baseDbUri + '/' + dbId
|
||||
const clientRef: PostgresClientReference = getDBClient(baseDbUri)
|
||||
let hierarchy: Hierarchy
|
||||
let model: ModelDb
|
||||
let client: Client
|
||||
let operations: TxOperations
|
||||
let serverStorage: DbAdapter
|
||||
|
||||
afterAll(async () => {
|
||||
clientRef.close()
|
||||
await shutdown()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
try {
|
||||
dbId = 'pg_testdb_' + generateId()
|
||||
dbUri = baseDbUri + '/' + dbId
|
||||
const client = await clientRef.getClient()
|
||||
await client.query(`CREATE DATABASE ${dbId}`)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
// await client.close()
|
||||
// await (await clientRef.getClient()).query(`DROP DATABASE ${dbId}`)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
await serverStorage?.close()
|
||||
})
|
||||
|
||||
async function initDb (): Promise<void> {
|
||||
// Remove all stuff from database.
|
||||
hierarchy = new Hierarchy()
|
||||
model = new ModelDb(hierarchy)
|
||||
for (const t of txes) {
|
||||
hierarchy.tx(t)
|
||||
}
|
||||
for (const t of txes) {
|
||||
await model.tx(t)
|
||||
}
|
||||
|
||||
const mctx = new MeasureMetricsContext('', {})
|
||||
const txStorage = await createPostgresTxAdapter(mctx, hierarchy, dbUri, getWorkspaceId(dbId), model)
|
||||
|
||||
// Put all transactions to Tx
|
||||
for (const t of txes) {
|
||||
await txStorage.tx(mctx, t)
|
||||
}
|
||||
|
||||
await txStorage.close()
|
||||
|
||||
const ctx = new MeasureMetricsContext('client', {})
|
||||
const serverStorage = await createPostgresAdapter(ctx, hierarchy, dbUri, getWorkspaceId(dbId), model)
|
||||
await serverStorage.init?.()
|
||||
client = await createClient(async (handler) => {
|
||||
const st: ClientConnection = {
|
||||
isConnected: () => true,
|
||||
findAll: async (_class, query, options) => await serverStorage.findAll(ctx, _class, query, options),
|
||||
tx: async (tx) => await serverStorage.tx(ctx, tx),
|
||||
searchFulltext: async () => ({ docs: [] }),
|
||||
close: async () => {},
|
||||
loadChunk: async (domain): Promise<DocChunk> => await Promise.reject(new Error('unsupported')),
|
||||
closeChunk: async (idx) => {},
|
||||
loadDocs: async (domain: Domain, docs: Ref<Doc>[]) => [],
|
||||
upload: async (domain: Domain, docs: Doc[]) => {},
|
||||
clean: async (domain: Domain, docs: Ref<Doc>[]) => {},
|
||||
loadModel: async () => txes,
|
||||
getAccount: async () => ({}) as any,
|
||||
sendForceClose: async () => {}
|
||||
}
|
||||
return st
|
||||
})
|
||||
|
||||
operations = new TxOperations(client, core.account.System)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.setTimeout(30000)
|
||||
await initDb()
|
||||
})
|
||||
|
||||
it('check add', async () => {
|
||||
const times: number[] = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const t = Date.now()
|
||||
await operations.createDoc(taskPlugin.class.Task, '' as Ref<Space>, {
|
||||
name: `my-task-${i}`,
|
||||
description: `${i * i}`,
|
||||
rate: 20 + i
|
||||
})
|
||||
times.push(Date.now() - t)
|
||||
}
|
||||
|
||||
console.log('createDoc times', times)
|
||||
|
||||
const r = await client.findAll<Task>(taskPlugin.class.Task, {})
|
||||
expect(r.length).toEqual(50)
|
||||
})
|
||||
|
||||
it('check find by criteria', async () => {
|
||||
jest.setTimeout(20000)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await operations.createDoc(taskPlugin.class.Task, '' as Ref<Space>, {
|
||||
name: `my-task-${i}`,
|
||||
description: `${i * i}`,
|
||||
rate: 20 + i
|
||||
})
|
||||
}
|
||||
|
||||
const r = await client.findAll<Task>(taskPlugin.class.Task, {})
|
||||
expect(r.length).toEqual(50)
|
||||
|
||||
const first = await client.findAll<Task>(taskPlugin.class.Task, { name: 'my-task-0' })
|
||||
expect(first.length).toEqual(1)
|
||||
|
||||
const second = await client.findAll<Task>(taskPlugin.class.Task, { name: { $like: '%0' } })
|
||||
expect(second.length).toEqual(5)
|
||||
|
||||
const third = await client.findAll<Task>(taskPlugin.class.Task, { rate: { $in: [25, 26, 27, 28] } })
|
||||
expect(third.length).toEqual(4)
|
||||
})
|
||||
|
||||
it('check update', async () => {
|
||||
await operations.createDoc(taskPlugin.class.Task, '' as Ref<Space>, {
|
||||
name: 'my-task',
|
||||
description: 'some data ',
|
||||
rate: 20,
|
||||
arr: []
|
||||
})
|
||||
|
||||
const doc = (await client.findAll<Task>(taskPlugin.class.Task, {}))[0]
|
||||
|
||||
await operations.updateDoc(doc._class, doc.space, doc._id, { rate: 30 })
|
||||
let tasks = await client.findAll<Task>(taskPlugin.class.Task, {})
|
||||
expect(tasks.length).toEqual(1)
|
||||
expect(tasks[0].rate).toEqual(30)
|
||||
|
||||
await operations.updateDoc(doc._class, doc.space, doc._id, { $inc: { rate: 1 } })
|
||||
tasks = await client.findAll<Task>(taskPlugin.class.Task, {})
|
||||
expect(tasks.length).toEqual(1)
|
||||
expect(tasks[0].rate).toEqual(31)
|
||||
|
||||
await operations.updateDoc(doc._class, doc.space, doc._id, { $inc: { rate: -1 } })
|
||||
tasks = await client.findAll<Task>(taskPlugin.class.Task, {})
|
||||
expect(tasks.length).toEqual(1)
|
||||
expect(tasks[0].rate).toEqual(30)
|
||||
|
||||
await operations.updateDoc(doc._class, doc.space, doc._id, { $push: { arr: 1 } })
|
||||
tasks = await client.findAll<Task>(taskPlugin.class.Task, {})
|
||||
expect(tasks.length).toEqual(1)
|
||||
expect(tasks[0].arr?.length).toEqual(1)
|
||||
expect(tasks[0].arr?.[0]).toEqual(1)
|
||||
|
||||
await operations.updateDoc(doc._class, doc.space, doc._id, { $push: { arr: 3 } })
|
||||
tasks = await client.findAll<Task>(taskPlugin.class.Task, {})
|
||||
expect(tasks.length).toEqual(1)
|
||||
expect(tasks[0].arr?.length).toEqual(2)
|
||||
expect(tasks[0].arr?.[0]).toEqual(1)
|
||||
expect(tasks[0].arr?.[1]).toEqual(3)
|
||||
})
|
||||
|
||||
it('check remove', async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await operations.createDoc(taskPlugin.class.Task, '' as Ref<Space>, {
|
||||
name: `my-task-${i}`,
|
||||
description: `${i * i}`,
|
||||
rate: 20 + i
|
||||
})
|
||||
}
|
||||
|
||||
let r = await client.findAll<Task>(taskPlugin.class.Task, {})
|
||||
expect(r.length).toEqual(10)
|
||||
await operations.removeDoc<Task>(taskPlugin.class.Task, '' as Ref<Space>, r[0]._id)
|
||||
r = await client.findAll<Task>(taskPlugin.class.Task, {})
|
||||
expect(r.length).toEqual(9)
|
||||
})
|
||||
|
||||
it('limit and sorting', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await operations.createDoc(taskPlugin.class.Task, '' as Ref<Space>, {
|
||||
name: `my-task-${i}`,
|
||||
description: `${i * i}`,
|
||||
rate: 20 + i
|
||||
})
|
||||
}
|
||||
|
||||
const without = await client.findAll(taskPlugin.class.Task, {})
|
||||
expect(without).toHaveLength(5)
|
||||
|
||||
const limit = await client.findAll(taskPlugin.class.Task, {}, { limit: 1 })
|
||||
expect(limit).toHaveLength(1)
|
||||
|
||||
const sortAsc = await client.findAll(taskPlugin.class.Task, {}, { sort: { name: SortingOrder.Ascending } })
|
||||
expect(sortAsc[0].name).toMatch('my-task-0')
|
||||
|
||||
const sortDesc = await client.findAll(taskPlugin.class.Task, {}, { sort: { name: SortingOrder.Descending } })
|
||||
expect(sortDesc[0].name).toMatch('my-task-4')
|
||||
})
|
||||
|
||||
it('check attached', async () => {
|
||||
const docId = await operations.createDoc(taskPlugin.class.Task, '' as Ref<Space>, {
|
||||
name: 'my-task',
|
||||
description: 'Descr',
|
||||
rate: 20
|
||||
})
|
||||
|
||||
const commentId = await operations.addCollection(
|
||||
taskPlugin.class.TaskComment,
|
||||
'' as Ref<Space>,
|
||||
docId,
|
||||
taskPlugin.class.Task,
|
||||
'tasks',
|
||||
{
|
||||
message: 'my-msg',
|
||||
date: new Date()
|
||||
}
|
||||
)
|
||||
|
||||
await operations.addCollection(
|
||||
taskPlugin.class.TaskComment,
|
||||
'' as Ref<Space>,
|
||||
docId,
|
||||
taskPlugin.class.Task,
|
||||
'tasks',
|
||||
{
|
||||
message: 'my-msg2',
|
||||
date: new Date()
|
||||
}
|
||||
)
|
||||
|
||||
const r2 = await client.findAll<TaskComment>(
|
||||
taskPlugin.class.TaskComment,
|
||||
{},
|
||||
{
|
||||
lookup: {
|
||||
attachedTo: taskPlugin.class.Task
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(r2.length).toEqual(2)
|
||||
expect((r2[0].$lookup?.attachedTo as Task)?._id).toEqual(docId)
|
||||
|
||||
const r3 = await client.findAll<Task>(
|
||||
taskPlugin.class.Task,
|
||||
{},
|
||||
{
|
||||
lookup: {
|
||||
_id: { comment: taskPlugin.class.TaskComment }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(r3).toHaveLength(1)
|
||||
expect((r3[0].$lookup as any).comment).toHaveLength(2)
|
||||
|
||||
const comment2Id = await operations.addCollection(
|
||||
taskPlugin.class.TaskComment,
|
||||
'' as Ref<Space>,
|
||||
commentId,
|
||||
taskPlugin.class.TaskComment,
|
||||
'comments',
|
||||
{
|
||||
message: 'my-msg3',
|
||||
date: new Date()
|
||||
}
|
||||
)
|
||||
|
||||
const r4 = await client.findAll<TaskComment>(
|
||||
taskPlugin.class.TaskComment,
|
||||
{
|
||||
_id: comment2Id
|
||||
},
|
||||
{
|
||||
lookup: { attachedTo: [taskPlugin.class.TaskComment, { attachedTo: taskPlugin.class.Task } as any] }
|
||||
}
|
||||
)
|
||||
expect((r4[0].$lookup?.attachedTo as TaskComment)?._id).toEqual(commentId)
|
||||
expect(((r4[0].$lookup?.attachedTo as any)?.$lookup.attachedTo as Task)?._id).toEqual(docId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
type Account,
|
||||
type AttachedDoc,
|
||||
type Class,
|
||||
ClassifierKind,
|
||||
type Data,
|
||||
type Doc,
|
||||
type Domain,
|
||||
type Ref,
|
||||
type Space,
|
||||
type Tx
|
||||
} from '@hcengineering/core'
|
||||
import { type IntlString, plugin, type Plugin } from '@hcengineering/platform'
|
||||
import { createClass } from './minmodel'
|
||||
|
||||
export interface TaskComment extends AttachedDoc {
|
||||
message: string
|
||||
date: Date
|
||||
}
|
||||
|
||||
export enum TaskStatus {
|
||||
Open,
|
||||
Close,
|
||||
Resolved = 100,
|
||||
InProgress
|
||||
}
|
||||
|
||||
export enum TaskReproduce {
|
||||
Always = 'always',
|
||||
Rare = 'rare',
|
||||
Sometimes = 'sometimes'
|
||||
}
|
||||
|
||||
export interface Task extends Doc {
|
||||
name: string
|
||||
description: string
|
||||
rate?: number
|
||||
status?: TaskStatus
|
||||
reproduce?: TaskReproduce
|
||||
eta?: TaskEstimate | null
|
||||
arr?: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Define ROM and Estimated Time to arrival
|
||||
*/
|
||||
export interface TaskEstimate extends AttachedDoc {
|
||||
rom: number // in hours
|
||||
eta: number // in hours
|
||||
}
|
||||
|
||||
export interface TaskMixin extends Task {
|
||||
textValue?: string
|
||||
}
|
||||
|
||||
export interface TaskWithSecond extends Task {
|
||||
secondTask: string | null
|
||||
}
|
||||
|
||||
const taskIds = 'taskIds' as Plugin
|
||||
|
||||
export const taskPlugin = plugin(taskIds, {
|
||||
class: {
|
||||
Task: '' as Ref<Class<Task>>,
|
||||
TaskEstimate: '' as Ref<Class<TaskEstimate>>,
|
||||
TaskComment: '' as Ref<Class<TaskComment>>
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Create a random task with name specified
|
||||
* @param name
|
||||
*/
|
||||
export function createTask (name: string, rate: number, description: string): Data<Task> {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
rate
|
||||
}
|
||||
}
|
||||
|
||||
export const doc1: Task = {
|
||||
_id: 'd1' as Ref<Task>,
|
||||
_class: taskPlugin.class.Task,
|
||||
name: 'my-space',
|
||||
description: 'some-value',
|
||||
rate: 20,
|
||||
modifiedBy: 'user' as Ref<Account>,
|
||||
modifiedOn: 10,
|
||||
// createdOn: 10,
|
||||
space: '' as Ref<Space>
|
||||
}
|
||||
|
||||
export function createTaskModel (txes: Tx[]): void {
|
||||
txes.push(
|
||||
createClass(taskPlugin.class.Task, {
|
||||
kind: ClassifierKind.CLASS,
|
||||
label: 'Task' as IntlString,
|
||||
domain: 'test-task' as Domain
|
||||
}),
|
||||
createClass(taskPlugin.class.TaskEstimate, {
|
||||
kind: ClassifierKind.CLASS,
|
||||
label: 'Estimate' as IntlString,
|
||||
domain: 'test-task' as Domain
|
||||
}),
|
||||
createClass(taskPlugin.class.TaskComment, {
|
||||
kind: ClassifierKind.CLASS,
|
||||
label: 'Comment' as IntlString,
|
||||
domain: 'test-task' as Domain
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
export * from './storage'
|
||||
export { getDBClient, convertDoc, createTable, retryTxn, translateDomain } from './utils'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,391 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import core, {
|
||||
type Account,
|
||||
AccountRole,
|
||||
type Class,
|
||||
type Doc,
|
||||
type Domain,
|
||||
type FieldIndexConfig,
|
||||
generateId,
|
||||
type Projection,
|
||||
type Ref,
|
||||
type WorkspaceId
|
||||
} from '@hcengineering/core'
|
||||
import { PlatformError, unknownStatus } from '@hcengineering/platform'
|
||||
import { type DomainHelperOperations } from '@hcengineering/server-core'
|
||||
import { Pool, type PoolClient } from 'pg'
|
||||
|
||||
const connections = new Map<string, PostgresClientReferenceImpl>()
|
||||
|
||||
// Register close on process exit.
|
||||
process.on('exit', () => {
|
||||
shutdown().catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
})
|
||||
|
||||
const clientRefs = new Map<string, ClientRef>()
|
||||
|
||||
export async function retryTxn (pool: Pool, operation: (client: PoolClient) => Promise<any>): Promise<any> {
|
||||
const backoffInterval = 100 // millis
|
||||
const maxTries = 5
|
||||
let tries = 0
|
||||
const client = await pool.connect()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
await client.query('BEGIN;')
|
||||
tries++
|
||||
|
||||
try {
|
||||
const result = await operation(client)
|
||||
await client.query('COMMIT;')
|
||||
return result
|
||||
} catch (err: any) {
|
||||
await client.query('ROLLBACK;')
|
||||
|
||||
if (err.code !== '40001' || tries === maxTries) {
|
||||
throw err
|
||||
} else {
|
||||
console.log('Transaction failed. Retrying.')
|
||||
console.log(err.message)
|
||||
await new Promise((resolve) => setTimeout(resolve, tries * backoffInterval))
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTable (client: Pool, domains: string[]): Promise<void> {
|
||||
if (domains.length === 0) {
|
||||
return
|
||||
}
|
||||
const mapped = domains.map((p) => translateDomain(p))
|
||||
const inArr = mapped.map((it) => `'${it}'`).join(', ')
|
||||
const exists = await client.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_name IN (${inArr})
|
||||
`)
|
||||
|
||||
const toCreate = mapped.filter((it) => !exists.rows.map((it) => it.table_name).includes(it))
|
||||
await retryTxn(client, async (client) => {
|
||||
for (const domain of toCreate) {
|
||||
await client.query(
|
||||
`CREATE TABLE ${domain} (
|
||||
"workspaceId" VARCHAR(255) NOT NULL,
|
||||
_id VARCHAR(255) NOT NULL,
|
||||
_class VARCHAR(255) NOT NULL,
|
||||
"createdBy" VARCHAR(255),
|
||||
"modifiedBy" VARCHAR(255) NOT NULL,
|
||||
"modifiedOn" bigint NOT NULL,
|
||||
"createdOn" bigint,
|
||||
space VARCHAR(255) NOT NULL,
|
||||
"attachedTo" VARCHAR(255),
|
||||
data JSONB NOT NULL,
|
||||
PRIMARY KEY("workspaceId", _id)
|
||||
)`
|
||||
)
|
||||
await client.query(`
|
||||
CREATE INDEX ${domain}_attachedTo ON ${domain} ("attachedTo")
|
||||
`)
|
||||
await client.query(`
|
||||
CREATE INDEX ${domain}_class ON ${domain} (_class)
|
||||
`)
|
||||
await client.query(`
|
||||
CREATE INDEX ${domain}_space ON ${domain} (space)
|
||||
`)
|
||||
await client.query(`
|
||||
CREATE INDEX ${domain}_idxgin ON ${domain} USING GIN (data)
|
||||
`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function shutdown (): Promise<void> {
|
||||
for (const c of connections.values()) {
|
||||
c.close(true)
|
||||
}
|
||||
connections.clear()
|
||||
}
|
||||
|
||||
export interface PostgresClientReference {
|
||||
getClient: () => Promise<Pool>
|
||||
close: () => void
|
||||
}
|
||||
|
||||
class PostgresClientReferenceImpl {
|
||||
count: number
|
||||
client: Pool | Promise<Pool>
|
||||
|
||||
constructor (
|
||||
client: Pool | Promise<Pool>,
|
||||
readonly onclose: () => void
|
||||
) {
|
||||
this.count = 0
|
||||
this.client = client
|
||||
}
|
||||
|
||||
async getClient (): Promise<Pool> {
|
||||
if (this.client instanceof Promise) {
|
||||
this.client = await this.client
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
close (force: boolean = false): void {
|
||||
this.count--
|
||||
if (this.count === 0 || force) {
|
||||
if (force) {
|
||||
this.count = 0
|
||||
}
|
||||
void (async () => {
|
||||
this.onclose()
|
||||
const cl = await this.client
|
||||
await cl.end()
|
||||
console.log('Closed postgres connection')
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
addRef (): void {
|
||||
this.count++
|
||||
}
|
||||
}
|
||||
export class ClientRef implements PostgresClientReference {
|
||||
id = generateId()
|
||||
constructor (readonly client: PostgresClientReferenceImpl) {
|
||||
clientRefs.set(this.id, this)
|
||||
}
|
||||
|
||||
closed = false
|
||||
async getClient (): Promise<Pool> {
|
||||
if (!this.closed) {
|
||||
return await this.client.getClient()
|
||||
} else {
|
||||
throw new PlatformError(unknownStatus('DB client is already closed'))
|
||||
}
|
||||
}
|
||||
|
||||
close (): void {
|
||||
// Do not allow double close of mongo connection client
|
||||
if (!this.closed) {
|
||||
clientRefs.delete(this.id)
|
||||
this.closed = true
|
||||
this.client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a workspace connection to DB
|
||||
* @public
|
||||
*/
|
||||
export function getDBClient (connectionString: string, database?: string): PostgresClientReference {
|
||||
const key = `${connectionString}${process.env.postgree_OPTIONS ?? '{}'}`
|
||||
let existing = connections.get(key)
|
||||
|
||||
if (existing === undefined) {
|
||||
const pool = new Pool({
|
||||
connectionString,
|
||||
application_name: 'transactor',
|
||||
database
|
||||
})
|
||||
|
||||
existing = new PostgresClientReferenceImpl(pool, () => {
|
||||
connections.delete(key)
|
||||
})
|
||||
connections.set(key, existing)
|
||||
}
|
||||
// Add reference and return once closable
|
||||
existing.addRef()
|
||||
return new ClientRef(existing)
|
||||
}
|
||||
|
||||
export function convertDoc<T extends Doc> (doc: T, workspaceId: string): DBDoc {
|
||||
const { _id, _class, createdBy, modifiedBy, modifiedOn, createdOn, space, attachedTo, ...data } = doc as any
|
||||
return {
|
||||
_id,
|
||||
_class,
|
||||
createdBy,
|
||||
modifiedBy,
|
||||
modifiedOn,
|
||||
createdOn,
|
||||
space,
|
||||
attachedTo,
|
||||
workspaceId,
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeBackticks (str: string): string {
|
||||
return str.replaceAll("'", "''")
|
||||
}
|
||||
|
||||
export function isOwner (account: Account): boolean {
|
||||
return account.role === AccountRole.Owner || account._id === core.account.System
|
||||
}
|
||||
|
||||
export class DBCollectionHelper implements DomainHelperOperations {
|
||||
constructor (
|
||||
protected readonly client: Pool,
|
||||
protected readonly workspaceId: WorkspaceId
|
||||
) {}
|
||||
|
||||
domains = new Set<Domain>()
|
||||
async create (domain: Domain): Promise<void> {}
|
||||
|
||||
async exists (domain: Domain): Promise<boolean> {
|
||||
const exists = await this.client.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = '${translateDomain(domain)}'
|
||||
`)
|
||||
return exists.rows.length > 0
|
||||
}
|
||||
|
||||
async listDomains (): Promise<Set<Domain>> {
|
||||
return this.domains
|
||||
}
|
||||
|
||||
async createIndex (domain: Domain, value: string | FieldIndexConfig<Doc>, options?: { name: string }): Promise<void> {}
|
||||
|
||||
async dropIndex (domain: Domain, name: string): Promise<void> {}
|
||||
|
||||
async listIndexes (domain: Domain): Promise<{ name: string }[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
async estimatedCount (domain: Domain): Promise<number> {
|
||||
const res = await this.client.query(`SELECT COUNT(_id) FROM ${translateDomain(domain)} WHERE "workspaceId" = $1`, [
|
||||
this.workspaceId.name
|
||||
])
|
||||
return res.rows[0].count
|
||||
}
|
||||
}
|
||||
|
||||
export function translateDomain (domain: string): string {
|
||||
return domain.replaceAll('-', '_')
|
||||
}
|
||||
|
||||
export function parseDocWithProjection<T extends Doc> (doc: DBDoc, projection: Projection<T> | undefined): T {
|
||||
const { workspaceId, data, ...rest } = doc
|
||||
for (const key in rest) {
|
||||
if ((rest as any)[key] === 'NULL') {
|
||||
if (key === 'attachedTo') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete rest[key]
|
||||
} else {
|
||||
;(rest as any)[key] = null
|
||||
}
|
||||
}
|
||||
if (key === 'modifiedOn' || key === 'createdOn') {
|
||||
;(rest as any)[key] = Number.parseInt((rest as any)[key])
|
||||
}
|
||||
}
|
||||
if (projection !== undefined) {
|
||||
for (const key in data) {
|
||||
if (!Object.prototype.hasOwnProperty.call(projection, key) || (projection as any)[key] === 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete data[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
const res = {
|
||||
...data,
|
||||
...rest
|
||||
} as any as T
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
export function parseDoc<T extends Doc> (doc: DBDoc): T {
|
||||
const { workspaceId, data, ...rest } = doc
|
||||
for (const key in rest) {
|
||||
if ((rest as any)[key] === 'NULL') {
|
||||
if (key === 'attachedTo') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete rest[key]
|
||||
} else {
|
||||
;(rest as any)[key] = null
|
||||
}
|
||||
}
|
||||
if (key === 'modifiedOn' || key === 'createdOn') {
|
||||
;(rest as any)[key] = Number.parseInt((rest as any)[key])
|
||||
}
|
||||
}
|
||||
const res = {
|
||||
...data,
|
||||
...rest
|
||||
} as any as T
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
export interface DBDoc extends Doc {
|
||||
workspaceId: string
|
||||
attachedTo?: Ref<Doc>
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
export function isDataField (field: string): boolean {
|
||||
return !docFields.includes(field)
|
||||
}
|
||||
|
||||
export const docFields: string[] = [
|
||||
'_id',
|
||||
'_class',
|
||||
'createdBy',
|
||||
'modifiedBy',
|
||||
'modifiedOn',
|
||||
'createdOn',
|
||||
'space',
|
||||
'attachedTo'
|
||||
] as const
|
||||
|
||||
export function getUpdateValue (value: any): string {
|
||||
if (typeof value === 'string') {
|
||||
return '"' + escapeDoubleQuotes(value) + '"'
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function escapeDoubleQuotes (jsonString: string): string {
|
||||
const unescapedQuotes = /(?<!\\)"/g
|
||||
|
||||
return jsonString.replace(unescapedQuotes, '\\"')
|
||||
}
|
||||
|
||||
export interface JoinProps {
|
||||
table: string // table to join
|
||||
path: string // _id.roles, attachedTo.attachedTo, space...
|
||||
fromAlias: string
|
||||
fromField: string
|
||||
toAlias: string // alias for the table
|
||||
toField: string // field to join on
|
||||
isReverse: boolean
|
||||
toClass: Ref<Class<Doc>>
|
||||
classes?: Ref<Class<Doc>>[] // filter by classes
|
||||
}
|
||||
Reference in New Issue
Block a user