@ossy/platform 1.39.3 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -12
- package/src/Definition.js +1 -1
- package/src/PlatformShell.jsx +5 -5
- package/src/accept-invitation.api.js +46 -0
- package/src/actions/action.service.js +6 -1
- package/src/audit/action-invocation-service.js +1 -2
- package/src/audit/action-invocation.aggregate.js +1 -3
- package/src/audit/task-run-list.aggregate.js +3 -5
- package/src/audit/task-run-service.js +18 -3
- package/src/audit/task-run.aggregate.js +1 -3
- package/src/config.service.js +1 -48
- package/src/directory.schema.js +1 -3
- package/src/file.schema.js +1 -3
- package/src/index.js +0 -1
- package/src/mcp/create-ossy-mcp-server.js +1 -1
- package/src/mcp/mount-platform-mcp.js +1 -1
- package/src/metering/metering-service.js +1 -2
- package/src/push/mount-push-sse.js +51 -3
- package/src/request-diagnostics.js +144 -0
- package/src/request-diagnostics.spec.js +40 -0
- package/src/resources/schema.registry.js +1 -26
- package/src/resources/schema.validation.js +6 -90
- package/src/runtime.js +17 -6
- package/src/select-workspace.api.js +50 -0
- package/src/server.js +166 -48
- package/src/tasks/change-stream.js +69 -9
- package/src/users.middleware.js +52 -20
- package/src/schema-ids.js +0 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -44,16 +44,17 @@
|
|
|
44
44
|
"@aws-sdk/util-create-request": "^3.972.26",
|
|
45
45
|
"@aws-sdk/util-format-url": "^3.972.17",
|
|
46
46
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
47
|
-
"@ossy/
|
|
48
|
-
"@ossy/event-store": "^
|
|
49
|
-
"@ossy/locale": "^
|
|
50
|
-
"@ossy/
|
|
51
|
-
"@ossy/
|
|
52
|
-
"@ossy/
|
|
53
|
-
"@ossy/
|
|
54
|
-
"@ossy/
|
|
55
|
-
"@ossy/
|
|
56
|
-
"@ossy/
|
|
47
|
+
"@ossy/config": "^3.0.2",
|
|
48
|
+
"@ossy/event-store": "^3.0.2",
|
|
49
|
+
"@ossy/locale": "^3.0.2",
|
|
50
|
+
"@ossy/manifest": "^3.0.2",
|
|
51
|
+
"@ossy/observability": "^3.0.2",
|
|
52
|
+
"@ossy/policies": "^3.0.2",
|
|
53
|
+
"@ossy/schema": "^3.0.2",
|
|
54
|
+
"@ossy/sdk": "^3.0.2",
|
|
55
|
+
"@ossy/tokens": "^3.0.2",
|
|
56
|
+
"@ossy/users": "^3.0.2",
|
|
57
|
+
"@ossy/workspaces": "^3.0.2",
|
|
57
58
|
"cookie-parser": "^1.4.7",
|
|
58
59
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
59
60
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -73,5 +74,5 @@
|
|
|
73
74
|
"src",
|
|
74
75
|
"Dockerfile"
|
|
75
76
|
],
|
|
76
|
-
"gitHead": "
|
|
77
|
+
"gitHead": "f0a8af3c370bbaffe4f7388c14f9345a5c6c56e5"
|
|
77
78
|
}
|
package/src/Definition.js
CHANGED
package/src/PlatformShell.jsx
CHANGED
|
@@ -25,12 +25,12 @@ import { ComponentSlotsProvider, Slot } from '@ossy/design-system'
|
|
|
25
25
|
export function PlatformShell({ slots = {}, children }) {
|
|
26
26
|
return (
|
|
27
27
|
<ComponentSlotsProvider slots={slots}>
|
|
28
|
-
<Slot
|
|
29
|
-
<Slot
|
|
30
|
-
<Slot
|
|
31
|
-
<Slot
|
|
28
|
+
<Slot view="app:system-messages" />
|
|
29
|
+
<Slot view="app:header" />
|
|
30
|
+
<Slot view="app:sidebar" />
|
|
31
|
+
<Slot view="app:toolbar" />
|
|
32
32
|
{children}
|
|
33
|
-
<Slot
|
|
33
|
+
<Slot view="app:notifications" />
|
|
34
34
|
</ComponentSlotsProvider>
|
|
35
35
|
)
|
|
36
36
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActionService,
|
|
3
|
+
mergeUserAppSettingsCookie,
|
|
4
|
+
setAuthCookie,
|
|
5
|
+
} from '@ossy/platform'
|
|
6
|
+
|
|
7
|
+
export const metadata = {
|
|
8
|
+
id: 'users.accept-invitation',
|
|
9
|
+
path: '/api/v0/users/accept-invitation',
|
|
10
|
+
action: '@ossy/workspaces/actions/accept-invitation',
|
|
11
|
+
method: 'GET',
|
|
12
|
+
query: ['token', 'workspaceId', 'redirect'],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default async function handle (req, res) {
|
|
16
|
+
if (req.method !== 'GET') {
|
|
17
|
+
res.setHeader('Allow', 'GET')
|
|
18
|
+
res.status(405).json({ error: 'Method Not Allowed' })
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const { authToken, workspaceId } = await ActionService.invoke('@ossy/workspaces/actions/accept-invitation', {
|
|
24
|
+
payload: {
|
|
25
|
+
token: req.query.token,
|
|
26
|
+
workspaceId: req.query.workspaceId,
|
|
27
|
+
},
|
|
28
|
+
req,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
setAuthCookie(res, authToken)
|
|
32
|
+
if (workspaceId) {
|
|
33
|
+
mergeUserAppSettingsCookie(req, res, { workspaceId })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const redirect = req.query.redirect
|
|
37
|
+
if (redirect && typeof redirect === 'string') {
|
|
38
|
+
res.redirect(302, redirect)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
res.status(200).json('')
|
|
43
|
+
} catch (err) {
|
|
44
|
+
res.status(err.status || 401).json('')
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -62,7 +62,7 @@ export const ActionService = {
|
|
|
62
62
|
* Invoke an action by id — records audit, runs the derived primary task, dispatches on_action follow-ups.
|
|
63
63
|
*
|
|
64
64
|
* @param {string} id
|
|
65
|
-
* @param {{ payload?: unknown, sdk?: unknown, log?: unknown, integrations?: unknown, req?: unknown }} context
|
|
65
|
+
* @param {{ payload?: unknown, sdk?: unknown, log?: unknown, integrations?: unknown, req?: unknown, signal?: AbortSignal }} context
|
|
66
66
|
* @returns {Promise<unknown>}
|
|
67
67
|
*/
|
|
68
68
|
async invoke (id, context = {}) {
|
|
@@ -92,6 +92,11 @@ export const ActionService = {
|
|
|
92
92
|
|
|
93
93
|
try {
|
|
94
94
|
const result = await TaskService.invoke(taskId, invokeContext)
|
|
95
|
+
if (context.signal?.aborted) {
|
|
96
|
+
throw context.signal.reason instanceof Error
|
|
97
|
+
? context.signal.reason
|
|
98
|
+
: new Error(`[ActionService] Action aborted: "${id}"`)
|
|
99
|
+
}
|
|
95
100
|
const taskRunId = invokeContext.audit?.taskRunId ?? null
|
|
96
101
|
|
|
97
102
|
if (!skipAudit) {
|
|
@@ -3,7 +3,6 @@ import { Aggregate } from '@ossy/event-store'
|
|
|
3
3
|
import { createLogger } from '@ossy/observability'
|
|
4
4
|
import { taskIdFromActionId } from '@ossy/schema'
|
|
5
5
|
import { ActionInvocation } from './action-invocation.aggregate.js'
|
|
6
|
-
import { PlatformSchema } from '../schema-ids.js'
|
|
7
6
|
import { MeteringService } from '../metering/metering-service.js'
|
|
8
7
|
import { detectChannel } from './detect-channel.js'
|
|
9
8
|
import {
|
|
@@ -42,7 +41,7 @@ export const ActionInvocationService = {
|
|
|
42
41
|
try {
|
|
43
42
|
await Aggregate.Of(ActionInvocation, {
|
|
44
43
|
resourceId: invocationId,
|
|
45
|
-
type:
|
|
44
|
+
type: '@ossy/platform/schema/action-invocation',
|
|
46
45
|
event: 'Invoked',
|
|
47
46
|
createdBy: actorId ?? undefined,
|
|
48
47
|
payload: {
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import { PlatformSchema } from '../schema-ids.js'
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* ADR 0008 entity — audit record for each action invocation (POST /actions, MCP, SDK).
|
|
5
3
|
*/
|
|
6
4
|
export class ActionInvocation {
|
|
7
5
|
|
|
8
6
|
static kind = 'entity'
|
|
9
|
-
static SchemaId =
|
|
7
|
+
static SchemaId = '@ossy/platform/schema/action-invocation'
|
|
10
8
|
static AggregateType = 'ActionInvocation'
|
|
11
9
|
|
|
12
10
|
static View (events, state = {}) {
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { PlatformSchema } from '../schema-ids.js'
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* ADR 0008 projection — workspace-scoped task run list for UI and billing queries.
|
|
5
3
|
*/
|
|
@@ -9,9 +7,9 @@ export class TaskRunListProjection {
|
|
|
9
7
|
static ProjectionId = '@ossy/platform/data/task-run-list'
|
|
10
8
|
|
|
11
9
|
static sources = [
|
|
12
|
-
{ type:
|
|
13
|
-
{ type:
|
|
14
|
-
{ type:
|
|
10
|
+
{ type: '@ossy/platform/schema/task-run', event: 'Started' },
|
|
11
|
+
{ type: '@ossy/platform/schema/task-run', event: 'Completed' },
|
|
12
|
+
{ type: '@ossy/platform/schema/task-run', event: 'Failed' },
|
|
15
13
|
]
|
|
16
14
|
|
|
17
15
|
static scopeFromEvent (event) {
|
|
@@ -2,7 +2,6 @@ import { nanoid } from 'nanoid'
|
|
|
2
2
|
import { Aggregate } from '@ossy/event-store'
|
|
3
3
|
import { createLogger, metrics } from '@ossy/observability'
|
|
4
4
|
import { TaskRun } from './task-run.aggregate.js'
|
|
5
|
-
import { PlatformSchema } from '../schema-ids.js'
|
|
6
5
|
import { MeteringService } from '../metering/metering-service.js'
|
|
7
6
|
import { detectChannel } from './detect-channel.js'
|
|
8
7
|
import {
|
|
@@ -12,9 +11,18 @@ import {
|
|
|
12
11
|
summarizeError,
|
|
13
12
|
summarizeResult,
|
|
14
13
|
} from './audit-helpers.js'
|
|
14
|
+
import { rejectOnAbort } from '../request-diagnostics.js'
|
|
15
15
|
|
|
16
16
|
const log = createLogger('platform/task-run')
|
|
17
17
|
|
|
18
|
+
function assertNotAborted (context) {
|
|
19
|
+
if (context.signal?.aborted) {
|
|
20
|
+
throw context.signal.reason instanceof Error
|
|
21
|
+
? context.signal.reason
|
|
22
|
+
: new Error('Operation aborted')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
function recordTaskMetrics (taskId, durationMs) {
|
|
19
27
|
try { metrics.timing('task.duration', durationMs, { task: taskId }) } catch {}
|
|
20
28
|
try { metrics.increment('task.run', { task: taskId }) } catch {}
|
|
@@ -49,6 +57,10 @@ export const TaskRunService = {
|
|
|
49
57
|
} = opts
|
|
50
58
|
|
|
51
59
|
if (audit === false) {
|
|
60
|
+
assertNotAborted(context)
|
|
61
|
+
if (context.signal) {
|
|
62
|
+
return Promise.race([handler(context), rejectOnAbort(context.signal)])
|
|
63
|
+
}
|
|
52
64
|
return handler(context)
|
|
53
65
|
}
|
|
54
66
|
|
|
@@ -72,7 +84,10 @@ export const TaskRunService = {
|
|
|
72
84
|
})
|
|
73
85
|
|
|
74
86
|
try {
|
|
75
|
-
|
|
87
|
+
assertNotAborted(context)
|
|
88
|
+
const result = context.signal
|
|
89
|
+
? await Promise.race([handler(context), rejectOnAbort(context.signal)])
|
|
90
|
+
: await handler(context)
|
|
76
91
|
const durationMs = Date.now() - startMs
|
|
77
92
|
await TaskRunService._appendCompleted({
|
|
78
93
|
runId,
|
|
@@ -145,7 +160,7 @@ export const TaskRunService = {
|
|
|
145
160
|
try {
|
|
146
161
|
await Aggregate.Of(TaskRun, {
|
|
147
162
|
resourceId: runId,
|
|
148
|
-
type:
|
|
163
|
+
type: '@ossy/platform/schema/task-run',
|
|
149
164
|
event: 'Started',
|
|
150
165
|
createdBy: actorId ?? undefined,
|
|
151
166
|
payload: {
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import { PlatformSchema } from '../schema-ids.js'
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* ADR 0008 entity — one stream per task execution (sync invoke, changestream, cron, on_action).
|
|
5
3
|
*/
|
|
6
4
|
export class TaskRun {
|
|
7
5
|
|
|
8
6
|
static kind = 'entity'
|
|
9
|
-
static SchemaId =
|
|
7
|
+
static SchemaId = '@ossy/platform/schema/task-run'
|
|
10
8
|
static AggregateType = 'TaskRun'
|
|
11
9
|
|
|
12
10
|
static View (events, state = {}) {
|
package/src/config.service.js
CHANGED
|
@@ -1,48 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* API Configuration
|
|
3
|
-
* @class
|
|
4
|
-
*/
|
|
5
|
-
export class ConfigService {
|
|
6
|
-
|
|
7
|
-
static Port = process.env.BE_PORT || 3000
|
|
8
|
-
static Domain = process.env.DOMAIN || `localhost:${ConfigService.Port}`
|
|
9
|
-
static WebClientDomain = process.env.WEB_CLIENT_DOMAIN || `http://localhost:3000`
|
|
10
|
-
static MongoUrl = process.env.DB_URL || 'mongodb://mongodb:27017/'
|
|
11
|
-
static DbName = process.env.DB_NAME || 'test'
|
|
12
|
-
static TokenSecret = process.env.TOKEN_SECRET || 'testsecret2'
|
|
13
|
-
static TokenValidity = process.env.TOKEN_VALIDITY || 60 * 60 * 24 * 14
|
|
14
|
-
static LimitedAccessCode = process.env.LIMITED_ACCESS_CODE || 'test'
|
|
15
|
-
static BuildEnvironment = process.env.BUILD_ENVIRONMENT
|
|
16
|
-
static BotUserEmail = process.env.BOT_USER_EMAIL || 'ossybot@ossy.se'
|
|
17
|
-
static BotUserId = process.env.BOT_USER_ID || 'Mil5qAL7jDFCTyuKD_BKb'
|
|
18
|
-
/** Set on `req.userId` when there is no valid session; role checks can treat this as unauthenticated. */
|
|
19
|
-
static AnonymousUserId = process.env.ANONYMOUS_USER_ID || 'anonymous'
|
|
20
|
-
static MediaRepository = process.env.MEDIA_REPOSITORY
|
|
21
|
-
static MediaCdnDomainName = process.env.MEDIA_CDN_DOMAIN_NAME
|
|
22
|
-
static awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID
|
|
23
|
-
static awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY
|
|
24
|
-
static SesRegion = process.env.SES_REGION || 'eu-north-1'
|
|
25
|
-
static Debug = process.env.DEBUG
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Derives the web client base URL from the incoming request's Origin or Referer
|
|
29
|
-
* header so that email links point back to whichever domain the user came from
|
|
30
|
-
* (e.g. http://ossy.local, http://localhost:3002). Falls back to the
|
|
31
|
-
* WEB_CLIENT_DOMAIN env var / default when no header is present.
|
|
32
|
-
*/
|
|
33
|
-
static getWebClientBaseUrl(req) {
|
|
34
|
-
const origin = req?.headers?.origin
|
|
35
|
-
if (origin && origin !== 'null') return origin
|
|
36
|
-
|
|
37
|
-
const referer = req?.headers?.referer
|
|
38
|
-
if (referer) {
|
|
39
|
-
try {
|
|
40
|
-
const { origin: refOrigin } = new URL(referer)
|
|
41
|
-
if (refOrigin && refOrigin !== 'null') return refOrigin
|
|
42
|
-
} catch {}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return ConfigService.WebClientDomain
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
}
|
|
1
|
+
export { ConfigService } from '@ossy/config'
|
package/src/directory.schema.js
CHANGED
package/src/file.schema.js
CHANGED
package/src/index.js
CHANGED
|
@@ -4,7 +4,6 @@ export { ChangeStream } from './tasks/change-stream.js'
|
|
|
4
4
|
export { registerSchema, getSystemSchemas } from './resources/schema.registry.js'
|
|
5
5
|
export { getPlatformSchema, initPlatformSchema, createSchemaEngine, schemaForWorkspace } from './resources/schema.service.js'
|
|
6
6
|
export { validateSchemasForImport, ALLOWED_FIELD_TYPES, normalizeFieldType, resolveFieldDef } from './resources/schema.validation.js'
|
|
7
|
-
export { PlatformSchema } from './schema-ids.js'
|
|
8
7
|
export { IntegrationService } from './integration.service.js'
|
|
9
8
|
export { ConfigService } from './config.service.js'
|
|
10
9
|
export { ActionService } from './actions/action.service.js'
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildCapabilities } from '@ossy/
|
|
1
|
+
import { buildCapabilities } from '@ossy/manifest/build-capabilities'
|
|
2
2
|
import { mountOssyMcp } from './mount-ossy-mcp.js'
|
|
3
3
|
import { uploadFileToolHandler, UPLOAD_FILE_TOOL } from './upload-file-tool.js'
|
|
4
4
|
import { ActionService } from '../actions/action.service.js'
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid'
|
|
2
2
|
import { EventStore } from '@ossy/event-store'
|
|
3
3
|
import { createLogger, metrics } from '@ossy/observability'
|
|
4
|
-
import { PlatformSchema } from '../schema-ids.js'
|
|
5
4
|
import { moduleIdFromTaskId } from '../audit/audit-helpers.js'
|
|
6
5
|
|
|
7
6
|
const log = createLogger('platform/metering')
|
|
@@ -73,7 +72,7 @@ export const MeteringService = {
|
|
|
73
72
|
try {
|
|
74
73
|
await EventStore.AppendResourceEvent({
|
|
75
74
|
id: nanoid(),
|
|
76
|
-
type:
|
|
75
|
+
type: '@ossy/platform/schema/meter-event',
|
|
77
76
|
resourceId: nanoid(),
|
|
78
77
|
event: 'Recorded',
|
|
79
78
|
version: 1,
|
|
@@ -5,6 +5,46 @@ const log = createLogger('@ossy/platform')
|
|
|
5
5
|
|
|
6
6
|
const HEARTBEAT_MS = 25_000
|
|
7
7
|
|
|
8
|
+
/** @type {Map<string, number>} */
|
|
9
|
+
const activeConnectionsByWorkspace = new Map()
|
|
10
|
+
|
|
11
|
+
/** @type {Set<{ res: import('express').Response, heartbeat: ReturnType<typeof setInterval>, unsubscribe: () => void, workspaceId: string }>} */
|
|
12
|
+
const activeConnections = new Set()
|
|
13
|
+
|
|
14
|
+
function trackConnection (workspaceId, delta) {
|
|
15
|
+
const next = Math.max(0, (activeConnectionsByWorkspace.get(workspaceId) ?? 0) + delta)
|
|
16
|
+
if (next === 0) {
|
|
17
|
+
activeConnectionsByWorkspace.delete(workspaceId)
|
|
18
|
+
} else {
|
|
19
|
+
activeConnectionsByWorkspace.set(workspaceId, next)
|
|
20
|
+
}
|
|
21
|
+
return next
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function releaseConnection (connection) {
|
|
25
|
+
if (!activeConnections.delete(connection)) return
|
|
26
|
+
|
|
27
|
+
clearInterval(connection.heartbeat)
|
|
28
|
+
connection.unsubscribe()
|
|
29
|
+
const remaining = trackConnection(connection.workspaceId, -1)
|
|
30
|
+
log.debug('[push] SSE closed', {
|
|
31
|
+
workspaceId: connection.workspaceId,
|
|
32
|
+
activeForWorkspace: remaining,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Tear down all open SSE connections so HTTP server shutdown is not blocked.
|
|
38
|
+
*/
|
|
39
|
+
export function closePushSseConnections () {
|
|
40
|
+
for (const connection of [...activeConnections]) {
|
|
41
|
+
releaseConnection(connection)
|
|
42
|
+
if (!connection.res.writableEnded) {
|
|
43
|
+
connection.res.end()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
8
48
|
/**
|
|
9
49
|
* ADR 0008 §9 — workspace-scoped SSE invalidation bus.
|
|
10
50
|
*
|
|
@@ -30,14 +70,22 @@ export function mountPushSse (app) {
|
|
|
30
70
|
send({ kind: 'connected', scope: { workspaceId } })
|
|
31
71
|
|
|
32
72
|
const unsubscribe = PushInvalidation.subscribe(workspaceId, send)
|
|
73
|
+
const activeForWorkspace = trackConnection(workspaceId, 1)
|
|
74
|
+
log.debug('[push] SSE opened', {
|
|
75
|
+
workspaceId,
|
|
76
|
+
activeForWorkspace,
|
|
77
|
+
userId: req.userId,
|
|
78
|
+
})
|
|
79
|
+
|
|
33
80
|
const heartbeat = setInterval(() => {
|
|
34
81
|
res.write(': heartbeat\n\n')
|
|
35
82
|
}, HEARTBEAT_MS)
|
|
36
83
|
|
|
84
|
+
const connection = { res, heartbeat, unsubscribe, workspaceId }
|
|
85
|
+
activeConnections.add(connection)
|
|
86
|
+
|
|
37
87
|
req.on('close', () => {
|
|
38
|
-
|
|
39
|
-
unsubscribe()
|
|
40
|
-
log.debug(`[push] SSE closed for workspace ${workspaceId}`)
|
|
88
|
+
releaseConnection(connection)
|
|
41
89
|
})
|
|
42
90
|
})
|
|
43
91
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createLogger } from '@ossy/observability'
|
|
2
|
+
|
|
3
|
+
const log = createLogger('@ossy/platform/request-diagnostics')
|
|
4
|
+
|
|
5
|
+
export class OperationTimeoutError extends Error {
|
|
6
|
+
constructor (label, timeoutMs) {
|
|
7
|
+
super(`Operation timed out after ${timeoutMs}ms: ${label}`)
|
|
8
|
+
this.name = 'OperationTimeoutError'
|
|
9
|
+
this.label = label
|
|
10
|
+
this.timeoutMs = timeoutMs
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveTimeoutMs (envKey, fallback) {
|
|
15
|
+
const raw = process.env[envKey]
|
|
16
|
+
const parsed = Number.parseInt(String(raw ?? ''), 10)
|
|
17
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reject when `promise` does not settle within `timeoutMs`.
|
|
22
|
+
* Pass `timeoutMs <= 0` to disable (returns `promise` unchanged).
|
|
23
|
+
*
|
|
24
|
+
* When `abortController` is supplied, aborts its signal on timeout so callers
|
|
25
|
+
* can propagate cancellation into long-running work.
|
|
26
|
+
*
|
|
27
|
+
* @template T
|
|
28
|
+
* @param {Promise<T>} promise
|
|
29
|
+
* @param {number} timeoutMs
|
|
30
|
+
* @param {string} label
|
|
31
|
+
* @param {{ abortController?: AbortController }} [options]
|
|
32
|
+
* @returns {Promise<T>}
|
|
33
|
+
*/
|
|
34
|
+
export function withTimeout (promise, timeoutMs, label, options = {}) {
|
|
35
|
+
if (!timeoutMs || timeoutMs <= 0) return promise
|
|
36
|
+
|
|
37
|
+
const { abortController } = options
|
|
38
|
+
let timer
|
|
39
|
+
|
|
40
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
41
|
+
timer = setTimeout(() => {
|
|
42
|
+
const err = new OperationTimeoutError(label, timeoutMs)
|
|
43
|
+
abortController?.abort(err)
|
|
44
|
+
reject(err)
|
|
45
|
+
}, timeoutMs)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const abortPromise = abortController
|
|
49
|
+
? new Promise((_, reject) => {
|
|
50
|
+
const { signal } = abortController
|
|
51
|
+
if (signal.aborted) {
|
|
52
|
+
reject(signal.reason ?? new OperationTimeoutError(label, timeoutMs))
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
signal.addEventListener('abort', () => {
|
|
56
|
+
reject(signal.reason ?? new OperationTimeoutError(label, timeoutMs))
|
|
57
|
+
}, { once: true })
|
|
58
|
+
})
|
|
59
|
+
: null
|
|
60
|
+
|
|
61
|
+
const races = abortPromise ? [promise, timeoutPromise, abortPromise] : [promise, timeoutPromise]
|
|
62
|
+
|
|
63
|
+
return Promise.race(races).finally(() => {
|
|
64
|
+
clearTimeout(timer)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reject when a signal aborts (used to race cooperative task handlers).
|
|
70
|
+
*
|
|
71
|
+
* @param {AbortSignal} signal
|
|
72
|
+
* @returns {Promise<never>}
|
|
73
|
+
*/
|
|
74
|
+
export function rejectOnAbort (signal) {
|
|
75
|
+
return new Promise((_, reject) => {
|
|
76
|
+
if (signal.aborted) {
|
|
77
|
+
reject(signal.reason ?? new Error('Operation aborted'))
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
signal.addEventListener('abort', () => {
|
|
81
|
+
reject(signal.reason ?? new Error('Operation aborted'))
|
|
82
|
+
}, { once: true })
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Log when an operation exceeds the slow threshold.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} label
|
|
90
|
+
* @param {{ log?: import('@ossy/observability').Logger, warnThresholdMs?: number }} [options]
|
|
91
|
+
*/
|
|
92
|
+
export function createTimedOperation (label, options = {}) {
|
|
93
|
+
const logger = options.log ?? log
|
|
94
|
+
const warnThresholdMs = options.warnThresholdMs
|
|
95
|
+
?? resolveTimeoutMs('OSSY_REQUEST_SLOW_MS', 5000)
|
|
96
|
+
const start = performance.now()
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
finish (detail) {
|
|
100
|
+
const elapsedMs = Math.round(performance.now() - start)
|
|
101
|
+
const payload = { label, elapsedMs, ...(detail ?? {}) }
|
|
102
|
+
if (elapsedMs >= warnThresholdMs) {
|
|
103
|
+
logger.warn('[slow-operation]', payload)
|
|
104
|
+
} else {
|
|
105
|
+
logger.debug('[timing]', payload)
|
|
106
|
+
}
|
|
107
|
+
return elapsedMs
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Express middleware — logs requests that exceed OSSY_REQUEST_SLOW_MS (default 5s).
|
|
114
|
+
*
|
|
115
|
+
* @param {import('@ossy/observability').Logger} [logger]
|
|
116
|
+
*/
|
|
117
|
+
export function createSlowRequestLogger (logger = log) {
|
|
118
|
+
const slowThresholdMs = resolveTimeoutMs('OSSY_REQUEST_SLOW_MS', 5000)
|
|
119
|
+
|
|
120
|
+
return (req, res, next) => {
|
|
121
|
+
const start = performance.now()
|
|
122
|
+
const requestUrl = req.originalUrl || req.url || '/'
|
|
123
|
+
|
|
124
|
+
const onFinished = () => {
|
|
125
|
+
res.removeListener('finish', onFinished)
|
|
126
|
+
res.removeListener('close', onFinished)
|
|
127
|
+
const elapsedMs = Math.round(performance.now() - start)
|
|
128
|
+
if (elapsedMs < slowThresholdMs) return
|
|
129
|
+
|
|
130
|
+
logger.warn('[slow-request]', {
|
|
131
|
+
method: req.method,
|
|
132
|
+
url: requestUrl,
|
|
133
|
+
elapsedMs,
|
|
134
|
+
statusCode: res.statusCode,
|
|
135
|
+
userId: req.userId,
|
|
136
|
+
workspaceId: req.workspaceId,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
res.on('finish', onFinished)
|
|
141
|
+
res.on('close', onFinished)
|
|
142
|
+
next()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { OperationTimeoutError, rejectOnAbort, withTimeout } from './request-diagnostics.js'
|
|
3
|
+
|
|
4
|
+
describe('request-diagnostics', () => {
|
|
5
|
+
it('withTimeout resolves when promise settles in time', async () => {
|
|
6
|
+
const result = await withTimeout(Promise.resolve('ok'), 100, 'fast')
|
|
7
|
+
expect(result).toBe('ok')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('withTimeout rejects with OperationTimeoutError when promise is slow', async () => {
|
|
11
|
+
await expect(withTimeout(new Promise(() => {}), 20, 'slow-op')).rejects.toMatchObject({
|
|
12
|
+
label: 'slow-op',
|
|
13
|
+
timeoutMs: 20,
|
|
14
|
+
})
|
|
15
|
+
await expect(withTimeout(new Promise(() => {}), 20, 'slow-op')).rejects.toBeInstanceOf(OperationTimeoutError)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('withTimeout passes through when timeout is disabled', async () => {
|
|
19
|
+
const result = await withTimeout(Promise.resolve(42), 0, 'disabled')
|
|
20
|
+
expect(result).toBe(42)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('withTimeout aborts the supplied controller on timeout', async () => {
|
|
24
|
+
const abortController = new AbortController()
|
|
25
|
+
let aborted = false
|
|
26
|
+
abortController.signal.addEventListener('abort', () => { aborted = true })
|
|
27
|
+
|
|
28
|
+
await expect(
|
|
29
|
+
withTimeout(new Promise(() => {}), 20, 'abortable', { abortController }),
|
|
30
|
+
).rejects.toBeInstanceOf(OperationTimeoutError)
|
|
31
|
+
expect(aborted).toBe(true)
|
|
32
|
+
expect(abortController.signal.aborted).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('rejectOnAbort rejects when signal is already aborted', async () => {
|
|
36
|
+
const abortController = new AbortController()
|
|
37
|
+
abortController.abort(new Error('already gone'))
|
|
38
|
+
await expect(rejectOnAbort(abortController.signal)).rejects.toThrow(/already gone/)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -1,26 +1 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const log = createLogger('platform')
|
|
4
|
-
|
|
5
|
-
/** @type {object[]} */
|
|
6
|
-
const systemSchemas = []
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Register a single schema POJO (as inlined in `build/manifest.json`).
|
|
10
|
-
*
|
|
11
|
-
* @param {object} schema
|
|
12
|
-
*/
|
|
13
|
-
export function registerSchema (schema) {
|
|
14
|
-
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return
|
|
15
|
-
if (typeof schema.id !== 'string' || schema.id.trim() === '') return
|
|
16
|
-
if (systemSchemas.find(s => s.id === schema.id)) return
|
|
17
|
-
systemSchemas.push(schema)
|
|
18
|
-
log.info(`[SchemaRegistry] Registered system schema: ${schema.id}`)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* @returns {object[]}
|
|
23
|
-
*/
|
|
24
|
-
export function getSystemSchemas () {
|
|
25
|
-
return systemSchemas
|
|
26
|
-
}
|
|
1
|
+
export { registerSchema, getSystemSchemas } from '@ossy/schema'
|