{#if isDraggedOver}
@@ -160,12 +161,12 @@
_id={docid}
icon={isFolder ? documents.icon.Folder : documents.icon.Document}
iconProps={{
- fill: 'currentColor'
+ fill: isObsolete ? 'var(--dangerous-bg-color)' : 'currentColor'
}}
{title}
selected={selected === docid || selected === prjdoc._id}
isFold
- empty={children.length === 0 || children === undefined}
+ empty={children.length === 0}
actions={getMoreActions !== undefined ? () => getDocMoreActions(prjdoc) : undefined}
{level}
{collapsedPrefix}
diff --git a/plugins/controlled-documents-resources/src/components/print/DocumentPrintTitlePage.svelte b/plugins/controlled-documents-resources/src/components/print/DocumentPrintTitlePage.svelte
index ddc44074d4..a6dade36ef 100644
--- a/plugins/controlled-documents-resources/src/components/print/DocumentPrintTitlePage.svelte
+++ b/plugins/controlled-documents-resources/src/components/print/DocumentPrintTitlePage.svelte
@@ -38,6 +38,9 @@
case DocumentState.Archived:
statusWMLabel = plugin.string.Archived
break
+ case DocumentState.Obsolete:
+ statusWMLabel = plugin.string.Obsolete
+ break
}
}
@@ -51,7 +54,7 @@
$controlledDocument != null &&
(isOrgSpace
? $controlledDocument.state !== DocumentState.Effective
- : ![DocumentState.Effective, DocumentState.Archived].includes($controlledDocument.state))
+ : ![DocumentState.Effective, DocumentState.Obsolete, DocumentState.Archived].includes($controlledDocument.state))
{#if $controlledDocument !== null}
diff --git a/plugins/controlled-documents-resources/src/index.ts b/plugins/controlled-documents-resources/src/index.ts
index 5f22385e3f..e0b32883c5 100644
--- a/plugins/controlled-documents-resources/src/index.ts
+++ b/plugins/controlled-documents-resources/src/index.ts
@@ -182,6 +182,24 @@ async function archiveDocuments (obj: Document | Document[]): Promise
{
})
}
+async function makeDocumentObsolete (obj: Document | Document[]): Promise {
+ const docs = Array.isArray(obj) ? obj : [obj]
+ const docNames = docs.map((d) => `${d.title} (${d.prefix}-${d.seqNumber})`).join(', ')
+
+ showPopup(MessageBox, {
+ label: documents.string.MakeDocumentObsoleteDialog,
+ labelProps: { count: docs.length },
+ message: documents.string.MakeDocumentObsoleteConfirm,
+ params: { titles: docNames },
+ action: async () => {
+ const client = getClient()
+ for (const doc of docs) {
+ await client.update(doc, { state: DocumentState.Obsolete })
+ }
+ }
+ })
+}
+
async function canDeleteDocument (obj?: Doc | Doc[]): Promise {
if (obj == null) {
return false
@@ -220,6 +238,28 @@ async function canArchiveDocument (obj?: Doc | Doc[]): Promise {
).then((res) => res.every((r) => r))
}
+async function canMakeDocumentObsolete (obj?: Doc | Doc[]): Promise {
+ if (obj == null) {
+ return false
+ }
+
+ const objs = (Array.isArray(obj) ? obj : [obj]) as Document[]
+ const currentUser = getCurrentAccount() as PersonAccount
+ const isOwner = objs.every((doc) => doc.owner === currentUser.person)
+
+ if (isOwner) {
+ return true
+ }
+
+ const spaces = new Set(objs.map((doc) => doc.space))
+
+ return await Promise.all(
+ Array.from(spaces).map(
+ async (space) => await checkPermission(getClient(), documents.permission.ArchiveDocument, space)
+ )
+ ).then((res) => res.every((r) => r))
+}
+
async function canOpenDocument (obj?: ProjectDocument | ProjectDocument[]): Promise {
if (obj == null) {
return false
@@ -411,6 +451,7 @@ export default async (): Promise => ({
GetDocumentMetaLinkFragment: getDocumentMetaLinkFragment,
CanDeleteDocument: canDeleteDocument,
CanArchiveDocument: canArchiveDocument,
+ CanMakeDocumentObsolete: canMakeDocumentObsolete,
CanTransferDocument: canTransferDocument,
CanOpenDocument: canOpenDocument,
CanPrintDocument: canPrintDocument,
@@ -430,6 +471,7 @@ export default async (): Promise => ({
CreateFolder: createFolder,
DeleteDocument: deleteDocuments,
ArchiveDocument: archiveDocuments,
+ MakeDocumentObsolete: makeDocumentObsolete,
TransferDocument: transferDocuments,
EditDocSpace: editDocSpace
},
diff --git a/plugins/controlled-documents-resources/src/plugin.ts b/plugins/controlled-documents-resources/src/plugin.ts
index 164838e64e..b91b51218d 100644
--- a/plugins/controlled-documents-resources/src/plugin.ts
+++ b/plugins/controlled-documents-resources/src/plugin.ts
@@ -242,6 +242,7 @@ export default mergeIds(documentsId, documents, {
GetDocumentMetaLinkFragment: '' as Resource<(doc: Doc, props: Record) => Promise>,
CanDeleteDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise>,
CanArchiveDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise>,
+ CanMakeDocumentObsolete: '' as Resource<(doc?: Doc | Doc[]) => Promise>,
CanOpenDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise>,
CanPrintDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise>,
CanTransferDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise>,
diff --git a/plugins/controlled-documents-resources/src/stores/editors/document/canCreateNewDraft.ts b/plugins/controlled-documents-resources/src/stores/editors/document/canCreateNewDraft.ts
index faad3302cf..bc8d6cca34 100644
--- a/plugins/controlled-documents-resources/src/stores/editors/document/canCreateNewDraft.ts
+++ b/plugins/controlled-documents-resources/src/stores/editors/document/canCreateNewDraft.ts
@@ -21,9 +21,10 @@ export const $canCreateNewDraft = combine($controlledDocument, $documentAllVersi
if (document == null) return false
const currentIndex = versions.findIndex((p) => p._id === document._id)
+ const forbiddenStates = [DocumentState.Draft, DocumentState.Obsolete]
return (
versions.slice(0, currentIndex).every((p) => p.state === DocumentState.Deleted) &&
- document.state !== DocumentState.Draft
+ !forbiddenStates.includes(document.state)
)
})
diff --git a/plugins/controlled-documents-resources/src/utils.ts b/plugins/controlled-documents-resources/src/utils.ts
index 926e258897..b20fef6285 100644
--- a/plugins/controlled-documents-resources/src/utils.ts
+++ b/plugins/controlled-documents-resources/src/utils.ts
@@ -82,7 +82,8 @@ export async function getTranslatedDocumentStates (lang: string): Promise>,
DeleteDocument: '' as Ref,
ArchiveDocument: '' as Ref,
+ MakeDocumentObsolete: '' as Ref,
EditDocSpace: '' as Ref,
TransferDocument: '' as Ref,
Print: '' as Ref>,
@@ -202,6 +203,10 @@ export const documentsPlugin = plugin(documentsId, {
Deleted: '' as IntlString,
Effective: '' as IntlString,
Archived: '' as IntlString,
+ Obsolete: '' as IntlString,
+ MakeDocumentObsolete: '' as IntlString,
+ MakeDocumentObsoleteDialog: '' as IntlString,
+ MakeDocumentObsoleteConfirm: '' as IntlString,
Parent: '' as IntlString,
Template: '' as IntlString,
GeneralInfo: '' as IntlString,
diff --git a/plugins/controlled-documents/src/types.ts b/plugins/controlled-documents/src/types.ts
index 7bfaaef133..a6eb794240 100644
--- a/plugins/controlled-documents/src/types.ts
+++ b/plugins/controlled-documents/src/types.ts
@@ -194,7 +194,8 @@ export enum DocumentState {
Draft = 'draft',
Effective = 'effective',
Archived = 'archived',
- Deleted = 'deleted'
+ Deleted = 'deleted',
+ Obsolete = 'obsolete'
}
/**
diff --git a/plugins/diffview-resources/package.json b/plugins/diffview-resources/package.json
index 17b365167a..adcb84c950 100644
--- a/plugins/diffview-resources/package.json
+++ b/plugins/diffview-resources/package.json
@@ -47,7 +47,7 @@
"@hcengineering/view-resources": "^0.6.0",
"@hcengineering/highlight": "^0.6.0",
"@hcengineering/diffview": "^0.6.0",
- "fast-equals": "^5.0.1",
+ "fast-equals": "^5.2.2",
"diff2html": "~3.4.35"
}
}
diff --git a/plugins/document-resources/package.json b/plugins/document-resources/package.json
index 26c5f481b4..b700239771 100644
--- a/plugins/document-resources/package.json
+++ b/plugins/document-resources/package.json
@@ -63,8 +63,8 @@
"@hcengineering/document": "^0.6.0",
"@hcengineering/time": "^0.6.0",
"@hcengineering/rank": "^0.6.4",
- "@tiptap/core": "^2.6.6",
+ "@tiptap/core": "^2.11.3",
"slugify": "^1.6.6",
- "fast-equals": "^5.0.1"
+ "fast-equals": "^5.2.2"
}
}
diff --git a/plugins/drive-resources/package.json b/plugins/drive-resources/package.json
index 8d9a866359..9807633eb8 100644
--- a/plugins/drive-resources/package.json
+++ b/plugins/drive-resources/package.json
@@ -50,6 +50,6 @@
"@hcengineering/view": "^0.6.13",
"@hcengineering/view-resources": "^0.6.0",
"svelte": "^4.2.19",
- "fast-equals": "^5.0.1"
+ "fast-equals": "^5.2.2"
}
}
diff --git a/plugins/guest-resources/package.json b/plugins/guest-resources/package.json
index da72099f8c..5de7dba8e0 100644
--- a/plugins/guest-resources/package.json
+++ b/plugins/guest-resources/package.json
@@ -50,6 +50,6 @@
"@hcengineering/login": "^0.6.12",
"@hcengineering/view-resources": "^0.6.0",
"@hcengineering/analytics": "^0.6.0",
- "fast-copy": "~3.0.1"
+ "fast-copy": "^3.0.2"
}
}
diff --git a/plugins/lead-resources/package.json b/plugins/lead-resources/package.json
index a844bbad8f..ceff13c70b 100644
--- a/plugins/lead-resources/package.json
+++ b/plugins/lead-resources/package.json
@@ -61,6 +61,6 @@
"@hcengineering/view-resources": "^0.6.0",
"@hcengineering/workbench": "^0.6.16",
"svelte": "^4.2.19",
- "fast-equals": "^5.0.1"
+ "fast-equals": "^5.2.2"
}
}
diff --git a/plugins/login-resources/src/components/AdminWorkspaces.svelte b/plugins/login-resources/src/components/AdminWorkspaces.svelte
index 827f0b6f43..19e7905bab 100644
--- a/plugins/login-resources/src/components/AdminWorkspaces.svelte
+++ b/plugins/login-resources/src/components/AdminWorkspaces.svelte
@@ -1,5 +1,14 @@
+
+
diff --git a/plugins/recruit-resources/src/plugin.ts b/plugins/recruit-resources/src/plugin.ts
index c6e4c67081..a22e2b7c0d 100644
--- a/plugins/recruit-resources/src/plugin.ts
+++ b/plugins/recruit-resources/src/plugin.ts
@@ -127,7 +127,8 @@ export default mergeIds(recruitId, recruit, {
OpenVacancyList: '' as IntlString,
Export: '' as IntlString,
GetTalentIds: '' as IntlString,
- CreateNewSkills: '' as IntlString
+ CreateNewSkills: '' as IntlString,
+ SwapFirstAndLastNames: '' as IntlString
},
category: {
Other: '' as Ref,
diff --git a/plugins/setting-resources/src/plugin.ts b/plugins/setting-resources/src/plugin.ts
index 05450e7af0..03b12565d7 100644
--- a/plugins/setting-resources/src/plugin.ts
+++ b/plugins/setting-resources/src/plugin.ts
@@ -70,6 +70,7 @@ export default mergeIds(settingId, setting, {
AddOwner: '' as IntlString,
User: '' as IntlString,
Maintainer: '' as IntlString,
+ Guest: '' as IntlString,
Owner: '' as IntlString,
OwnerFirstName: '' as IntlString,
OwnerLastName: '' as IntlString,
diff --git a/plugins/test-management-resources/package.json b/plugins/test-management-resources/package.json
index 7cec2be751..59cf77f9d4 100644
--- a/plugins/test-management-resources/package.json
+++ b/plugins/test-management-resources/package.json
@@ -71,7 +71,7 @@
"@hcengineering/view-resources": "^0.6.0",
"@hcengineering/workbench": "^0.6.16",
"@hcengineering/workbench-resources": "^0.6.1",
- "fast-equals": "^5.0.1",
+ "fast-equals": "^5.2.2",
"svelte": "^4.2.19"
}
}
diff --git a/plugins/text-editor-resources/package.json b/plugins/text-editor-resources/package.json
index 422114b1ea..4ef3d4ad4d 100644
--- a/plugins/text-editor-resources/package.json
+++ b/plugins/text-editor-resources/package.json
@@ -50,40 +50,40 @@
"@hcengineering/text": "^0.6.5",
"@hcengineering/text-editor": "^0.6.0",
"@hcengineering/collaborator-client": "^0.6.4",
- "@tiptap/core": "^2.6.6",
- "@tiptap/pm": "^2.6.6",
- "@tiptap/extension-code-block-lowlight": "^2.6.6",
- "@tiptap/extension-collaboration": "^2.6.6",
- "@tiptap/extension-collaboration-cursor": "^2.6.6",
- "@tiptap/extension-placeholder": "^2.6.6",
- "@tiptap/extension-hard-break": "^2.6.6",
- "@tiptap/extension-bubble-menu": "^2.6.6",
- "@tiptap/extension-table": "^2.6.6",
- "@tiptap/extension-table-cell": "^2.6.6",
- "@tiptap/extension-table-header": "^2.6.6",
- "@tiptap/extension-table-row": "^2.6.6",
- "@tiptap/extension-heading": "^2.6.6",
- "@tiptap/extension-list-keymap": "^2.6.6",
- "@tiptap/extension-code": "^2.6.6",
- "@tiptap/extension-code-block": "^2.6.6",
- "@tiptap/extension-highlight": "^2.6.6",
- "@tiptap/extension-typography": "^2.6.6",
- "@tiptap/extension-link": "^2.6.6",
- "@tiptap/starter-kit": "^2.6.6",
- "@tiptap/extension-underline": "^2.6.6",
+ "@tiptap/core": "^2.11.3",
+ "@tiptap/pm": "^2.11.3",
+ "@tiptap/extension-code-block-lowlight": "^2.11.3",
+ "@tiptap/extension-collaboration": "^2.11.3",
+ "@tiptap/extension-collaboration-cursor": "^2.11.3",
+ "@tiptap/extension-placeholder": "^2.11.3",
+ "@tiptap/extension-hard-break": "^2.11.3",
+ "@tiptap/extension-bubble-menu": "^2.11.3",
+ "@tiptap/extension-table": "^2.11.3",
+ "@tiptap/extension-table-cell": "^2.11.3",
+ "@tiptap/extension-table-header": "^2.11.3",
+ "@tiptap/extension-table-row": "^2.11.3",
+ "@tiptap/extension-heading": "^2.11.3",
+ "@tiptap/extension-list-keymap": "^2.11.3",
+ "@tiptap/extension-code": "^2.11.3",
+ "@tiptap/extension-code-block": "^2.11.3",
+ "@tiptap/extension-highlight": "^2.11.3",
+ "@tiptap/extension-typography": "^2.11.3",
+ "@tiptap/extension-link": "^2.11.3",
+ "@tiptap/starter-kit": "^2.11.3",
+ "@tiptap/extension-underline": "^2.11.3",
"@hocuspocus/provider": "^2.11.0",
"prosemirror-codemark": "^0.4.2",
"y-protocols": "^1.0.6",
- "y-prosemirror": "^1.2.12",
- "y-websocket": "^2.0.4",
- "yjs": "^13.6.19",
- "fast-equals": "^5.0.1",
+ "y-prosemirror": "^1.2.15",
+ "y-websocket": "^2.1.0",
+ "yjs": "^13.6.23",
+ "fast-equals": "^5.2.2",
"rfc6902": "^5.0.1",
"diff": "^5.1.0",
"slugify": "^1.6.6",
"lib0": "^0.2.88",
"y-indexeddb": "^9.0.12",
- "lowlight": "^3.1.0",
+ "lowlight": "^3.3.0",
"mermaid": "~11.4.1",
"@hcengineering/theme": "^0.6.5",
"tippy.js": "~6.3.7",
diff --git a/plugins/text-editor-resources/src/components/extension/mermaid.ts b/plugins/text-editor-resources/src/components/extension/mermaid.ts
index f19c2f37fc..ba4c7177f6 100644
--- a/plugins/text-editor-resources/src/components/extension/mermaid.ts
+++ b/plugins/text-editor-resources/src/components/extension/mermaid.ts
@@ -287,7 +287,9 @@ export const MermaidExtension = CodeBlockLowlight.extend({
stopEvent: (event) => {
if (event instanceof DragEvent && !nodeState.folded) {
event.preventDefault()
+ return true
}
+ return false
},
update: (node, decorations) => {
if (node.type.name !== MermaidExtension.name) return false
diff --git a/plugins/text-editor-resources/src/components/node-view/svelte-node-view-renderer.ts b/plugins/text-editor-resources/src/components/node-view/svelte-node-view-renderer.ts
index b65fbd131c..778ef2896d 100644
--- a/plugins/text-editor-resources/src/components/node-view/svelte-node-view-renderer.ts
+++ b/plugins/text-editor-resources/src/components/node-view/svelte-node-view-renderer.ts
@@ -22,14 +22,15 @@ import {
type NodeViewRendererOptions,
type NodeViewRendererProps
} from '@tiptap/core'
-import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import type { ComponentType, SvelteComponent } from 'svelte'
+import { type Node } from '@tiptap/pm/model'
+import { type Decoration, DecorationSet } from '@tiptap/pm/view'
import { createNodeViewContext } from './context'
import { SvelteRenderer } from './svelte-renderer'
export interface SvelteNodeViewRendererOptions extends NodeViewRendererOptions {
- update?: (node: ProseMirrorNode, decorations: DecorationWithType[]) => boolean
+ update?: (node: Node, decorations: readonly Decoration[]) => boolean
contentAs?: string
contentClass?: string
componentProps?: Record
@@ -59,7 +60,7 @@ class SvelteNodeView extends NodeView this.getPos(),
@@ -69,6 +70,9 @@ class SvelteNodeView extends NodeView {
this.deleteNode()
},
+ innerDecorations: DecorationSet.empty,
+ HTMLAttributes: {},
+ view: this.editor.view,
...(this.options.componentProps ?? {})
}
@@ -117,7 +121,7 @@ class SvelteNodeView extends NodeView {
- this.on('synced', resolve)
+ this.loaded = new Promise((resolve) => {
+ this.on('sync', resolve)
})
}
diff --git a/plugins/text-editor/package.json b/plugins/text-editor/package.json
index 5799db503a..d41805ed3a 100644
--- a/plugins/text-editor/package.json
+++ b/plugins/text-editor/package.json
@@ -43,7 +43,7 @@
"@hcengineering/platform": "^0.6.11",
"@hcengineering/core": "^0.6.32",
"@hcengineering/ui": "^0.6.15",
- "@tiptap/core": "^2.6.6",
- "@tiptap/pm": "^2.6.6"
+ "@tiptap/core": "^2.11.3",
+ "@tiptap/pm": "^2.11.3"
}
}
diff --git a/plugins/time-resources/package.json b/plugins/time-resources/package.json
index cbe05310ea..03e3b6c844 100644
--- a/plugins/time-resources/package.json
+++ b/plugins/time-resources/package.json
@@ -64,9 +64,9 @@
"@hcengineering/text-editor-resources": "^0.6.0",
"@hcengineering/time": "^0.6.0",
"@hcengineering/rank": "^0.6.4",
- "@tiptap/extension-task-item": "^2.6.6",
- "@tiptap/extension-task-list": "^2.6.6",
- "fast-equals": "^5.0.1",
+ "@tiptap/extension-task-item": "^2.11.3",
+ "@tiptap/extension-task-list": "^2.11.3",
+ "fast-equals": "^5.2.2",
"@hcengineering/activity": "^0.6.0",
"@hcengineering/activity-resources": "^0.6.1",
"@hcengineering/workbench-resources": "^0.6.1"
diff --git a/plugins/tracker-resources/package.json b/plugins/tracker-resources/package.json
index 696365845d..30e9ed56ee 100644
--- a/plugins/tracker-resources/package.json
+++ b/plugins/tracker-resources/package.json
@@ -72,7 +72,7 @@
"@hcengineering/view-resources": "^0.6.0",
"@hcengineering/workbench": "^0.6.16",
"@hcengineering/workbench-resources": "^0.6.1",
- "fast-equals": "^5.0.1",
+ "fast-equals": "^5.2.2",
"svelte": "^4.2.19"
}
}
diff --git a/plugins/training-resources/package.json b/plugins/training-resources/package.json
index daac6d572e..93d1511547 100644
--- a/plugins/training-resources/package.json
+++ b/plugins/training-resources/package.json
@@ -56,7 +56,7 @@
"@hcengineering/questions": "^0.1.0",
"@hcengineering/questions-resources": "^0.1.0",
"@hcengineering/training": "^0.1.0",
- "fast-equals": "^5.0.1",
+ "fast-equals": "^5.2.2",
"lexorank": "~1.0.4",
"svelte": "^4.2.19"
}
diff --git a/plugins/view-resources/package.json b/plugins/view-resources/package.json
index d6acaa8170..8a58067938 100644
--- a/plugins/view-resources/package.json
+++ b/plugins/view-resources/package.json
@@ -58,7 +58,7 @@
"@hcengineering/text-editor-resources": "^0.6.0",
"@hcengineering/analytics": "^0.6.0",
"@hcengineering/query": "^0.6.12",
- "fast-equals": "^5.0.1",
- "hls.js": "^1.5.15"
+ "fast-equals": "^5.2.2",
+ "hls.js": "^1.5.20"
}
}
diff --git a/plugins/view-resources/src/components/list/List.svelte b/plugins/view-resources/src/components/list/List.svelte
index 893917e1e0..3f050046f7 100644
--- a/plugins/view-resources/src/components/list/List.svelte
+++ b/plugins/view-resources/src/components/list/List.svelte
@@ -23,22 +23,15 @@
Space,
mergeQueries
} from '@hcengineering/core'
- import { IntlString, getResource } from '@hcengineering/platform'
+ import { IntlString } from '@hcengineering/platform'
import { createQuery, getClient, reduceCalls } from '@hcengineering/presentation'
import { AnyComponent, AnySvelteComponent } from '@hcengineering/ui'
- import {
- BuildModelKey,
- ViewOptionModel,
- ViewOptions,
- ViewOptionsOption,
- ViewQueryOption,
- Viewlet
- } from '@hcengineering/view'
+ import { BuildModelKey, ViewOptionModel, ViewOptions, Viewlet } from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import { SelectionFocusProvider } from '../../selection'
import { buildConfigLookup } from '../../utils'
- import ListCategories from './ListCategories.svelte'
import { getResultOptions, getResultQuery } from '../../viewOptions'
+ import ListCategories from './ListCategories.svelte'
export let _class: Ref>
export let space: Ref | undefined = undefined
diff --git a/plugins/view-resources/src/components/list/ListHeader.svelte b/plugins/view-resources/src/components/list/ListHeader.svelte
index ce669a6f53..be99a8329c 100644
--- a/plugins/view-resources/src/components/list/ListHeader.svelte
+++ b/plugins/view-resources/src/components/list/ListHeader.svelte
@@ -230,6 +230,7 @@
min-height: 2.75rem;
min-width: 0;
background: var(--theme-bg-color);
+ border-radius: 0.25rem 0.25rem 0 0;
.on-hover {
visibility: hidden;
@@ -268,7 +269,7 @@
/* Global styles in components.scss and there is an influence from the Scroller component */
&.collapsed {
- border-radius: 0 0 0.25rem 0.25rem;
+ border-radius: 0.25rem;
.chevron {
transform: rotate(0deg);
diff --git a/plugins/workbench-resources/package.json b/plugins/workbench-resources/package.json
index c8dc7a025a..00367edd1a 100644
--- a/plugins/workbench-resources/package.json
+++ b/plugins/workbench-resources/package.json
@@ -57,7 +57,7 @@
"@hcengineering/support": "^0.6.5",
"@hcengineering/support-resources": "^0.6.0",
"@hcengineering/view-resources": "^0.6.0",
- "fast-copy": "~3.0.1",
+ "fast-copy": "^3.0.2",
"@hcengineering/analytics": "^0.6.0"
}
}
diff --git a/pods/backup/src/index.ts b/pods/backup/src/index.ts
index 9aa888997f..6a5d2817ea 100644
--- a/pods/backup/src/index.ts
+++ b/pods/backup/src/index.ts
@@ -73,7 +73,7 @@ const sentryDSN = process.env.SENTRY_DSN
configureAnalytics(sentryDSN, {})
Analytics.setTag('application', 'backup-service')
-const usePrepare = process.env.DB_PREPARE === 'true'
+const usePrepare = (process.env.DB_PREPARE ?? 'true') === 'true'
setDBExtraOptions({
prepare: usePrepare // We override defaults
diff --git a/pods/fulltext/src/server.ts b/pods/fulltext/src/server.ts
index b826a1ac2b..6c0cbcdbaf 100644
--- a/pods/fulltext/src/server.ts
+++ b/pods/fulltext/src/server.ts
@@ -237,7 +237,7 @@ export async function startIndexer (
): Promise<() => void> {
const closeTimeout = 5 * 60 * 1000
- const usePrepare = process.env.DB_PREPARE === 'true'
+ const usePrepare = (process.env.DB_PREPARE ?? 'true') === 'true'
setDBExtraOptions({
prepare: usePrepare // We override defaults
diff --git a/pods/green/.eslintrc.js b/pods/green/.eslintrc.js
new file mode 100644
index 0000000000..ce90fb9646
--- /dev/null
+++ b/pods/green/.eslintrc.js
@@ -0,0 +1,7 @@
+module.exports = {
+ extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'],
+ parserOptions: {
+ tsconfigRootDir: __dirname,
+ project: './tsconfig.json'
+ }
+}
diff --git a/pods/green/.gitignore b/pods/green/.gitignore
new file mode 100644
index 0000000000..9b1ee42e84
--- /dev/null
+++ b/pods/green/.gitignore
@@ -0,0 +1,175 @@
+# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
+
+# Logs
+
+logs
+_.log
+npm-debug.log_
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Caches
+
+.cache
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# Runtime data
+
+pids
+_.pid
+_.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+
+lib-cov
+
+# Coverage directory used by tools like istanbul
+
+coverage
+*.lcov
+
+# nyc test coverage
+
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+
+bower_components
+
+# node-waf configuration
+
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+
+build/Release
+
+# Dependency directories
+
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+
+web_modules/
+
+# TypeScript cache
+
+*.tsbuildinfo
+
+# Optional npm cache directory
+
+.npm
+
+# Optional eslint cache
+
+.eslintcache
+
+# Optional stylelint cache
+
+.stylelintcache
+
+# Microbundle cache
+
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+
+.node_repl_history
+
+# Output of 'npm pack'
+
+*.tgz
+
+# Yarn Integrity file
+
+.yarn-integrity
+
+# dotenv environment variable files
+
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+
+.parcel-cache
+
+# Next.js build output
+
+.next
+out
+
+# Nuxt.js build / generate output
+
+.nuxt
+dist
+
+# Gatsby files
+
+# Comment in the public line in if your project uses Gatsby and not Next.js
+
+# https://nextjs.org/blog/next-9-1#public-directory-support
+
+# public
+
+# vuepress build output
+
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+
+.temp
+
+# Docusaurus cache and generated files
+
+.docusaurus
+
+# Serverless directories
+
+.serverless/
+
+# FuseBox cache
+
+.fusebox/
+
+# DynamoDB Local files
+
+.dynamodb/
+
+# TernJS port file
+
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+
+.vscode-test
+
+# yarn v2
+
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+# IntelliJ based IDEs
+.idea
+
+# Finder (MacOS) folder config
+.DS_Store
diff --git a/pods/green/Dockerfile b/pods/green/Dockerfile
new file mode 100644
index 0000000000..6792286f9d
--- /dev/null
+++ b/pods/green/Dockerfile
@@ -0,0 +1,9 @@
+
+FROM hardcoreeng/base:v20250113a
+WORKDIR /usr/src/app
+
+COPY bundle/bundle.js ./
+COPY bundle/bundle.js.map ./
+
+EXPOSE 6767
+CMD [ "node", "--expose-gc", "bundle.js" ]
diff --git a/pods/green/README.md b/pods/green/README.md
new file mode 100644
index 0000000000..69d0368090
--- /dev/null
+++ b/pods/green/README.md
@@ -0,0 +1,15 @@
+# green
+
+To install dependencies:
+
+```bash
+bun install
+```
+
+To run:
+
+```bash
+bun run index.ts
+```
+
+This project was created using `bun init` in bun v1.1.30. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
diff --git a/pods/green/config/rig.json b/pods/green/config/rig.json
new file mode 100644
index 0000000000..78cc5a1733
--- /dev/null
+++ b/pods/green/config/rig.json
@@ -0,0 +1,5 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
+ "rigPackageName": "@hcengineering/platform-rig",
+ "rigProfile": "node"
+}
diff --git a/pods/green/jest.config.js b/pods/green/jest.config.js
new file mode 100644
index 0000000000..2cfd408b67
--- /dev/null
+++ b/pods/green/jest.config.js
@@ -0,0 +1,7 @@
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: 'node',
+ testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
+ roots: ["./src"],
+ coverageReporters: ["text-summary", "html"]
+}
diff --git a/pods/green/package.json b/pods/green/package.json
new file mode 100644
index 0000000000..87c5e5eb21
--- /dev/null
+++ b/pods/green/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "@hcengineering/green",
+ "version": "0.6.0",
+ "main": "lib/index.js",
+ "svelte": "src/index.ts",
+ "types": "types/index.d.ts",
+ "author": "Anticrm Platform Contributors",
+ "template": "@hcengineering/node-package",
+ "license": "EPL-2.0",
+ "scripts": {
+ "build": "compile",
+ "build:watch": "compile",
+ "_phase:bundle": "rushx bundle",
+ "_phase:docker-build": "rushx docker:build",
+ "_phase:docker-staging": "rushx docker:staging",
+ "bundle": "node ../../common/scripts/esbuild.js --keep-names=true --bundle=true --external=*.node --external=snappy --sourcemap=external",
+ "docker:build": "../../common/scripts/docker_build.sh hardcoreeng/green",
+ "docker:tbuild": "docker build -t hardcoreeng/green . --platform=linux/amd64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/green",
+ "docker:abuild": "docker build -t hardcoreeng/green . --platform=linux/arm64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/green",
+ "docker:staging": "../../common/scripts/docker_tag.sh hardcoreeng/green staging",
+ "docker:push": "../../common/scripts/docker_tag.sh hardcoreeng/green",
+ "start": "rushx bundle && node --expose-gc --max-old-space-size=8000 ./bundle/bundle.js",
+ "format": "format src",
+ "test": "jest --passWithNoTests --silent --forceExit",
+ "_phase:build": "compile transpile src",
+ "_phase:test": "jest --passWithNoTests --silent --forceExit",
+ "_phase:format": "format src",
+ "_phase:validate": "compile validate"
+ },
+ "devDependencies": {
+ "cross-env": "~7.0.3",
+ "@hcengineering/platform-rig": "^0.6.0",
+ "@types/node": "~20.11.16",
+ "@typescript-eslint/eslint-plugin": "^6.11.0",
+ "eslint-plugin-import": "^2.26.0",
+ "eslint-plugin-promise": "^6.1.1",
+ "eslint-plugin-n": "^15.4.0",
+ "eslint": "^8.54.0",
+ "esbuild": "^0.24.2",
+ "@typescript-eslint/parser": "^6.11.0",
+ "eslint-config-standard-with-typescript": "^40.0.0",
+ "prettier": "^3.1.0",
+ "typescript": "^5.3.3",
+ "@hcengineering/model-all": "^0.6.0",
+ "jest": "^29.7.0",
+ "ts-jest": "^29.1.1",
+ "@types/jest": "^29.5.5"
+ },
+ "dependencies": {
+ "postgres": "^3.4.5",
+ "snappy": "^7.2.2"
+ }
+}
diff --git a/pods/green/src/index.ts b/pods/green/src/index.ts
new file mode 100644
index 0000000000..622c7f16ad
--- /dev/null
+++ b/pods/green/src/index.ts
@@ -0,0 +1,130 @@
+import postgres, { type Sql } from 'postgres'
+import { compress } from 'snappy'
+
+import http from 'node:http'
+
+const port = parseInt(process.env.PORT ?? '6767')
+const version = process.env.VERSION ?? '0.6.388'
+const dbUrl = process.env.DB_URL ?? 'postgresql://root@host.docker.internal:26257/defaultdb?sslmode=disable'
+const extraOptions = JSON.parse(process.env.DB_OPTIONS ?? '{}')
+
+const authToken = process.env.AUTH_TOKEN ?? 'secret'
+const tickTimeout = 5000
+
+console.log('Green service: v4 ' + version, ' on port ' + port)
+
+const sql: Sql = postgres(dbUrl, {
+ connection: {
+ application_name: 'green'
+ },
+ max: 100,
+ min: 2,
+ connect_timeout: 10,
+ idle_timeout: 30,
+ max_lifetime: 300,
+ transform: {
+ undefined: null
+ },
+ debug: false,
+ notice: false,
+ onnotice (notice) {},
+ onparameter (key, value) {},
+ ...extraOptions,
+ prepare: true,
+ fetch_types: true
+})
+
+async function toResponse (compression: string, data: any, response: http.ServerResponse): Promise {
+ if (compression === 'snappy') {
+ response
+ .writeHead(200, {
+ 'content-type': 'application/json',
+ compression: 'snappy',
+ 'keep-alive': 'timeout=5'
+ })
+ .end(await compress(JSON.stringify(data)))
+ } else {
+ response
+ .writeHead(200, {
+ 'content-type': 'application/json',
+ 'keep-alive': 'timeout=5'
+ })
+ .end(JSON.stringify(data))
+ }
+}
+
+const activeQueries = new Map void, query: string }>()
+
+setInterval(() => {
+ for (const [k, v] of activeQueries.entries()) {
+ if (Date.now() - v.time > tickTimeout) {
+ console.log('query hang', k, v)
+ v.cancel()
+ activeQueries.delete(k)
+ }
+ }
+}, tickTimeout)
+
+let queryId = 0
+
+async function handleSQLFind (
+ compression: string,
+ req: http.IncomingMessage,
+ response: http.ServerResponse
+): Promise {
+ let data = ''
+ for await (const chunk of req) {
+ data += chunk
+ }
+ const json = JSON.parse(data)
+ const qid = ++queryId
+ try {
+ const lq = (json.query as string).toLowerCase()
+ if (lq.includes('begin') || lq.includes('commit') || lq.includes('rollback')) {
+ console.error('not allowed', json.query)
+ response.writeHead(403).end('Not allowed')
+ return
+ }
+ const st = Date.now()
+ const query = sql.unsafe(json.query, json.params, { prepare: true })
+ activeQueries.set(qid, {
+ time: Date.now(),
+ cancel: () => {
+ query.cancel()
+ },
+ query: json.query
+ })
+ const result = await query
+ console.log('query', json.query, Date.now() - st, result.length)
+ await toResponse(compression, result, response)
+ } catch (err: any) {
+ console.error('failed to execute sql', json.query, json.params, err.message, err)
+ if (!response.writableEnded) {
+ response.writeHead(500).end(err.message)
+ }
+ } finally {
+ activeQueries.delete(qid)
+ }
+}
+
+const reqHandler = (req: http.IncomingMessage, resp: http.ServerResponse): void => {
+ const token = ((req.headers.authorization as string) ?? '').split(' ')[1]
+ const compression = (req.headers.compression as string) ?? ''
+ if (token !== authToken) {
+ resp.writeHead(401).end('Unauthorized')
+ return
+ }
+
+ const url = req.url ?? ''
+ if (url.startsWith('/api/v1/version')) {
+ resp.writeHead(200).end(version)
+ return
+ }
+ if (req.method === 'POST' && url.startsWith('/api/v1/sql')) {
+ void handleSQLFind(compression, req, resp)
+ } else {
+ resp.writeHead(404).end('Not found')
+ }
+}
+
+http.createServer(reqHandler).listen(port)
diff --git a/pods/green/tsconfig.json b/pods/green/tsconfig.json
new file mode 100644
index 0000000000..f017cc597c
--- /dev/null
+++ b/pods/green/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/tsconfig.json",
+
+ "compilerOptions": {
+ "rootDir": "./src",
+ "outDir": "./lib",
+ "declarationDir": "./types",
+ "tsBuildInfoFile": ".build/build.tsbuildinfo"
+ }
+}
\ No newline at end of file
diff --git a/pods/server/src/__start.ts b/pods/server/src/__start.ts
index 04a15b4e1d..2d049cf40a 100644
--- a/pods/server/src/__start.ts
+++ b/pods/server/src/__start.ts
@@ -59,7 +59,7 @@ setOperationLogProfiling(process.env.OPERATION_PROFILING === 'true')
const config = serverConfigFromEnv()
const storageConfig: StorageConfiguration = storageConfigFromEnv()
-const usePrepare = process.env.DB_PREPARE === 'true'
+const usePrepare = (process.env.DB_PREPARE ?? 'true') === 'true'
setDBExtraOptions({
prepare: usePrepare // We override defaults
diff --git a/pods/server/src/server.ts b/pods/server/src/server.ts
index 46344ffd5e..5c7595f1bc 100644
--- a/pods/server/src/server.ts
+++ b/pods/server/src/server.ts
@@ -36,6 +36,7 @@ import {
registerTxAdapterFactory,
sharedPipelineContextVars
} from '@hcengineering/server-pipeline'
+import { uncompress } from 'snappy'
import {
createMongoAdapter,
@@ -47,6 +48,8 @@ import {
createPostgreeDestroyAdapter,
createPostgresAdapter,
createPostgresTxAdapter,
+ registerGreenDecoder,
+ registerGreenUrl,
setDBExtraOptions,
shutdownPostgres
} from '@hcengineering/postgres'
@@ -97,7 +100,10 @@ export function start (
registerAdapterFactory('postgresql', createPostgresAdapter, true)
registerDestroyFactory('postgresql', createPostgreeDestroyAdapter, true)
- const usePrepare = process.env.DB_PREPARE === 'true'
+ const usePrepare = (process.env.DB_PREPARE ?? 'true') === 'true'
+
+ registerGreenDecoder('snappy', uncompress)
+ registerGreenUrl(process.env.GREEN_URL)
setDBExtraOptions({
prepare: usePrepare // We override defaults
diff --git a/pods/stats/src/stats.ts b/pods/stats/src/stats.ts
index ee7d158287..8a4bce1cac 100644
--- a/pods/stats/src/stats.ts
+++ b/pods/stats/src/stats.ts
@@ -131,8 +131,7 @@ export function serveStats (ctx: MeasureContext, onClose?: () => void): void {
}
req.body = dta
} catch (err: any) {
- Analytics.handleError(err)
- console.error(err)
+ console.error(err, req.host, req.headers, req.ip)
req.res.writeHead(404, {})
req.res.end()
}
@@ -182,8 +181,7 @@ export function serveStats (ctx: MeasureContext, onClose?: () => void): void {
req.res.writeHead(200)
req.res.end()
} catch (err: any) {
- Analytics.handleError(err)
- console.error(err)
+ console.error(err, req.host, req.headers, req.ip)
req.res.writeHead(404, {})
req.res.end()
}
diff --git a/qms-tests/sanity/tests/model/documents/document-content-page.ts b/qms-tests/sanity/tests/model/documents/document-content-page.ts
index 9ca1a3e759..62f6c18ebb 100644
--- a/qms-tests/sanity/tests/model/documents/document-content-page.ts
+++ b/qms-tests/sanity/tests/model/documents/document-content-page.ts
@@ -488,7 +488,8 @@ export class DocumentContentPage extends DocumentCommonPage {
await this.page.getByRole('button', { name: 'AQ Admin Qara' }).click()
await this.page.getByRole('button', { name: 'AJ Appleseed John' }).nth(1).click()
await this.page.keyboard.press('Escape')
- await this.page.getByRole('button', { name: 'Create' }).click()
+ await expect(this.page.locator('.selectPopup')).not.toBeAttached()
+ await this.page.getByRole('button', { name: 'Create' }).click({ timeout: 3000 })
}
async checkIfUserCanCreateDocument (spaceName: string): Promise {
diff --git a/rush.json b/rush.json
index 0c3e386ddf..a861ad41f8 100644
--- a/rush.json
+++ b/rush.json
@@ -2250,6 +2250,11 @@
"packageName": "@hcengineering/card-resources",
"projectFolder": "plugins/card-resources",
"shouldPublish": false
+ },
+ {
+ "packageName": "@hcengineering/green",
+ "projectFolder": "pods/green",
+ "shouldPublish": false
}
]
}
diff --git a/server-plugins/ai-bot-resources/src/utils.ts b/server-plugins/ai-bot-resources/src/utils.ts
index f603d89c32..2b407d51fb 100644
--- a/server-plugins/ai-bot-resources/src/utils.ts
+++ b/server-plugins/ai-bot-resources/src/utils.ts
@@ -65,6 +65,7 @@ export async function createAccountRequest (workspace: WorkspaceId, ctx: Measure
ctx.info('Requesting AI account creation', { url, workspace })
await fetch(concatLink(url, '/connect'), {
method: 'POST',
+ keepalive: true,
headers: {
Authorization: 'Bearer ' + generateToken(systemAccountEmail, workspace),
'Content-Type': 'application/json'
diff --git a/server-plugins/gmail-resources/src/index.ts b/server-plugins/gmail-resources/src/index.ts
index 6869330ade..758269bd1a 100644
--- a/server-plugins/gmail-resources/src/index.ts
+++ b/server-plugins/gmail-resources/src/index.ts
@@ -119,6 +119,7 @@ export async function sendEmailNotification (
const sesAuth: string | undefined = getMetadata(serverNotification.metadata.SesAuthToken)
await fetch(concatLink(sesURL, '/send'), {
method: 'post',
+ keepalive: true,
headers: {
'Content-Type': 'application/json',
...(sesAuth != null ? { Authorization: `Bearer ${sesAuth}` } : {})
diff --git a/server-plugins/notification-resources/src/push.ts b/server-plugins/notification-resources/src/push.ts
index 1ca94311c0..d3560f49b0 100644
--- a/server-plugins/notification-resources/src/push.ts
+++ b/server-plugins/notification-resources/src/push.ts
@@ -209,6 +209,7 @@ async function sendPushToSubscription (
await (
await fetch(concatLink(sesURL, '/web-push'), {
method: 'post',
+ keepalive: true,
headers: {
'Content-Type': 'application/json',
...(sesAuth != null ? { Authorization: `Bearer ${sesAuth}` } : {})
diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts
index 29778cd181..f02fe5a340 100644
--- a/server/account/src/operations.ts
+++ b/server/account/src/operations.ts
@@ -63,6 +63,7 @@ import { connect } from '@hcengineering/server-tool'
import { randomBytes } from 'crypto'
import otpGenerator from 'otp-generator'
+import { getWorkspaceDestroyAdapter, sharedPipelineContextVars } from '@hcengineering/server-pipeline'
import { accountPlugin } from './plugin'
import type {
Account,
@@ -92,7 +93,6 @@ import {
toAccountInfo,
verifyPassword
} from './utils'
-import { getWorkspaceDestroyAdapter, sharedPipelineContextVars } from '@hcengineering/server-pipeline'
import MD5 from 'crypto-js/md5'
function buildGravatarId (email: string): string {
@@ -866,7 +866,8 @@ export async function listWorkspaces (
db: AccountDB,
branding: Branding | null,
token: string,
- region?: string | null
+ region?: string | null,
+ mode?: WorkspaceMode | null
): Promise {
decodeToken(ctx, token) // Just verify token is valid
@@ -874,9 +875,17 @@ export async function listWorkspaces (
region = null
}
- return (await db.workspace.find(region != null ? { region } : {}))
- .filter((it) => it.disabled !== true)
- .map(trimWorkspaceInfo)
+ const q: Query = {
+ disabled: { $ne: true }
+ }
+ if (region != null) {
+ q.region = region
+ }
+ if (mode != null) {
+ q.mode = mode
+ }
+
+ return (await db.workspace.find(q)).map(trimWorkspaceInfo)
}
/**
@@ -1701,7 +1710,8 @@ export async function getAllWorkspaces (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
- token: string
+ token: string,
+ mode?: WorkspaceMode
): Promise {
const { email } = decodeToken(ctx, token)
const account = await getAccount(db, email)
diff --git a/server/backup/package.json b/server/backup/package.json
index 28ab5c9d08..49e0e82c5d 100644
--- a/server/backup/package.json
+++ b/server/backup/package.json
@@ -52,6 +52,6 @@
"@hcengineering/server-client": "^0.6.0",
"@hcengineering/server-token": "^0.6.11",
"@hcengineering/server-core": "^0.6.1",
- "fast-equals": "^5.0.1"
+ "fast-equals": "^5.2.2"
}
}
diff --git a/server/backup/src/service.ts b/server/backup/src/service.ts
index 13d04eaec8..7fada3bdc6 100644
--- a/server/backup/src/service.ts
+++ b/server/backup/src/service.ts
@@ -96,16 +96,18 @@ class BackupWorker {
console.log('schedule backup with interval', this.config.Interval, 'seconds')
while (!this.canceled) {
try {
- const res = await this.backup(ctx, this.config.CoolDown * 1000)
+ const res = await this.backup(ctx, (this.config.Interval / 4) * 1000)
this.printStats(ctx, res)
+ if (res.skipped === 0) {
+ console.log('cool down', this.config.CoolDown, 'seconds')
+ await new Promise((resolve) => setTimeout(resolve, this.config.CoolDown * 1000))
+ }
} catch (err: any) {
Analytics.handleError(err)
ctx.error('error retry in cool down/5', { cooldown: this.config.CoolDown, error: err })
await new Promise((resolve) => setTimeout(resolve, (this.config.CoolDown / 5) * 1000))
continue
}
- console.log('cool down', this.config.CoolDown, 'seconds')
- await new Promise((resolve) => setTimeout(resolve, this.config.CoolDown * 1000))
}
}
@@ -145,15 +147,31 @@ class BackupWorker {
return !workspacesIgnore.has(it.workspace)
})
workspaces.sort((a, b) => {
- return (b.backupInfo?.backupSize ?? 0) - (a.backupInfo?.backupSize ?? 0)
+ const lastBackupMin = Math.round(((a.backupInfo?.lastBackup ?? 0) - (b.backupInfo?.lastBackup ?? 0)) / 60)
+ if (lastBackupMin === 0) {
+ // Same minute, sort by backup size
+ return (a.backupInfo?.backupSize ?? 0) - (b.backupInfo?.backupSize ?? 0)
+ }
+ return lastBackupMin
})
- ctx.info('Preparing for BACKUP', {
+ ctx.warn('Preparing for BACKUP', {
total: workspaces.length,
skipped,
workspaces: workspaces.map((it) => it.workspace)
})
+ const part = workspaces.slice(0, 500)
+ let idx = 0
+ for (const ws of part) {
+ ctx.warn('prepare workspace', {
+ idx: ++idx,
+ workspace: ws.workspaceUrl ?? ws.workspace,
+ backupSize: ws.backupInfo?.backupSize ?? 0,
+ lastBackupSec: (Date.now() - (ws.backupInfo?.lastBackup ?? 0)) / 1000
+ })
+ }
+
return await this.doBackup(ctx, workspaces, recheckTimeout)
}
diff --git a/server/client/src/account.ts b/server/client/src/account.ts
index b83dcc0b6a..9f81710e9b 100644
--- a/server/client/src/account.ts
+++ b/server/client/src/account.ts
@@ -50,7 +50,7 @@ export async function listAccountWorkspaces (token: string, region: string | nul
},
body: JSON.stringify({
method: 'listWorkspaces',
- params: [token, region]
+ params: [token, region, 'active']
})
})
).json()
diff --git a/server/collaboration/package.json b/server/collaboration/package.json
index 399eff7063..50c52a6157 100644
--- a/server/collaboration/package.json
+++ b/server/collaboration/package.json
@@ -43,6 +43,6 @@
"@hcengineering/text": "^0.6.5",
"@hcengineering/text-ydoc": "^0.6.0",
"base64-js": "^1.5.1",
- "yjs": "^13.6.19"
+ "yjs": "^13.6.23"
}
}
diff --git a/server/collaborator/package.json b/server/collaborator/package.json
index 2f3a33d1ed..37d848ec96 100644
--- a/server/collaborator/package.json
+++ b/server/collaborator/package.json
@@ -65,11 +65,11 @@
"@hcengineering/mongo": "^0.6.1",
"@hocuspocus/server": "^2.13.5",
"@hocuspocus/transformer": "^2.13.5",
- "@tiptap/core": "^2.6.6",
- "@tiptap/html": "^2.6.6",
+ "@tiptap/core": "^2.11.3",
+ "@tiptap/html": "^2.11.3",
"mongodb": "^6.12.0",
- "yjs": "^13.6.19",
- "y-prosemirror": "^1.2.12",
+ "yjs": "^13.6.23",
+ "y-prosemirror": "^1.2.15",
"express": "^4.21.2",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
diff --git a/server/core/package.json b/server/core/package.json
index c1e5fed289..1d14855263 100644
--- a/server/core/package.json
+++ b/server/core/package.json
@@ -41,7 +41,7 @@
"@hcengineering/analytics": "^0.6.0",
"@hcengineering/server-token": "^0.6.11",
"@hcengineering/query": "^0.6.12",
- "fast-equals": "^5.0.1",
+ "fast-equals": "^5.2.2",
"@hcengineering/storage": "^0.6.0",
"uuid": "^8.3.2"
}
diff --git a/server/front/src/index.ts b/server/front/src/index.ts
index 156b9eec4a..904c2599c1 100644
--- a/server/front/src/index.ts
+++ b/server/front/src/index.ts
@@ -25,9 +25,9 @@ import fileUpload, { UploadedFile } from 'express-fileupload'
import expressStaticGzip from 'express-static-gzip'
import https from 'https'
import morgan from 'morgan'
-import { join, resolve } from 'path'
+import { join, normalize, resolve } from 'path'
import { cwd } from 'process'
-import sharp from 'sharp'
+import sharp, { type Sharp } from 'sharp'
import { v4 as uuid } from 'uuid'
import { preConditions } from './utils'
@@ -109,6 +109,7 @@ async function getFileRange (
)
res.writeHead(206, {
Connection: 'keep-alive',
+ 'Keep-Alive': 'timeout=5',
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
@@ -177,7 +178,8 @@ async function getFile (
etag: stat.etag,
'last-modified': new Date(stat.modifiedOn).toISOString(),
'cache-control': cacheControlValue,
- Connection: 'keep-alive'
+ Connection: 'keep-alive',
+ 'Keep-Alive': 'timeout=5'
})
res.end()
return
@@ -189,7 +191,8 @@ async function getFile (
etag: stat.etag,
'last-modified': new Date(stat.modifiedOn).toISOString(),
'cache-control': cacheControlValue,
- Connection: 'keep-alive'
+ Connection: 'keep-alive',
+ 'Keep-Alive': 'timeout=5'
})
res.end()
return
@@ -204,10 +207,12 @@ async function getFile (
res.writeHead(200, {
'Content-Type': stat.contentType,
'Content-Security-Policy': "default-src 'none';",
+ 'Content-Length': stat.size,
Etag: stat.etag,
'Last-Modified': new Date(stat.modifiedOn).toISOString(),
'Cache-Control': cacheControlValue,
- Connection: 'keep-alive'
+ Connection: 'keep-alive',
+ 'Keep-Alive': 'timeout=5'
})
dataStream.pipe(res)
@@ -270,6 +275,24 @@ export function start (
const tempFileDir = mkdtempSync(join(tmpdir(), 'front-'))
let temoFileIndex = 0
+ function cleanupTempFiles (): void {
+ const maxAge = 1000 * 60 * 60 // 1 hour
+ fs.readdir(tempFileDir, (err, files) => {
+ if (err != null) return
+ files.forEach((file) => {
+ const filePath = join(tempFileDir, file)
+ fs.stat(filePath, (err, stats) => {
+ if (err != null) return
+ if (Date.now() - stats.mtime.getTime() > maxAge) {
+ fs.unlink(filePath, () => {})
+ }
+ })
+ })
+ })
+ }
+
+ setInterval(cleanupTempFiles, 1000 * 60 * 15) // Run every 15 minutes
+
app.use(cors())
app.use(
fileUpload({
@@ -320,6 +343,7 @@ export function start (
res.status(200)
res.set('Cache-Control', cacheControlNoCache)
res.set('Connection', 'keep-alive')
+ res.set('Keep-Alive', 'timeout=5')
res.json(data)
})
@@ -331,6 +355,7 @@ export function start (
res.status(200)
res.setHeader('Content-Type', 'application/json')
res.setHeader('Connection', 'keep-alive')
+ res.setHeader('Keep-Alive', 'timeout=5')
res.setHeader('Cache-Control', cacheControlNoCache)
const json = JSON.stringify({
@@ -378,6 +403,7 @@ export function start (
res.setHeader('Cache-Control', cacheControlNoCache)
}
res.setHeader('Connection', 'keep-alive')
+ res.setHeader('Keep-Alive', 'timeout=5')
}
}
})
@@ -416,6 +442,7 @@ export function start (
if (req.method === 'HEAD') {
res.writeHead(200, {
'accept-ranges': 'bytes',
+ 'Keep-Alive': 'timeout=5',
'content-length': blobInfo.size,
'content-security-policy': "default-src 'none';",
Etag: blobInfo.etag,
@@ -605,7 +632,7 @@ export function start (
}
const id = uuid()
const contentType = response.headers['content-type'] ?? 'application/octet-stream'
- const data: Buffer[] = []
+ const data: Uint8Array[] = []
response
.on('data', function (chunk) {
data.push(chunk)
@@ -683,7 +710,7 @@ export function start (
}
const id = uuid()
const contentType = response.headers['content-type']
- const data: Buffer[] = []
+ const data: Uint8Array[] = []
response
.on('data', function (chunk) {
data.push(chunk)
@@ -737,6 +764,11 @@ export function start (
]
app.get('*', (request, response) => {
+ const safePath = normalize(join(dist, request.path))
+ if (!safePath.startsWith(dist)) {
+ response.sendStatus(403)
+ return
+ }
if (filesPatterns.some((it) => request.path.endsWith(it))) {
response.sendStatus(404)
return
@@ -746,7 +778,8 @@ export function start (
lastModified: true,
cacheControl: false,
headers: {
- 'Cache-Control': cacheControlNoCache
+ 'Cache-Control': cacheControlNoCache,
+ 'Keep-Alive': 'timeout=5'
}
})
})
@@ -811,13 +844,19 @@ async function getGeneratePreview (
return d
} else {
const files: string[] = []
+ let pipeline: Sharp | undefined
try {
// Let's get data and resize it
const fname = tempFile()
files.push(fname)
await writeFile(fname, await config.storageAdapter.get(ctx, payload.workspace, uuid))
- let pipeline = sharp(fname)
+ pipeline = sharp(fname)
+ const md = await pipeline.metadata()
+ if (md.format === undefined) {
+ // No format detected, return blob
+ return blob
+ }
sharp.cache(false)
pipeline = pipeline.resize({
@@ -864,7 +903,7 @@ async function getGeneratePreview (
const outFile = tempFile()
files.push(outFile)
- const dataBuff = await ctx.with('resize', { contentType }, () => pipeline.toFile(outFile))
+ const dataBuff = await ctx.with('resize', { contentType }, () => (pipeline as Sharp).toFile(outFile))
pipeline.destroy()
// Add support of avif as well.
@@ -897,6 +936,7 @@ async function getGeneratePreview (
// Return original in case of error
return blob
} finally {
+ pipeline?.destroy()
for (const f of files) {
await rm(f)
}
diff --git a/server/indexer/package.json b/server/indexer/package.json
index c1742e0515..902ba4f736 100644
--- a/server/indexer/package.json
+++ b/server/indexer/package.json
@@ -45,7 +45,7 @@
"@hcengineering/contact": "^0.6.24",
"@hcengineering/attachment": "^0.6.14",
"@hcengineering/drive": "^0.6.0",
- "fast-equals": "^5.0.1",
+ "fast-equals": "^5.2.2",
"@hcengineering/storage": "^0.6.0"
}
}
diff --git a/server/middleware/package.json b/server/middleware/package.json
index 3b04b8a8f0..748417a56d 100644
--- a/server/middleware/package.json
+++ b/server/middleware/package.json
@@ -44,6 +44,6 @@
"@hcengineering/server-notification": "^0.6.1",
"@hcengineering/query": "^0.6.12",
"@hcengineering/analytics": "^0.6.0",
- "fast-equals": "^5.0.1"
+ "fast-equals": "^5.2.2"
}
}
diff --git a/server/middleware/src/fulltext.ts b/server/middleware/src/fulltext.ts
index eada8a9416..0ec848e3f6 100644
--- a/server/middleware/src/fulltext.ts
+++ b/server/middleware/src/fulltext.ts
@@ -113,6 +113,7 @@ export class FullTextMiddleware extends BaseMiddleware implements Middleware {
try {
await fetch(this.fulltextEndpoint + '/api/v1/warmup', {
method: 'PUT',
+ keepalive: true,
headers: {
'Content-Type': 'application/json'
},
@@ -137,6 +138,7 @@ export class FullTextMiddleware extends BaseMiddleware implements Middleware {
return await (
await fetch(this.fulltextEndpoint + '/api/v1/search', {
method: 'PUT',
+ keepalive: true,
headers: {
'Content-Type': 'application/json'
},
@@ -360,6 +362,7 @@ export class FullTextMiddleware extends BaseMiddleware implements Middleware {
return await (
await fetch(this.fulltextEndpoint + '/api/v1/full-text-search', {
method: 'PUT',
+ keepalive: true,
headers: {
'Content-Type': 'application/json'
},
@@ -390,6 +393,7 @@ export class FullTextMiddleware extends BaseMiddleware implements Middleware {
try {
await fetch(this.fulltextEndpoint + '/api/v1/close', {
method: 'PUT',
+ keepalive: true,
headers: {
'Content-Type': 'application/json'
},
diff --git a/server/postgres/src/__tests__/conversion.spec.ts b/server/postgres/src/__tests__/conversion.spec.ts
new file mode 100644
index 0000000000..62e2f1b07a
--- /dev/null
+++ b/server/postgres/src/__tests__/conversion.spec.ts
@@ -0,0 +1,57 @@
+import { convertArrayParams, decodeArray } from '../utils'
+
+describe('array conversion', () => {
+ it('should handle undefined parameters', () => {
+ expect(convertArrayParams(undefined)).toBeUndefined()
+ })
+
+ it('should convert empty arrays', () => {
+ expect(convertArrayParams([['foo']])).toEqual(['{"foo"}'])
+ expect(convertArrayParams([[]])).toEqual(['{}'])
+ })
+
+ it('should handle string arrays with special characters', () => {
+ expect(convertArrayParams([['hello', 'world"quote"']])).toEqual(['{"hello","world\\"quote\\""}'])
+ })
+
+ it('should handle null values', () => {
+ expect(convertArrayParams([[null, 'value', null]])).toEqual(['{NULL,"value",NULL}'])
+ })
+
+ it('should handle mixed type arrays', () => {
+ expect(convertArrayParams([[123, 'text', null, true]])).toEqual(['{123,"text",NULL,true}'])
+ })
+
+ it('should pass through non-array parameters', () => {
+ expect(convertArrayParams(['text', 123, null])).toEqual(['text', 123, null])
+ })
+})
+
+describe('array decoding', () => {
+ it('should decode NULL to empty array', () => {
+ expect(decodeArray('NULL')).toEqual([])
+ })
+
+ it('should decode empty array', () => {
+ expect(decodeArray('{}')).toEqual([''])
+ })
+
+ it('should decode simple string array', () => {
+ expect(decodeArray('{hello,world}')).toEqual(['hello', 'world'])
+ })
+ it('should decode encoded string array', () => {
+ expect(decodeArray('{"hello","world"}')).toEqual(['hello', 'world'])
+ })
+
+ it('should decode array with quoted strings', () => {
+ expect(decodeArray('{hello,"quoted value"}')).toEqual(['hello', 'quoted value'])
+ })
+
+ it('should decode array with escaped quotes', () => {
+ expect(decodeArray('{"hello \\"world\\""}')).toEqual(['hello "world"'])
+ })
+
+ it('should decode array with multiple escaped characters', () => {
+ expect(decodeArray('{"first \\"quote\\"","second \\"quote\\""}')).toEqual(['first "quote"', 'second "quote"'])
+ })
+})
diff --git a/server/postgres/src/client.ts b/server/postgres/src/client.ts
new file mode 100644
index 0000000000..be9e905f5e
--- /dev/null
+++ b/server/postgres/src/client.ts
@@ -0,0 +1,102 @@
+import { concatLink } from '@hcengineering/core'
+import type postgres from 'postgres'
+import type { ParameterOrJSON } from 'postgres'
+import { convertArrayParams, doFetchTypes, getPrepare } from './utils'
+
+export type DBResult = any[] & { count: number }
+export interface DBClient {
+ execute: (query: string, parameters?: ParameterOrJSON[] | undefined) => Promise
+
+ release: () => void
+
+ reserve: () => Promise
+
+ raw: () => postgres.Sql
+}
+
+export function createDBClient (client: postgres.Sql, release: () => void = () => {}): DBClient {
+ return {
+ execute: (query, parameters) =>
+ client.unsafe(query, doFetchTypes ? parameters : convertArrayParams(parameters), getPrepare()),
+ release,
+ reserve: async () => {
+ const reserved = await client.reserve()
+ return createDBClient(reserved, () => {
+ reserved.release()
+ })
+ },
+ raw: () => client
+ }
+}
+
+class GreenClient implements DBClient {
+ endpoint: string
+ constructor (
+ readonly url: string,
+ private readonly token: string,
+ private readonly connection: postgres.Sql,
+ private readonly decoder: ((data: any) => Promise) | undefined
+ ) {
+ this.endpoint = concatLink(url, '/api/v1/sql')
+ }
+
+ async execute (query: string, parameters?: ParameterOrJSON[] | undefined): Promise {
+ const params = convertArrayParams(parameters)
+ const maxRetries = 3
+ let lastError: any
+
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ try {
+ const response = await fetch(this.endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: 'Bearer ' + this.token,
+ Connection: 'keep-alive'
+ },
+ body: JSON.stringify({
+ query,
+ params
+ })
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to execute sql: ${response.status} ${response.statusText}`)
+ }
+ if (this.decoder !== undefined && response.headers.get('compression') !== undefined) {
+ return JSON.parse(await this.decoder(Buffer.from(await response.arrayBuffer())))
+ }
+
+ return await response.json()
+ } catch (err: any) {
+ lastError = err
+ if (attempt === maxRetries - 1) {
+ console.warn('green failed after retries', query)
+ return await this.connection.unsafe(query, params, getPrepare())
+ }
+ await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100))
+ }
+ }
+
+ throw lastError
+ }
+
+ release (): void {}
+
+ async reserve (): Promise {
+ // We do reserve of connection, if we need it.
+ return createGreenDBClient(this.url, this.token, await this.connection.reserve(), this.decoder)
+ }
+
+ raw (): postgres.Sql {
+ return this.connection
+ }
+}
+
+export function createGreenDBClient (
+ url: string,
+ token: string,
+ connection: postgres.Sql,
+ decoder?: (data: any) => Promise
+): DBClient {
+ return new GreenClient(url, token, connection, decoder)
+}
diff --git a/server/postgres/src/index.ts b/server/postgres/src/index.ts
index ec73ea786f..7371163b27 100644
--- a/server/postgres/src/index.ts
+++ b/server/postgres/src/index.ts
@@ -17,17 +17,10 @@ import type { WorkspaceDestroyAdapter } from '@hcengineering/server-core'
import { domainSchemas } from './schemas'
import { getDBClient, retryTxn } from './utils'
+export { createDBClient } from './client'
export { getDocFieldsByDomains, translateDomain } from './schemas'
export * from './storage'
-export {
- convertDoc,
- createTables,
- getDBClient,
- retryTxn,
- setDBExtraOptions,
- setExtraOptions,
- shutdownPostgres
-} from './utils'
+export { convertDoc, createTables, getDBClient, retryTxn, setDBExtraOptions, shutdownPostgres } from './utils'
export function createPostgreeDestroyAdapter (url: string): WorkspaceDestroyAdapter {
return {
diff --git a/server/postgres/src/schemas.ts b/server/postgres/src/schemas.ts
index b9f85f8563..fc16a10b3f 100644
--- a/server/postgres/src/schemas.ts
+++ b/server/postgres/src/schemas.ts
@@ -306,7 +306,8 @@ export const domainSchemas: Record = {
[translateDomain('notification-user')]: userNotificationSchema,
[translateDomain('github_sync')]: docSyncInfo,
[translateDomain('github_user')]: githubLogin,
- [DOMAIN_RELATION]: relationSchema
+ [DOMAIN_RELATION]: relationSchema,
+ kanban: defaultSchema
}
export function getSchema (domain: string): Schema {
diff --git a/server/postgres/src/storage.ts b/server/postgres/src/storage.ts
index 532fc98689..1dd806e393 100644
--- a/server/postgres/src/storage.ts
+++ b/server/postgres/src/storage.ts
@@ -66,6 +66,7 @@ import {
type TxAdapter
} from '@hcengineering/server-core'
import type postgres from 'postgres'
+import { createDBClient, createGreenDBClient, type DBClient } from './client'
import {
getDocFieldsByDomains,
getSchema,
@@ -76,13 +77,13 @@ import {
} from './schemas'
import { type ValueType } from './types'
import {
+ convertArrayParams,
convertDoc,
createTables,
DBCollectionHelper,
type DBDoc,
- dbExtra,
+ doFetchTypes,
getDBClient,
- getPrepare,
inferType,
isDataField,
isOwner,
@@ -90,17 +91,16 @@ import {
NumericTypes,
parseDoc,
parseDocWithProjection,
- parseUpdate,
- type PostgresClientReference
+ parseUpdate
} from './utils'
async function * createCursorGenerator (
- client: postgres.ReservedSql,
+ client: postgres.Sql,
sql: string,
params: any,
schema: Schema,
bulkSize = 1000
): AsyncGenerator {
- const cursor = client.unsafe(sql, params).cursor(bulkSize)
+ const cursor = client.unsafe(sql, doFetchTypes ? params : convertArrayParams(params)).cursor(bulkSize)
try {
let docs: Doc[] = []
for await (const part of cursor) {
@@ -122,22 +122,19 @@ async function * createCursorGenerator (
class ConnectionInfo {
// It should preserve at least one available connection in pool, other connection should be closed
- available: postgres.ReservedSql[] = []
+ available: DBClient[] = []
released: boolean = false
constructor (
readonly mgrId: string,
readonly connectionId: string,
- protected readonly client: postgres.Sql,
+ protected readonly client: DBClient,
readonly managed: boolean
) {}
- async withReserve (
- reserveOrPool: boolean,
- action: (reservedClient: postgres.ReservedSql | postgres.Sql) => Promise
- ): Promise {
- let reserved: postgres.ReservedSql | undefined
+ async withReserve (reserveOrPool: boolean, action: (reservedClient: DBClient) => Promise): Promise {
+ let reserved: DBClient | undefined
// Check if we have at least one available connection and reserve one more if required.
if (this.available.length === 0) {
@@ -145,7 +142,7 @@ class ConnectionInfo {
reserved = await this.client.reserve()
}
} else {
- reserved = this.available.shift() as postgres.ReservedSql
+ reserved = this.available.shift() as DBClient
}
try {
@@ -193,15 +190,12 @@ class ConnectionInfo {
class ConnectionMgr {
constructor (
- protected readonly client: postgres.Sql,
+ protected readonly client: DBClient,
protected readonly connections: () => Map,
readonly mgrId: string
) {}
- async write (
- id: string | undefined,
- fn: (client: postgres.Sql | postgres.ReservedSql) => Promise
- ): Promise {
+ async write (id: string | undefined, fn: (client: DBClient) => Promise): Promise {
const backoffInterval = 25 // millis
const maxTries = 5
let tries = 0
@@ -215,12 +209,13 @@ class ConnectionMgr {
const retry: boolean | Error = await connection.withReserve(true, async (client) => {
tries++
try {
- await client.unsafe('BEGIN;')
+ await client.execute('BEGIN;')
await fn(client)
- await client.unsafe('COMMIT;')
+ await client.execute('COMMIT;')
return true
} catch (err: any) {
- await client.unsafe('ROLLBACK;')
+ await client.execute('ROLLBACK;')
+ console.error({ message: 'failed to process tx', error: err.message, cause: err })
if (err.code !== '40001' || tries === maxTries) {
return err
@@ -249,7 +244,7 @@ class ConnectionMgr {
}
}
- async retry (id: string | undefined, fn: (client: postgres.Sql | postgres.ReservedSql) => Promise): Promise {
+ async retry (id: string | undefined, fn: (client: DBClient) => Promise): Promise {
const backoffInterval = 25 // millis
const maxTries = 5
let tries = 0
@@ -265,6 +260,7 @@ class ConnectionMgr {
try {
return { result: await fn(client) }
} catch (err: any) {
+ console.error({ message: 'failed to process sql', error: err.message, cause: err })
if (err.code !== '40001' || tries === maxTries) {
return err
} else {
@@ -411,8 +407,11 @@ abstract class PostgresAdapterBase implements DbAdapter {
mgr: ConnectionMgr
constructor (
- protected readonly client: postgres.Sql,
- protected readonly refClient: PostgresClientReference,
+ protected readonly client: DBClient,
+ protected readonly refClient: {
+ url: () => string
+ close: () => void
+ },
protected readonly enrichedWorkspaceId: WorkspaceId,
protected readonly hierarchy: Hierarchy,
protected readonly modelDb: ModelDb,
@@ -456,7 +455,9 @@ abstract class PostgresAdapterBase implements DbAdapter {
}
const finalSql: string = sqlChunks.join(' ')
- const cursor: AsyncGenerator = createCursorGenerator(client, finalSql, vars.getValues(), schema)
+ const rawClient = client.raw()
+
+ const cursor: AsyncGenerator = createCursorGenerator(rawClient, finalSql, vars.getValues(), schema)
return {
next: async (count: number): Promise => {
const result = await cursor.next()
@@ -493,7 +494,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
async rawFindAll(_domain: Domain, query: DocumentQuery, options?: FindOptions): Promise {
const domain = translateDomain(_domain)
const vars = new ValuesVariables()
- const select = `SELECT ${this.getProjection(domain, options?.projection, [], options?.associations)} FROM ${domain}`
+ const select = `SELECT ${this.getProjection(vars, domain, options?.projection, [], options?.associations)} FROM ${domain}`
const sqlChunks: string[] = []
sqlChunks.push(`WHERE ${this.buildRawQuery(vars, domain, query, options)}`)
if (options?.sort !== undefined) {
@@ -503,9 +504,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
sqlChunks.push(`LIMIT ${options.limit}`)
}
const finalSql: string = [select, ...sqlChunks].join(' ')
- const result: DBDoc[] = await this.mgr.retry(undefined, (client) =>
- client.unsafe(finalSql, vars.getValues(), getPrepare())
- )
+ const result: DBDoc[] = await this.mgr.retry(undefined, (client) => client.execute(finalSql, vars.getValues()))
return result.map((p) => parseDocWithProjection(p, domain, options?.projection))
}
@@ -561,12 +560,11 @@ abstract class PostgresAdapterBase implements DbAdapter {
const schemaFields = getSchemaAndFields(domain)
if (isOps) {
await this.mgr.write(undefined, async (client) => {
- const res = await client.unsafe(
+ const res = await client.execute(
`SELECT * FROM ${translateDomain(domain)} WHERE ${translatedQuery} FOR UPDATE`,
- vars.getValues(),
- getPrepare()
+ vars.getValues()
)
- const docs = res.map((p) => parseDoc(p as any, schemaFields.schema))
+ const docs = res.map((p) => parseDoc(p, schemaFields.schema))
for (const doc of docs) {
if (doc === undefined) continue
const prevAttachedTo = (doc as any).attachedTo
@@ -590,13 +588,12 @@ abstract class PostgresAdapterBase implements DbAdapter {
if (Object.keys(remainingData).length > 0) {
updates.push(`data = ${params.add(converted.data, '::json')}`)
}
- await client.unsafe(
+ await client.execute(
`UPDATE ${translateDomain(domain)}
SET ${updates.join(', ')}
WHERE "workspaceId" = ${params.add(this.workspaceId.name, '::uuid')}
AND _id = ${params.add(doc._id, '::text')}`,
- params.getValues(),
- getPrepare()
+ params.getValues()
)
}
})
@@ -631,10 +628,9 @@ abstract class PostgresAdapterBase implements DbAdapter {
updates.push(`data = ${from}`)
}
await this.mgr.retry(undefined, async (client) => {
- await client.unsafe(
+ await client.execute(
`UPDATE ${translateDomain(domain)} SET ${updates.join(', ')} WHERE ${translatedQuery};`,
- vars.getValues(),
- getPrepare()
+ vars.getValues()
)
})
}
@@ -643,11 +639,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
const vars = new ValuesVariables()
const translatedQuery = this.buildRawQuery(vars, domain, query)
await this.mgr.retry(undefined, async (client) => {
- await client.unsafe(
- `DELETE FROM ${translateDomain(domain)} WHERE ${translatedQuery}`,
- vars.getValues(),
- getPrepare()
- )
+ await client.execute(`DELETE FROM ${translateDomain(domain)} WHERE ${translatedQuery}`, vars.getValues())
})
}
@@ -657,7 +649,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
query: DocumentQuery,
options?: ServerFindOptions
): Promise> {
- let fquery = ''
+ let fquery: any = ''
const vars = new ValuesVariables()
return ctx.with(
'findAll',
@@ -667,27 +659,11 @@ abstract class PostgresAdapterBase implements DbAdapter {
const domain = translateDomain(options?.domain ?? this.hierarchy.getDomain(_class))
const sqlChunks: string[] = []
- const joins = this.buildJoin(_class, options?.lookup)
- if (options?.domainLookup !== undefined) {
- const baseDomain = translateDomain(this.hierarchy.getDomain(_class))
-
- const domain = translateDomain(options.domainLookup.domain)
- const key = options.domainLookup.field
- const as = `lookup_${domain}_${key}`
- joins.push({
- isReverse: false,
- table: domain,
- path: options.domainLookup.field,
- toAlias: as,
- toField: '_id',
- fromField: key,
- fromAlias: baseDomain,
- toClass: undefined
- })
- }
+ const joins = this.buildJoins(_class, options)
// Add workspace name as $1
- const select = `SELECT ${this.getProjection(domain, options?.projection, joins, options?.associations)} FROM ${domain}`
+ const select = `SELECT ${this.getProjection(vars, domain, options?.projection, joins, options?.associations)} FROM ${domain}`
+
const showArchived = options?.showArchived ?? (query._id !== undefined && typeof query._id === 'string')
const secJoin = this.addSecurity(vars, query, showArchived, domain, ctx.contextData)
if (secJoin !== undefined) {
@@ -698,8 +674,6 @@ abstract class PostgresAdapterBase implements DbAdapter {
}
sqlChunks.push(`WHERE ${this.buildQuery(vars, _class, domain, query, joins, options)}`)
- const totalSqlChunks = [...sqlChunks]
-
if (options?.sort !== undefined) {
sqlChunks.push(this.buildOrder(_class, domain, options.sort, joins))
}
@@ -710,9 +684,22 @@ abstract class PostgresAdapterBase implements DbAdapter {
return (await this.mgr.retry(ctx.id, async (connection) => {
let total = options?.total === true ? 0 : -1
if (options?.total === true) {
+ const pvars = new ValuesVariables()
+ const showArchived = options?.showArchived ?? (query._id !== undefined && typeof query._id === 'string')
+ const secJoin = this.addSecurity(pvars, query, showArchived, domain, ctx.contextData)
+ const totalChunks: string[] = []
+ if (secJoin !== undefined) {
+ totalChunks.push(secJoin)
+ }
+ const joins = this.buildJoin(_class, options?.lookup)
+ if (joins.length > 0) {
+ totalChunks.push(this.buildJoinString(pvars, joins))
+ }
+ totalChunks.push(`WHERE ${this.buildQuery(pvars, _class, domain, query, joins, options)}`)
+
const totalReq = `SELECT COUNT(${domain}._id) as count FROM ${domain}`
- const totalSql = [totalReq, ...totalSqlChunks].join(' ')
- const totalResult = await connection.unsafe(totalSql, vars.getValues(), getPrepare())
+ const totalSql = [totalReq, ...totalChunks].join(' ')
+ const totalResult = await connection.execute(totalSql, pvars.getValues())
const parsed = Number.parseInt(totalResult[0].count)
total = Number.isNaN(parsed) ? 0 : parsed
}
@@ -720,16 +707,14 @@ abstract class PostgresAdapterBase implements DbAdapter {
const finalSql: string = [select, ...sqlChunks].join(' ')
fquery = finalSql
- const result = dbExtra?.useCF
- ? await connection.unsafe(vars.injectVars(finalSql), undefined, { prepare: false })
- : await connection.unsafe(finalSql, vars.getValues(), getPrepare())
+ const result = await connection.execute(finalSql, vars.getValues())
if (
options?.lookup === undefined &&
options?.domainLookup === undefined &&
options?.associations === undefined
) {
return toFindResult(
- result.map((p) => parseDocWithProjection(p as any, domain, options?.projection)),
+ result.map((p) => parseDocWithProjection(p, domain, options?.projection)),
total
)
} else {
@@ -738,14 +723,44 @@ abstract class PostgresAdapterBase implements DbAdapter {
}
})) as FindResult
} catch (err) {
- ctx.error('Error in findAll', { err, sql: fquery, sqlFull: vars.injectVars(fquery) })
+ const sqlFull = vars.injectVars(fquery)
+ ctx.error('Error in findAll', { err, sql: fquery, sqlFull })
throw err
}
},
- () => ({ query, psql: fquery, sql: vars.injectVars(fquery) })
+ () => ({
+ query,
+ psql: fquery
+ .replace(/\s+/g, ' ')
+ .replace(/(FROM|WHERE|ORDER BY|GROUP BY|LIMIT|OFFSET|LEFT JOIN|RIGHT JOIN|INNER JOIN|JOIN)/gi, '\n$1')
+ .split('\n'),
+ sql: vars.injectVars(fquery)
+ })
)
}
+ private buildJoins(_class: Ref>, options: ServerFindOptions | undefined): JoinProps[] {
+ const joins = this.buildJoin(_class, options?.lookup)
+ if (options?.domainLookup !== undefined) {
+ const baseDomain = translateDomain(this.hierarchy.getDomain(_class))
+
+ const domain = translateDomain(options.domainLookup.domain)
+ const key = options.domainLookup.field
+ const as = `lookup_${domain}_${key}`
+ joins.push({
+ isReverse: false,
+ table: domain,
+ path: options.domainLookup.field,
+ toAlias: as,
+ toField: '_id',
+ fromField: key,
+ fromAlias: baseDomain,
+ toClass: undefined
+ })
+ }
+ return joins
+ }
+
addSecurity(
vars: ValuesVariables,
query: DocumentQuery,
@@ -1375,13 +1390,13 @@ abstract class PostgresAdapterBase implements DbAdapter {
: `${tkey} @> '${typeof value === 'string' ? '"' + value + '"' : value}'`
}
- private getReverseProjection (join: JoinProps): string[] {
+ private getReverseProjection (vars: ValuesVariables, join: JoinProps): string[] {
let classsesQuery = ''
if (join.classes !== undefined) {
if (join.classes.length === 1) {
- classsesQuery = ` AND ${join.toAlias}._class = '${join.classes[0]}'`
+ classsesQuery = ` AND ${join.toAlias}._class = ${vars.add(join.classes[0])}`
} else {
- classsesQuery = ` AND ${join.toAlias}._class IN (${join.classes.map((c) => `'${c}'`).join(', ')})`
+ classsesQuery = ` AND ${join.toAlias}._class = ANY (${vars.add(join.classes, '::text[]')})`
}
}
return [
@@ -1389,10 +1404,10 @@ abstract class PostgresAdapterBase implements DbAdapter {
]
}
- private getProjectionsAliases (join: JoinProps): string[] {
+ private getProjectionsAliases (vars: ValuesVariables, join: JoinProps): string[] {
if (join.table === DOMAIN_MODEL) return []
if (join.path === '') return []
- if (join.isReverse) return this.getReverseProjection(join)
+ if (join.isReverse) return this.getReverseProjection(vars, join)
const fields = getDocFieldsByDomains(join.table)
const res: string[] = []
for (const key of [...fields, 'data']) {
@@ -1401,7 +1416,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
return res
}
- getAssociationsProjections (baseDomain: string, associations: AssociationQuery[]): string[] {
+ getAssociationsProjections (vars: ValuesVariables, baseDomain: string, associations: AssociationQuery[]): string[] {
const res: string[] = []
for (const association of associations) {
const _id = association[0]
@@ -1413,8 +1428,15 @@ abstract class PostgresAdapterBase implements DbAdapter {
const _class = isReverse ? assoc.classA : assoc.classB
const keyA = isReverse ? 'docB' : 'docA'
const keyB = isReverse ? 'docA' : 'docB'
+ const wsId = vars.add(this.workspaceId.name, '::uuid')
res.push(
- `(SELECT jsonb_agg(assoc.*) FROM ${translateDomain(this.hierarchy.getDomain(_class))} AS assoc JOIN ${translateDomain(DOMAIN_RELATION)} as relation ON relation."${keyB}" = assoc."_id" AND relation."workspaceId" = '${this.workspaceId.name}' WHERE relation."${keyA}" = ${translateDomain(baseDomain)}."_id" AND assoc."workspaceId" = '${this.workspaceId.name}') AS assoc_${association[0]}`
+ `(SELECT jsonb_agg(assoc.*)
+ FROM ${translateDomain(this.hierarchy.getDomain(_class))} AS assoc
+ JOIN ${translateDomain(DOMAIN_RELATION)} as relation
+ ON relation."${keyB}" = assoc."_id"
+ AND relation."workspaceId" = ${wsId}
+ WHERE relation."${keyA}" = ${translateDomain(baseDomain)}."_id"
+ AND assoc."workspaceId" = ${wsId}) AS assoc_${association[0]}`
)
}
return res
@@ -1425,6 +1447,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
}
private getProjection(
+ vars: ValuesVariables,
baseDomain: string,
projection: Projection | undefined,
joins: JoinProps[],
@@ -1454,10 +1477,10 @@ abstract class PostgresAdapterBase implements DbAdapter {
}
}
for (const join of joins) {
- res.push(...this.getProjectionsAliases(join))
+ res.push(...this.getProjectionsAliases(vars, join))
}
if (associations !== undefined) {
- res.push(...this.getAssociationsProjections(baseDomain, associations))
+ res.push(...this.getAssociationsProjections(vars, baseDomain, associations))
}
return res.join(', ')
}
@@ -1497,7 +1520,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
const ctx = _ctx.newChild('find', { domain })
let initialized: boolean = false
- let client: postgres.ReservedSql
+ let client: DBClient
const tdomain = translateDomain(domain)
const schema = getSchema(domain)
@@ -1505,9 +1528,13 @@ abstract class PostgresAdapterBase implements DbAdapter {
const workspaceId = this.workspaceId
function createBulk (projection: string, limit = 50000): AsyncGenerator {
- const sql = `SELECT ${projection} FROM ${tdomain} WHERE "workspaceId" = '${workspaceId.name}'`
+ const sql = `
+ SELECT ${projection}
+ FROM ${tdomain}
+ WHERE "workspaceId" = '${workspaceId.name}'
+ `
- return createCursorGenerator(client, sql, undefined, schema, limit)
+ return createCursorGenerator(client.raw(), sql, undefined, schema, limit)
}
let bulk: AsyncGenerator
@@ -1549,13 +1576,14 @@ abstract class PostgresAdapterBase implements DbAdapter {
}
return await this.mgr.retry('', async (client) => {
- const res = await client.unsafe(
- `SELECT * FROM ${translateDomain(domain)}
- WHERE "workspaceId" = $1::uuid AND _id = ANY($2::text[])`,
- [this.workspaceId.name, docs],
- getPrepare()
+ const res = await client.execute(
+ `SELECT *
+ FROM ${translateDomain(domain)}
+ WHERE "workspaceId" = $1::uuid
+ AND _id = ANY($2::text[])`,
+ [this.workspaceId.name, docs]
)
- return res.map((p) => parseDocWithProjection(p as any, domain))
+ return res.map((p) => parseDocWithProjection(p, domain))
})
})
}
@@ -1596,7 +1624,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
const query = `INSERT INTO ${tdomain} ("workspaceId", ${insertStr}) VALUES ${vals} ${
handleConflicts ? `ON CONFLICT ("workspaceId", _id) DO UPDATE SET ${onConflictStr}` : ''
};`
- await this.mgr.retry(ctx.id, async (client) => await client.unsafe(query, values.getValues(), getPrepare()))
+ await this.mgr.retry(ctx.id, async (client) => await client.execute(query, values.getValues()))
}
} catch (err: any) {
ctx.error('failed to upload', { err })
@@ -1614,7 +1642,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
const part = docs.slice(i, i + batchSize)
await ctx.with('clean', {}, () => {
const params = [this.workspaceId.name, part]
- return this.mgr.retry(ctx.id, (client) => client.unsafe(query, params, getPrepare()))
+ return this.mgr.retry(ctx.id, (client) => client.execute(query, params))
})
}
}
@@ -1630,15 +1658,15 @@ abstract class PostgresAdapterBase implements DbAdapter {
try {
const vars = new ValuesVariables()
const sqlChunks: string[] = [
- `SELECT ${key} as ${field}, Count(*) AS count`,
+ `SELECT ${key} as _${field}, Count(*) AS count`,
`FROM ${translateDomain(domain)}`,
`WHERE ${this.buildRawQuery(vars, domain, query ?? {})}`,
- `GROUP BY ${key}`
+ `GROUP BY _${field}`
]
const finalSql = sqlChunks.join(' ')
return await this.mgr.retry(ctx.id, async (connection) => {
- const result = await connection.unsafe(finalSql, vars.getValues(), getPrepare())
- return new Map(result.map((r) => [r[field.toLowerCase()], r.count]))
+ const result = await connection.execute(finalSql, vars.getValues())
+ return new Map(result.map((r) => [r[`_${field.toLowerCase()}`], r.count]))
})
} catch (err) {
ctx.error('Error while grouping by', { domain, field })
@@ -1686,13 +1714,13 @@ class PostgresAdapter extends PostgresAdapterBase {
): Promise {
this.connections = contextVars.cntInfoPG ?? new Map()
contextVars.cntInfoPG = this.connections
- let resultDomains = domains ?? this.hierarchy.domains()
+ let resultDomains = [...(domains ?? this.hierarchy.domains()), 'kanban']
if (excludeDomains !== undefined) {
resultDomains = resultDomains.filter((it) => !excludeDomains.includes(it))
}
const url = this.refClient.url()
await initRateLimit.exec(async () => {
- await createTables(ctx, this.client, url, resultDomains)
+ await createTables(ctx, this.client.raw(), url, resultDomains)
})
this._helper.domains = new Set(resultDomains as Domain[])
}
@@ -1740,12 +1768,11 @@ class PostgresAdapter extends PostgresAdapterBase {
updates.push(`"${key}" = ${params.add(val, `::${schemaFields.schema[key].type}`)}`)
}
updates.push(`data = ${params.add(converted.data, '::json')}`)
- await client.unsafe(
+ await client.execute(
`UPDATE ${translateDomain(domain)}
SET ${updates.join(', ')}
WHERE "workspaceId" = ${wsId} AND _id = ${oId}`,
- params.getValues(),
- getPrepare()
+ params.getValues()
)
})
})
@@ -1852,13 +1879,12 @@ class PostgresAdapter extends PostgresAdapterBase {
if (Object.keys(remainingData).length > 0) {
updates.push(`data = ${params.add(converted.data, '::json')}`)
}
- await client.unsafe(
+ await client.execute(
`UPDATE ${tdomain}
SET ${updates.join(', ')}
WHERE "workspaceId" = ${wsId}
AND _id = ${oId}`,
- params.getValues(),
- getPrepare()
+ params.getValues()
)
})
if (tx.retrieve === true && doc !== undefined) {
@@ -1946,9 +1972,7 @@ class PostgresAdapter extends PostgresAdapterBase {
FROM (values ${indexes.join(',')}) AS update_data(__id, ${part[0].fields.map((it) => `"_${it}"`).join(',')})
WHERE "workspaceId" = $1::uuid AND "_id" = update_data.__id`
- await this.mgr.retry(ctx.id, (client) =>
- ctx.with('bulk-update', {}, () => client.unsafe(op, data, getPrepare()))
- )
+ await this.mgr.retry(ctx.id, (client) => ctx.with('bulk-update', {}, () => client.execute(op, data)))
}
}
const toRetrieve = operations.filter((it) => it.retrieve)
@@ -1969,21 +1993,20 @@ class PostgresAdapter extends PostgresAdapterBase {
private findDoc (
ctx: MeasureContext,
- client: postgres.ReservedSql | postgres.Sql,
+ client: DBClient,
_class: Ref>,
_id: Ref,
forUpdate: boolean = false
): Promise {
const domain = this.hierarchy.getDomain(_class)
return ctx.with('find-doc', { _class }, async () => {
- const res = await client.unsafe(
+ const res = await client.execute(
`SELECT * FROM "${translateDomain(domain)}" WHERE "workspaceId" = $1::uuid AND _id = $2::text ${
forUpdate ? ' FOR UPDATE' : ''
}`,
- [this.workspaceId.name, _id],
- getPrepare()
+ [this.workspaceId.name, _id]
)
- const dbDoc = res[0] as any
+ const dbDoc = res[0]
return dbDoc !== undefined ? parseDoc(dbDoc, getSchema(domain)) : undefined
})
}
@@ -2002,7 +2025,7 @@ class PostgresTxAdapter extends PostgresAdapterBase implements TxAdapter {
const resultDomains = domains ?? [DOMAIN_TX, DOMAIN_MODEL_TX]
await initRateLimit.exec(async () => {
const url = this.refClient.url()
- await createTables(ctx, this.client, url, resultDomains)
+ await createTables(ctx, this.client.raw(), url, resultDomains)
})
this._helper.domains = new Set(resultDomains as Domain[])
}
@@ -2035,11 +2058,13 @@ class PostgresTxAdapter extends PostgresAdapterBase implements TxAdapter {
async getModel (ctx: MeasureContext): Promise {
const res: DBDoc[] = await this.mgr.retry(undefined, (client) => {
- return client.unsafe(
- `SELECT * FROM "${translateDomain(DOMAIN_MODEL_TX)}" WHERE "workspaceId" = '${this.workspaceId.name}'::uuid ORDER BY _id::text ASC, "modifiedOn"::bigint ASC`,
- undefined,
- getPrepare()
- )
+ const query = `
+ SELECT *
+ FROM "${translateDomain(DOMAIN_MODEL_TX)}"
+ WHERE "workspaceId" = $1::uuid
+ ORDER BY _id::text ASC, "modifiedOn"::bigint ASC
+ `
+ return client.execute(query, [this.workspaceId.name])
})
const model = res.map((p) => parseDoc(p, getSchema(DOMAIN_MODEL_TX)))
@@ -2064,9 +2089,39 @@ export async function createPostgresAdapter (
): Promise {
const client = getDBClient(contextVars, url)
const connection = await client.getClient()
- return new PostgresAdapter(connection, client, workspaceId, hierarchy, modelDb, 'default-' + workspaceId.name)
+ return new PostgresAdapter(
+ greenURL !== undefined ? toGreenClient(greenURL, connection) : createDBClient(connection),
+ client,
+ workspaceId,
+ hierarchy,
+ modelDb,
+ 'default-' + workspaceId.name
+ )
}
+function toGreenClient (url: string, connection: postgres.Sql): DBClient {
+ const originalUrl = new URL(url)
+
+ // Extract components with default values if needed
+ const token = originalUrl.searchParams.get('token') ?? 'secret'
+ const compression = originalUrl.searchParams.get('compression') ?? ''
+
+ // Manually build the new URL components
+ const newHost = originalUrl.host
+ const newPathname = originalUrl.pathname
+
+ // Construct new search parameters without previous ones
+ const newSearchParams = new URLSearchParams()
+ // Add any search parameters you need, like `token` and `compression` if desired
+ if (compression !== '') {
+ newSearchParams.set('compression', compression)
+ }
+
+ console.warn('USE GREEN', newHost, newPathname, newSearchParams.toString())
+ // Construct the new URL
+ const newUrl = `${originalUrl.protocol}//${newHost}${newPathname}${newSearchParams.size > 0 ? '?' + newSearchParams.toString() : ''}`
+ return createGreenDBClient(newUrl, token, connection, greenDecoders.get(compression))
+}
/**
* @public
*/
@@ -2080,7 +2135,25 @@ export async function createPostgresTxAdapter (
): Promise {
const client = getDBClient(contextVars, url)
const connection = await client.getClient()
- return new PostgresTxAdapter(connection, client, workspaceId, hierarchy, modelDb, 'tx' + workspaceId.name)
+
+ return new PostgresTxAdapter(
+ greenURL !== undefined ? toGreenClient(greenURL, connection) : createDBClient(connection),
+ client,
+ workspaceId,
+ hierarchy,
+ modelDb,
+ 'tx' + workspaceId.name
+ )
+}
+
+const greenDecoders = new Map Promise>()
+let greenURL: string | undefined
+
+export function registerGreenDecoder (name: string, decoder: (data: any) => Promise): void {
+ greenDecoders.set(name, decoder)
+}
+export function registerGreenUrl (url?: string): void {
+ greenURL = url
}
function isPersonAccount (tx: Tx): boolean {
diff --git a/server/postgres/src/utils.ts b/server/postgres/src/utils.ts
index 68410b9285..fb8806cc0f 100644
--- a/server/postgres/src/utils.ts
+++ b/server/postgres/src/utils.ts
@@ -30,7 +30,8 @@ import core, {
} from '@hcengineering/core'
import { PlatformError, unknownStatus } from '@hcengineering/platform'
import { type DomainHelperOperations } from '@hcengineering/server-core'
-import postgres, { type Options } from 'postgres'
+import postgres, { type Options, type ParameterOrJSON } from 'postgres'
+import type { DBClient } from './client'
import {
addSchema,
type DataType,
@@ -86,7 +87,10 @@ export async function createTables (
SELECT table_name
FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
- AND table_name NOT LIKE 'pg_%'`)
+ AND table_name NOT LIKE 'pg_%'
+ AND table_name NOT LIKE 'cluster_%'
+ AND table_name NOT LIKE 'kv_%'
+ AND table_name NOT LIKE 'node_%'`)
)
).map((it) => it.table_name)
)
@@ -285,17 +289,7 @@ export function getPrepare (): { prepare: boolean } {
return { prepare: dbExtraOptions.prepare ?? false }
}
-export interface DBExtraOptions {
- useCF: boolean
-}
-
-export let dbExtra: DBExtraOptions = {
- useCF: false
-}
-
-export function setExtraOptions (options: DBExtraOptions): void {
- dbExtra = options
-}
+export const doFetchTypes = true
/**
* Initialize a workspace connection to DB
@@ -324,7 +318,6 @@ export function getDBClient (
connect_timeout: 10,
idle_timeout: 30,
max_lifetime: 300,
- fetch_types: true,
transform: {
undefined: null
},
@@ -333,7 +326,8 @@ export function getDBClient (
onnotice (notice) {},
onparameter (key, value) {},
...dbExtraOptions,
- ...extraOptions
+ ...extraOptions,
+ fetch_types: doFetchTypes
})
existing = new PostgresClientReferenceImpl(connectionString, sql, () => {
@@ -484,7 +478,7 @@ export class DBCollectionHelper implements DomainHelperOperations {
protected readonly workspaceId: WorkspaceId
constructor (
- protected readonly client: postgres.Sql,
+ protected readonly client: DBClient,
protected readonly enrichedWorkspaceId: WorkspaceId
) {
this.workspaceId = {
@@ -518,6 +512,50 @@ export class DBCollectionHelper implements DomainHelperOperations {
}
}
+export function decodeArray (value: string): string[] {
+ if (value === 'NULL') return []
+ // Remove first and last character (the array brackets)
+ const inner = value.substring(1, value.length - 1)
+ const items = inner.split(',')
+ return items.map((item) => {
+ // Remove quotes at start/end if they exist
+ let result = item
+ if (result.startsWith('"')) {
+ result = result.substring(1)
+ }
+ if (result.endsWith('"')) {
+ result = result.substring(0, result.length - 1)
+ }
+ // Replace escaped quotes with regular quotes
+ let final = ''
+ for (let i = 0; i < result.length; i++) {
+ if (result[i] === '\\' && result[i + 1] === '"') {
+ final += '"'
+ i++ // Skip next char
+ } else {
+ final += result[i]
+ }
+ }
+ return final
+ })
+}
+
+export function convertArrayParams (parameters?: ParameterOrJSON[]): any[] | undefined {
+ if (parameters === undefined) return undefined
+ return parameters.map((param) => {
+ if (Array.isArray(param)) {
+ if (param.length === 0) return '{}'
+ const sanitized = param.map((item) => {
+ if (item === null) return 'NULL'
+ if (typeof item === 'string') return `"${item.replace(/"/g, '\\"')}"`
+ return String(item)
+ })
+ return `{${sanitized.join(',')}}`
+ }
+ return param
+ })
+}
+
export function parseDocWithProjection (
doc: DBDoc,
domain: string,
@@ -535,6 +573,8 @@ export function parseDocWithProjection (
}
} else if (schema[key] !== undefined && schema[key].type === 'bigint') {
;(rest as any)[key] = Number.parseInt((rest as any)[key])
+ } else if (schema[key] !== undefined && schema[key].type === 'text[]' && typeof (rest as any)[key] === 'string') {
+ ;(rest as any)[key] = decodeArray((rest as any)[key])
}
}
if (projection !== undefined) {
@@ -565,6 +605,8 @@ export function parseDoc (doc: DBDoc, schema: Schema): T {
}
} else if (schema[key] !== undefined && schema[key].type === 'bigint') {
;(rest as any)[key] = Number.parseInt((rest as any)[key])
+ } else if (schema[key] !== undefined && schema[key].type === 'text[]' && typeof (rest as any)[key] === 'string') {
+ ;(rest as any)[key] = decodeArray((rest as any)[key])
}
}
const res = {
diff --git a/server/s3/package.json b/server/s3/package.json
index ffd4311d4d..743ba092f0 100644
--- a/server/s3/package.json
+++ b/server/s3/package.json
@@ -38,9 +38,9 @@
"@hcengineering/platform": "^0.6.11",
"@hcengineering/server-core": "^0.6.1",
"@hcengineering/storage": "^0.6.0",
- "@aws-sdk/client-s3": "^3.575.0",
- "@aws-sdk/s3-request-presigner": "^3.582.0",
- "@aws-sdk/lib-storage": "^3.583.0",
- "@smithy/node-http-handler": "^3.0.0"
+ "@aws-sdk/client-s3": "^3.738.0",
+ "@aws-sdk/s3-request-presigner": "^3.738.0",
+ "@aws-sdk/lib-storage": "^3.738.0",
+ "@smithy/node-http-handler": "^4.0.2"
}
}
diff --git a/server/server-pipeline/src/pipeline.ts b/server/server-pipeline/src/pipeline.ts
index e0ff4b75f1..9ac943a629 100644
--- a/server/server-pipeline/src/pipeline.ts
+++ b/server/server-pipeline/src/pipeline.ts
@@ -248,7 +248,7 @@ export function registerDestroyFactory (
function matchTxAdapterFactory (dbUrl: string): DbAdapterFactory {
for (const [k, v] of Object.entries(txAdapterFactories)) {
- if (dbUrl.startsWith(k)) {
+ if (k !== '' && dbUrl.startsWith(k)) {
return v
}
}
@@ -257,7 +257,7 @@ function matchTxAdapterFactory (dbUrl: string): DbAdapterFactory {
function matchAdapterFactory (dbUrl: string): DbAdapterFactory {
for (const [k, v] of Object.entries(adapterFactories)) {
- if (dbUrl.startsWith(k)) {
+ if (k !== '' && dbUrl.startsWith(k)) {
return v
}
}
diff --git a/server/server/src/sessionManager.ts b/server/server/src/sessionManager.ts
index bc09436ef3..832cf84761 100644
--- a/server/server/src/sessionManager.ts
+++ b/server/server/src/sessionManager.ts
@@ -296,6 +296,7 @@ class TSessionManager implements SessionManager {
const userInfo = await (
await fetch(this.accountsUrl, {
method: 'POST',
+ keepalive: true,
headers: {
Authorization: 'Bearer ' + token,
'Content-Type': 'application/json'
diff --git a/server/server/src/utils.ts b/server/server/src/utils.ts
index 57df91fa66..3725c6699b 100644
--- a/server/server/src/utils.ts
+++ b/server/server/src/utils.ts
@@ -43,12 +43,16 @@ export function doSessionOp (
if (data.session instanceof Promise) {
// We need to copy since we will out of protected buffer area
const msgCopy = Buffer.copyBytesFrom(msg)
- void data.session.then((_session) => {
- data.session = _session
- if ('session' in _session) {
- op(_session, msgCopy)
- }
- })
+ void data.session
+ .then((_session) => {
+ data.session = _session
+ if ('session' in _session) {
+ op(_session, msgCopy)
+ }
+ })
+ .catch((err) => {
+ console.error({ message: 'Failed to process session operation', err })
+ })
} else {
if (data.session !== undefined && 'session' in data.session) {
op(data.session, msg)
diff --git a/server/tool/package.json b/server/tool/package.json
index d76f238055..eeea0cbfe8 100644
--- a/server/tool/package.json
+++ b/server/tool/package.json
@@ -58,7 +58,7 @@
"@hcengineering/mongo": "^0.6.1",
"@hcengineering/collaboration": "^0.6.0",
"@hcengineering/minio": "^0.6.0",
- "fast-equals": "^5.0.1",
+ "fast-equals": "^5.2.2",
"@hcengineering/text": "^0.6.5",
"js-yaml": "^4.1.0"
}
diff --git a/server/ws/src/server_http.ts b/server/ws/src/server_http.ts
index 5ba1be3b9a..c111a74ffd 100644
--- a/server/ws/src/server_http.ts
+++ b/server/ws/src/server_http.ts
@@ -218,6 +218,13 @@ export function startHttpServer (
const name = req.query.name as string
const contentType = req.query.contentType as string
const size = parseInt((req.query.size as string) ?? '-1')
+ const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB limit
+
+ if (size > MAX_FILE_SIZE) {
+ res.writeHead(413, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ error: 'File too large' }))
+ return
+ }
if (Number.isNaN(size)) {
ctx.error('/api/v1/blob put error', {
message: 'invalid NaN file size',
@@ -304,14 +311,23 @@ export function startHttpServer (
})
.on('end', () => {
// on end of data, perform necessary action
- const data = JSON.parse(Buffer.concat(body as any).toString())
- if (Array.isArray(data)) {
- sessions.broadcastAll(ws, data as Tx[])
- } else {
- sessions.broadcastAll(ws, [data as unknown as Tx])
+ try {
+ const data = JSON.parse(Buffer.concat(body as any).toString())
+ if (Array.isArray(data)) {
+ sessions.broadcastAll(ws, data as Tx[])
+ } else {
+ sessions.broadcastAll(ws, [data as unknown as Tx])
+ }
+ res.end()
+ } catch (err: any) {
+ ctx.error('JSON parse error', { err })
+ res.writeHead(400, {})
+ res.end()
}
- res.end()
})
+ } else {
+ res.writeHead(404, {})
+ res.end()
}
} catch (err: any) {
Analytics.handleError(err)
@@ -441,6 +457,10 @@ export function startHttpServer (
webSocketData,
(s) => {
ctx.error('error', { err, user: s.session.getUser() })
+ if (!(s.session.workspaceClosed ?? false)) {
+ // remove session after 1seconds, give a time to reconnect.
+ void sessions.close(ctx, cs, toWorkspaceString(token.workspace))
+ }
},
Buffer.from('')
)
diff --git a/services/ai-bot/love-agent/src/deepgram/stt.ts b/services/ai-bot/love-agent/src/deepgram/stt.ts
index dfac2523bc..806d29e7bb 100644
--- a/services/ai-bot/love-agent/src/deepgram/stt.ts
+++ b/services/ai-bot/love-agent/src/deepgram/stt.ts
@@ -207,6 +207,7 @@ export class STT implements Stt {
try {
await fetch(`${config.PlatformUrl}/love/transcript`, {
method: 'POST',
+ keepalive: true,
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + config.PlatformToken
diff --git a/services/ai-bot/pod-ai-bot/package.json b/services/ai-bot/pod-ai-bot/package.json
index 3c4bcaff74..0ba52eed51 100644
--- a/services/ai-bot/pod-ai-bot/package.json
+++ b/services/ai-bot/pod-ai-bot/package.json
@@ -82,7 +82,7 @@
"cors": "^2.8.5",
"dotenv": "~16.0.0",
"express": "^4.21.2",
- "fast-equals": "^5.0.1",
+ "fast-equals": "^5.2.2",
"form-data": "^4.0.0",
"js-tiktoken": "^1.0.14",
"uuid": "^8.3.2",
diff --git a/services/analytics-collector/pod-analytics-collector/src/account.ts b/services/analytics-collector/pod-analytics-collector/src/account.ts
index 81aa0370b3..52877bd892 100644
--- a/services/analytics-collector/pod-analytics-collector/src/account.ts
+++ b/services/analytics-collector/pod-analytics-collector/src/account.ts
@@ -22,6 +22,7 @@ export async function getWorkspaceInfo (token: string): Promise {
@@ -42,6 +44,7 @@ const extractToken = (header: IncomingHttpHeaders): any => {
export const startServer = async (): Promise => {
const app = express()
+ setMetadata(serverToken.metadata.Secret, process.env.SECRET)
const ctx = initStatisticsContext('rekoni', {})
class MyStream {
diff --git a/services/ses/pod-ses/package.json b/services/ses/pod-ses/package.json
index 8693fe15ee..997799ede9 100644
--- a/services/ses/pod-ses/package.json
+++ b/services/ses/pod-ses/package.json
@@ -60,7 +60,7 @@
"@hcengineering/notification": "^0.6.23",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/server-token": "^0.6.11",
- "aws-sdk": "^2.1423.0",
+ "@aws-sdk/client-ses": "^3.738.0",
"cors": "^2.8.5",
"dotenv": "~16.0.0",
"express": "^4.21.2",
diff --git a/services/ses/pod-ses/src/ses.ts b/services/ses/pod-ses/src/ses.ts
index 624d9ddc1b..496990a0fb 100644
--- a/services/ses/pod-ses/src/ses.ts
+++ b/services/ses/pod-ses/src/ses.ts
@@ -12,54 +12,65 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
-
-import AWS from 'aws-sdk'
+import {
+ SESClient,
+ SendEmailCommand,
+ type Body,
+ type Destination,
+ type SendEmailCommandInput,
+ type Message as SesMessage
+} from '@aws-sdk/client-ses'
import config from './config'
import { Message, Receivers } from './types'
export class SES {
- private readonly client: AWS.SES
+ private readonly client: SESClient
constructor () {
- AWS.config.credentials = {
- accessKeyId: config.AccessKey,
- secretAccessKey: config.SecretKey
- }
- this.client = new AWS.SES({ region: config.Region })
+ this.client = new SESClient({
+ region: config.Region,
+ credentials: {
+ accessKeyId: config.AccessKey,
+ secretAccessKey: config.SecretKey
+ }
+ })
}
async sendMessage (message: Message, receivers: Receivers, from?: string): Promise {
console.log('send email to', receivers.to)
- const params: AWS.SES.Types.SendEmailRequest = {
- Source: config.Source,
- Destination: {
- ToAddresses: receivers.to
- },
- Message: {
- Subject: {
- Data: message.subject
- },
- Body: {
- Text: {
- Data: message.text
- }
- }
+ const destionation: Destination = {
+ ToAddresses: receivers.to
+ }
+ const body: Body = {
+ Text: {
+ Data: message.text
}
}
+ const sesMessage: SesMessage = {
+ Subject: {
+ Data: message.subject
+ },
+ Body: body
+ }
+ const params: SendEmailCommandInput = {
+ Source: config.Source,
+ Destination: destionation,
+ Message: sesMessage
+ }
// if (from !== undefined) {
// params.Source = `${from} <` + config.Source + '>'
// }
if (receivers.cc !== undefined) {
- params.Destination.CcAddresses = receivers.cc
+ destionation.CcAddresses = receivers.cc
}
if (receivers.bcc !== undefined) {
- params.Destination.BccAddresses = receivers.bcc
+ destionation.BccAddresses = receivers.bcc
}
if (message.html !== undefined) {
- params.Message.Body.Html = {
+ body.Html = {
Data: message.html
}
}
- await this.client.sendEmail(params).promise()
+ await this.client.send(new SendEmailCommand(params))
}
}
diff --git a/templates/cloud/package.json b/templates/cloud/package.json
index dae1b1b2ad..871dca97b9 100644
--- a/templates/cloud/package.json
+++ b/templates/cloud/package.json
@@ -22,7 +22,7 @@
"@hcengineering/platform-rig": "*"
},
"devDependencies": {
- "wrangler": "^3.103.1",
+ "wrangler": "^3.106.0",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"eslint-config-standard-with-typescript": "^40.0.0",
diff --git a/tests/tool-pg.sh b/tests/tool-pg.sh
index 1810a434f3..e94688c99b 100755
--- a/tests/tool-pg.sh
+++ b/tests/tool-pg.sh
@@ -13,4 +13,4 @@ export ELASTIC_URL=http://localhost:9201
export SERVER_SECRET=secret
export DB_URL=postgresql://postgres:example@localhost:5433
-node ../dev/tool/bundle/bundle.js $@
\ No newline at end of file
+node ${TOOL_OPTIONS} ../dev/tool/bundle/bundle.js $@
\ No newline at end of file
diff --git a/workers/branding/package.json b/workers/branding/package.json
index 6e70c991bc..14d57c7cb3 100644
--- a/workers/branding/package.json
+++ b/workers/branding/package.json
@@ -22,7 +22,7 @@
"@hcengineering/platform-rig": "^0.6.0",
"@cloudflare/workers-types": "^4.20241022.0",
"typescript": "^5.3.3",
- "wrangler": "^3.103.1",
+ "wrangler": "^3.106.0",
"jest": "^29.7.0",
"prettier": "^3.1.0",
"ts-jest": "^29.1.1",
diff --git a/workers/transactor/package.json b/workers/transactor/package.json
index be914e0be5..a31db5208f 100644
--- a/workers/transactor/package.json
+++ b/workers/transactor/package.json
@@ -10,7 +10,7 @@
"start": "wrangler dev --port 3335",
"logs": "npx wrangler tail --format pretty",
"cf-typegen": "wrangler types",
- "get-model": "mkdir -p bundle && esbuild src/get-model.ts --bundle --keep-names --platform=node --define:process.env.MODEL_VERSION=$(node ../../common/scripts/show_version.js) --define:process.env.VERSION=$(node ../../common/scripts/show_tag.js) --define:process.env.GIT_REVISION=$(../../common/scripts/git_version.sh) --outfile=bundle/bundle.js --log-level=error && node ./bundle/bundle.js > ./src/model.json",
+ "get-model": "mkdir -p bundle && esbuild src/get-model.ts --bundle --keep-names --external:*.node --platform=node --define:process.env.MODEL_VERSION=$(node ../../common/scripts/show_version.js) --define:process.env.VERSION=$(node ../../common/scripts/show_tag.js) --define:process.env.GIT_REVISION=$(../../common/scripts/git_version.sh) --outfile=bundle/bundle.js --log-level=error && node ./bundle/bundle.js > ./src/model.json",
"bundle": "rushx get-model && wrangler deploy --dry-run --outdir dist",
"build": "compile",
"build:watch": "compile",
@@ -38,7 +38,7 @@
"prettier": "^3.1.0",
"ts-jest": "^29.1.1",
"typescript": "^5.3.3",
- "wrangler": "^3.103.1",
+ "wrangler": "^3.106.0",
"esbuild": "^0.24.2",
"@types/snappyjs": "^0.7.1"
},
diff --git a/workers/transactor/src/index.ts b/workers/transactor/src/index.ts
index 0bed8c5366..fc95d8f470 100644
--- a/workers/transactor/src/index.ts
+++ b/workers/transactor/src/index.ts
@@ -22,22 +22,25 @@ export default {
const router = Router()
router
- .get('/:token', ({ params, headers }) => {
+ .get('/:token', async ({ params, headers }) => {
if (headers.get('Upgrade') !== 'websocket') {
return new Response('Expected header Upgrade: websocket', { status: 426 })
}
try {
const decodedToken = decodeToken(params.token, true, env.SERVER_SECRET)
- console.log('connecting', decodedToken.email)
+ console.log({ message: 'connecting', email: decodedToken.email, workspace: decodedToken.workspace.name })
const id = env.TRANSACTOR.idFromName(decodedToken.workspace.name)
const stub = env.TRANSACTOR.get(id)
- return stub.fetch(request)
+ return await stub.fetch(request)
} catch (err: any) {
- return new Response('Expected header Upgrade: websocket', { status: 426 })
+ console.error({ message: 'Request failed:', err, errMessage: err.message, stack: err.stack })
+
+ return new Response('Invalid', { status: 401 })
}
})
+
// TODO: Add statistics using storage
.all('/', () =>
html(
diff --git a/workers/transactor/src/logger.ts b/workers/transactor/src/logger.ts
index a299490f28..b0926ac94d 100644
--- a/workers/transactor/src/logger.ts
+++ b/workers/transactor/src/logger.ts
@@ -6,14 +6,17 @@ import type { MeasureLogger, ParamsType } from '@hcengineering/core'
export class CloudFlareLogger implements MeasureLogger {
error (message: string, obj?: Record): void {
+ const errMsg: Record = {}
// Check if obj has error inside, so we could send it to Analytics
for (const v of Object.values(obj ?? {})) {
if (v instanceof Error) {
// Analytics.handleError(v)
+ errMsg.error = v.message
+ errMsg.stack = v.stack
}
}
- console.error({ message, ...obj })
+ console.error({ message, ...obj, ...errMsg })
}
info (message: string, obj?: Record): void {
diff --git a/workers/transactor/src/transactor.ts b/workers/transactor/src/transactor.ts
index 91068c616c..1b516c265e 100644
--- a/workers/transactor/src/transactor.ts
+++ b/workers/transactor/src/transactor.ts
@@ -2,8 +2,7 @@
import {
generateId,
- MeasureMetricsContext,
- newMetrics,
+ NoMetricsContext,
type Class,
type Doc,
type DocumentQuery,
@@ -15,10 +14,8 @@ import {
import { setMetadata } from '@hcengineering/platform'
import { RPCHandler } from '@hcengineering/rpc'
import { ClientSession, createSessionManager, doSessionOp, type WebsocketData } from '@hcengineering/server'
-import serverClient from '@hcengineering/server-client'
import serverCore, {
createDummyStorageAdapter,
- initStatisticsContext,
loadBrandingMap,
pingConst,
pongConst,
@@ -29,7 +26,7 @@ import serverCore, {
} from '@hcengineering/server-core'
import serverPlugin, { decodeToken, type Token } from '@hcengineering/server-token'
import { DurableObject } from 'cloudflare:workers'
-import { compress } from 'snappyjs'
+import { compress, uncompress } from 'snappyjs'
import { promisify } from 'util'
import { gzip } from 'zlib'
@@ -39,8 +36,9 @@ import {
createPostgresAdapter,
createPostgresTxAdapter,
getDBClient,
- setDBExtraOptions,
- setExtraOptions
+ registerGreenDecoder,
+ registerGreenUrl,
+ setDBExtraOptions
} from '@hcengineering/postgres'
import {
createServerPipeline,
@@ -84,11 +82,7 @@ export class Transactor extends DurableObject {
ssl: false,
connection: {
application_name: 'cloud-transactor'
- },
- prepare: false
- })
- setExtraOptions({
- useCF: true
+ }
})
// configureAnalytics(env.SENTRY_DSN, {})
@@ -108,17 +102,23 @@ export class Transactor extends DurableObject {
registerAdapterFactory('postgresql', createPostgresAdapter, true)
registerDestroyFactory('postgresql', createPostgreeDestroyAdapter, true)
+ if (env.USE_GREEN === 'true') {
+ registerGreenUrl(env.GREEN_URL)
+ registerGreenDecoder('snappy', uncompress)
+ }
+
registerStringLoaders()
registerServerPlugins()
this.accountsUrl = env.ACCOUNTS_URL ?? 'http://127.0.0.1:3000'
this.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(pingConst, pongConst))
- this.measureCtx = this.measureCtx = initStatisticsContext('cloud-transactor', {
- statsUrl: this.env.STATS_URL ?? 'http://127.0.0.1:4900',
- serviceName: () => 'cloud-transactor: ' + this.workspace,
- factory: () => new MeasureMetricsContext('transactor', {}, {}, newMetrics(), new CloudFlareLogger())
- })
+ this.measureCtx = new NoMetricsContext(new CloudFlareLogger())
+ // initStatisticsContext('ctr-' + ctx.id.toString(), {
+ // statsUrl: this.env.STATS_URL ?? 'http://127.0.0.1:4900',
+ // serviceName: () => 'cloud-transactor: ' + this.workspace,
+ // factory: () => new MeasureMetricsContext('transactor', {}, {}, newMetrics(), new CloudFlareLogger())
+ // })
setMetadata(serverPlugin.metadata.Secret, env.SERVER_SECRET ?? 'secret')
@@ -153,8 +153,6 @@ export class Transactor extends DurableObject {
void this.ctx
.blockConcurrencyWhile(async () => {
- setMetadata(serverClient.metadata.Endpoint, env.ACCOUNTS_URL)
-
this.sessionManager = createSessionManager(
this.measureCtx,
(token: Token, workspace) => new ClientSession(token, workspace, false),
@@ -170,7 +168,7 @@ export class Transactor extends DurableObject {
)
})
.catch((err) => {
- console.error('Failed to init transactor', err)
+ console.error({ message: 'Failed to init transactor', err })
})
}
@@ -194,7 +192,7 @@ export class Transactor extends DurableObject {
}
return new Response(null, { status: 101, webSocket: client })
} catch (err: any) {
- console.error(err)
+ console.error({ message: 'Failed to handle request', errMsg: err.message, errStack: err.stack })
return new Response(null, { status: 404 })
}
}
@@ -208,31 +206,47 @@ export class Transactor extends DurableObject {
if (cs === undefined) {
return
}
- doSessionOp(
- session,
- (s, buff) => {
- s.context.measure('receive-data', buff?.length ?? 0)
- // processRequest(s.session, cs, s.context, s.workspaceId, buff, handleRequest)
- const request = cs.readRequest(buff, s.session.binaryMode)
- console.log({
- message: 'handle-request',
- method: request.method,
- workspace: s.workspaceId,
- user: s.session.getUser()
- })
- this.ctx.waitUntil(this.sessionManager.handleRequest(this.measureCtx, s.session, cs, request, this.workspace))
- },
- typeof message === 'string' ? Buffer.from(message) : Buffer.from(message)
- )
+ try {
+ doSessionOp(
+ session,
+ (s, buff) => {
+ s.context.measure('receive-data', buff?.length ?? 0)
+ // processRequest(s.session, cs, s.context, s.workspaceId, buff, handleRequest)
+ const request = cs.readRequest(buff, s.session.binaryMode)
+ const st = Date.now()
+ const r = this.sessionManager.handleRequest(this.measureCtx, s.session, cs, request, this.workspace)
+ void r.finally(() => {
+ console.log({
+ message: 'handle-request',
+ method: request.method,
+ params: request.params,
+ workspace: s.workspaceId,
+ user: s.session.getUser(),
+ time: Date.now() - st
+ })
+ })
+ this.ctx.waitUntil(r)
+ },
+ typeof message === 'string' ? Buffer.from(message) : Buffer.from(message)
+ )
+ } catch (err: any) {
+ console.error({ message: 'Failed to handle message:', err })
+ }
}
async webSocketError (ws: WebSocket, error: unknown): Promise {
- console.error('WebSocket error:', error)
+ console.error({ message: 'WebSocket error:', error })
await this.handleClose(ws, 1011, 'error')
}
async alarm (): Promise {
- console.log({ message: 'alarm' })
+ const memoryUsage = process.memoryUsage()
+ console.log({
+ message: 'Resource usage',
+ memoryUsed: memoryUsage.rss,
+ heapTotal: memoryUsage.heapTotal,
+ heapUsed: memoryUsage.heapUsed
+ })
}
async handleSession (
@@ -250,44 +264,64 @@ export class Transactor extends DurableObject {
mode: token.extra?.mode,
model: token.extra?.model
}
+ console.log({
+ message: 'New session attempt',
+ remoteAddress: data.remoteAddress,
+ userAgent: data.userAgent,
+ email: data.email
+ })
+ this.ctx.acceptWebSocket(ws)
const cs = this.createWebsocketClientSocket(ws, data)
- const session = await this.sessionManager.addSession(
- this.measureCtx,
- cs,
- token,
- rawToken,
- this.pipelineFactory,
- sessionId ?? undefined
- )
-
- const webSocketData: WebsocketData = {
- connectionSocket: cs,
- payload: token,
- token: rawToken,
- session,
- url: ''
- }
-
- if ('error' in session) {
- if (session.terminate === true) {
- ws.close()
- }
- throw session.error
- }
- if ('upgrade' in session) {
- cs.send(
+ try {
+ const session = await this.sessionManager.addSession(
this.measureCtx,
- { id: -1, result: { state: 'upgrading', stats: (session as any).upgradeInfo } },
- false,
- false
+ cs,
+ token,
+ rawToken,
+ this.pipelineFactory,
+ sessionId ?? undefined
)
- cs.close()
- return true
- }
- this.sessions.set(ws, webSocketData)
- this.ctx.acceptWebSocket(ws)
+ const webSocketData: WebsocketData = {
+ connectionSocket: cs,
+ payload: token,
+ token: rawToken,
+ session,
+ url: ''
+ }
+
+ if ('error' in session) {
+ console.error({ message: 'Failed to establish session:', error: session.error })
+ ws.close(4003, 'Session establishment failed')
+ if (session.terminate === true) {
+ ws.close()
+ }
+ throw session.error
+ }
+ if ('upgrade' in session) {
+ cs.send(
+ this.measureCtx,
+ { id: -1, result: { state: 'upgrading', stats: (session as any).upgradeInfo } },
+ false,
+ false
+ )
+ cs.close()
+ return true
+ }
+
+ console.log({
+ message: 'Session established successfully:',
+ sessionId: session.session.sessionId,
+ workspaceId: token.workspace.name,
+ user: token.email
+ })
+ this.sessions.set(ws, webSocketData)
+ } catch (err: any) {
+ console.error({ message: 'Failed to establish session:', err })
+ ws.close(4003, 'Session establishment failed')
+ throw err
+ }
return true
}
@@ -365,19 +399,22 @@ export class Transactor extends DurableObject {
try {
ws.send(message)
} catch (error) {
- console.error('Failed to send message:', error)
+ console.error({ message: 'Failed to send message:', error })
await this.handleClose(ws, 1011, 'error')
}
}
async handleClose (ws: WebSocket, code: number, reason?: string): Promise {
try {
+ console.log({ message: 'Closing connection with code', code, reason })
ws.close(code, reason)
} catch (err) {
- console.error('Failed to close WebSocket:', err)
+ console.error({ message: 'Failed to close WebSocket:', err })
}
const session = this.sessions.get(ws)
if (session !== undefined) {
+ this.sessions.delete(ws)
+ console.log({ message: 'Cleaning up session for', email: session.payload.email })
await this.sessionManager.close(this.measureCtx, session.connectionSocket as ConnectionSocket, this.workspace)
}
}
diff --git a/workers/transactor/worker-configuration.d.ts b/workers/transactor/worker-configuration.d.ts
index caa7a739f2..6adeb9a62c 100644
--- a/workers/transactor/worker-configuration.d.ts
+++ b/workers/transactor/worker-configuration.d.ts
@@ -30,4 +30,6 @@ interface Env {
AI_BOT_URL?: string
LAST_NAME_FIRST?: string
+ GREEN_URL?: string
+ USE_GREEN?: string
}
diff --git a/workers/transactor/wrangler.toml b/workers/transactor/wrangler.toml
index 53a876fe57..f440cecfa9 100644
--- a/workers/transactor/wrangler.toml
+++ b/workers/transactor/wrangler.toml
@@ -2,7 +2,7 @@
name = "cloud-transactor"
main = "src/index.ts"
compatibility_date = "2024-09-23"
-compatibility_flags = ["nodejs_compat_v2"]
+compatibility_flags = ["nodejs_compat"]
keep_vars = true
[observability.logs]
@@ -37,6 +37,7 @@ ENABLE_COMPRESSION=true
# TELEGRAM_BOT_URL
# AI_BOT_URL
# LAST_NAME_FIRST
+USE_GREEN='true'
# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai