@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "1.39.3",
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/app": "^1.40.3",
48
- "@ossy/event-store": "^1.8.3",
49
- "@ossy/locale": "^1.40.3",
50
- "@ossy/observability": "^1.8.3",
51
- "@ossy/policies": "^1.13.3",
52
- "@ossy/schema": "^1.0.1",
53
- "@ossy/sdk": "^1.40.3",
54
- "@ossy/tokens": "^1.13.3",
55
- "@ossy/users": "^1.13.3",
56
- "@ossy/workspaces": "^1.17.3",
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": "a0d89185a17f8de8ce328c3a648c108ff1d61d8f"
77
+ "gitHead": "f0a8af3c370bbaffe4f7388c14f9345a5c6c56e5"
77
78
  }
package/src/Definition.js CHANGED
@@ -3,6 +3,6 @@ export const Definition = {
3
3
  title: 'Platform',
4
4
  description: 'Deployment platform configuration aligned with deployment-tools S3 platform-config.json.',
5
5
  icon: 'controller',
6
- statuses: ['beta'],
6
+ status: ['beta'],
7
7
  entitlementRequired: false,
8
8
  }
@@ -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 name="app:system-messages" />
29
- <Slot name="app:header" />
30
- <Slot name="app:sidebar" />
31
- <Slot name="app:toolbar" />
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 name="app:notifications" />
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: PlatformSchema.actionInvocation,
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 = PlatformSchema.actionInvocation
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: PlatformSchema.taskRun, event: 'Started' },
13
- { type: PlatformSchema.taskRun, event: 'Completed' },
14
- { type: PlatformSchema.taskRun, event: 'Failed' },
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
- const result = await handler(context)
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: PlatformSchema.taskRun,
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 = PlatformSchema.taskRun
7
+ static SchemaId = '@ossy/platform/schema/task-run'
10
8
  static AggregateType = 'TaskRun'
11
9
 
12
10
  static View (events, state = {}) {
@@ -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'
@@ -1,7 +1,5 @@
1
- import { PlatformSchema } from './schema-ids.js'
2
-
3
1
  export default {
4
- id: PlatformSchema.directory,
2
+ id: '@ossy/platform/schema/directory',
5
3
  name: 'Directory',
6
4
  categoryName: 'Platform',
7
5
  icon: 'folder',
@@ -1,7 +1,5 @@
1
- import { PlatformSchema } from './schema-ids.js'
2
-
3
1
  export default {
4
- id: PlatformSchema.file,
2
+ id: '@ossy/platform/schema/file',
5
3
  name: 'File',
6
4
  categoryName: 'Platform',
7
5
  icon: 'file',
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'
@@ -3,7 +3,7 @@ import { jsonSchemaToZodShape } from './json-schema-to-zod.js'
3
3
  import {
4
4
  TASK_TOPOLOGY_RESOURCE_URI,
5
5
  capabilitiesTaskTopologyResource,
6
- } from '@ossy/app/manifest/build-capabilities'
6
+ } from '@ossy/manifest/build-capabilities'
7
7
 
8
8
  /**
9
9
  * @param {{
@@ -1,4 +1,4 @@
1
- import { buildCapabilities } from '@ossy/app/manifest/build-capabilities'
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: PlatformSchema.meterEvent,
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
- clearInterval(heartbeat)
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
- import { createLogger } from '@ossy/observability'
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'