@ossy/platform 1.39.2 → 3.0.1

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.
Files changed (66) hide show
  1. package/README.md +21 -17
  2. package/package.json +20 -11
  3. package/src/Definition.js +2 -1
  4. package/src/PlatformShell.jsx +10 -10
  5. package/src/actions/action.service.js +85 -7
  6. package/src/audit/action-invocation-service.js +143 -0
  7. package/src/audit/action-invocation.aggregate.js +57 -0
  8. package/src/audit/audit-helpers.js +61 -0
  9. package/src/audit/detect-channel.js +19 -0
  10. package/src/audit/list-task-runs.action.js +5 -0
  11. package/src/audit/list-task-runs.task.js +17 -0
  12. package/src/audit/task-run-list.aggregate.js +76 -0
  13. package/src/audit/task-run-service.js +208 -0
  14. package/src/audit/task-run.aggregate.js +58 -0
  15. package/src/auth/action-scopes.js +3 -0
  16. package/src/capability-schemas/action-capability.schema.js +1 -0
  17. package/src/capability-schemas/action-meta.schema.js +1 -0
  18. package/src/capability-schemas/component-capability.schema.js +1 -0
  19. package/src/capability-schemas/page-capability.schema.js +1 -0
  20. package/src/capability-schemas/page-meta.schema.js +1 -0
  21. package/src/capability-schemas/task-capability.schema.js +1 -0
  22. package/src/capability-schemas/task-meta.schema.js +1 -0
  23. package/src/capability-schemas/task-output.schema.js +1 -0
  24. package/src/capability-schemas/task-trigger-authoring.schema.js +1 -0
  25. package/src/capability-schemas/task-trigger.schema.js +1 -0
  26. package/src/directory.schema.js +7 -0
  27. package/src/entitlements/action-entitlement.js +69 -0
  28. package/src/file.schema.js +11 -0
  29. package/src/index.js +12 -3
  30. package/src/mcp/create-ossy-mcp-server.js +97 -0
  31. package/src/mcp/json-schema-to-zod.js +64 -0
  32. package/src/mcp/mount-ossy-mcp.js +72 -0
  33. package/src/mcp/mount-platform-mcp.js +101 -0
  34. package/src/mcp/upload-file-tool.js +62 -0
  35. package/src/metering/metering-service.js +91 -0
  36. package/src/{platform-config.resource.js → platform-config.schema.js} +1 -1
  37. package/src/proxy-internal.js +13 -16
  38. package/src/push/mount-push-sse.js +91 -0
  39. package/src/request-diagnostics.js +144 -0
  40. package/src/request-diagnostics.spec.js +40 -0
  41. package/src/resources/index.js +3 -2
  42. package/src/resources/schema.registry.js +26 -0
  43. package/src/resources/schema.service.js +54 -0
  44. package/src/resources/schema.validation.js +90 -0
  45. package/src/runtime.js +20 -6
  46. package/src/server.js +281 -62
  47. package/src/storage/filesystem-storage.client.js +109 -0
  48. package/src/storage/local-storage.client.js +2 -65
  49. package/src/storage/resource-read-url.js +36 -0
  50. package/src/storage/resource-read-url.spec.js +22 -0
  51. package/src/storage/s3-storage.client.js +102 -0
  52. package/src/storage/s3.client.js +27 -23
  53. package/src/storage/storage-keys.js +37 -0
  54. package/src/storage/storage-keys.spec.js +16 -0
  55. package/src/storage/storage.client.js +52 -8
  56. package/src/storage/storage.integration.js +40 -0
  57. package/src/tasks/change-stream.js +78 -11
  58. package/src/tasks/task-service.js +211 -34
  59. package/src/tasks/task-service.spec.js +187 -0
  60. package/src/test/e2e.util.js +18 -39
  61. package/src/test/flow-runner.js +476 -0
  62. package/src/test/test.util.js +30 -29
  63. package/src/user-app-settings.js +44 -0
  64. package/src/users.middleware.js +61 -17
  65. package/src/resources/resource-template.registry.js +0 -29
  66. package/src/resources/resource-template.validation.js +0 -232
@@ -1,65 +1,2 @@
1
- import path from 'path'
2
- import os from 'os'
3
- import { mkdir, writeFile, readFile, stat } from 'fs/promises'
4
- import { existsSync } from 'fs'
5
-
6
- const LOCAL_STORAGE_DIR = path.join(os.tmpdir(), 'ossy-local-media')
7
-
8
- function normalizeKey(keyOrShape) {
9
- const Key = typeof keyOrShape === 'string' ? keyOrShape : keyOrShape?.Key
10
- if (!Key || typeof Key !== 'string') {
11
- throw new Error('[LocalStorageClient] missing Key')
12
- }
13
- return Key
14
- }
15
-
16
- function keyToLocalPath(key) {
17
- const resolved = path.resolve(LOCAL_STORAGE_DIR, key)
18
- if (!resolved.startsWith(LOCAL_STORAGE_DIR)) {
19
- throw new Error('[LocalStorageClient] invalid key: path traversal detected')
20
- }
21
- return resolved
22
- }
23
-
24
- function buildUrl(key) {
25
- const base = (process.env.LOCAL_API_URL || 'http://localhost:3001').replace(/\/$/, '')
26
- return `${base}/local-storage?key=${encodeURIComponent(key)}`
27
- }
28
-
29
- export class LocalStorageClient {
30
-
31
- static createUploadUrl({ Key }) {
32
- return Promise.resolve(buildUrl(Key))
33
- }
34
-
35
- static createPresignedDownloadUrl(keyOrShape) {
36
- const Key = normalizeKey(keyOrShape)
37
- return Promise.resolve(buildUrl(Key))
38
- }
39
-
40
- static createDownloadUrl(keyOrShape) {
41
- const Key = normalizeKey(keyOrShape)
42
- return buildUrl(Key)
43
- }
44
-
45
- static async save(key, body) {
46
- const localPath = keyToLocalPath(key)
47
- await mkdir(path.dirname(localPath), { recursive: true })
48
- await writeFile(localPath, body)
49
- }
50
-
51
- static async load(key) {
52
- const localPath = keyToLocalPath(key)
53
- if (!existsSync(localPath)) return { buffer: null, exists: false }
54
- const buffer = await readFile(localPath)
55
- return { buffer, exists: true }
56
- }
57
-
58
- static async headObject({ Key }) {
59
- const localPath = keyToLocalPath(Key)
60
- const s = await stat(localPath).catch(() => null)
61
- if (!s) throw Object.assign(new Error('Not Found'), { $metadata: { httpStatusCode: 404 } })
62
- return { ContentLength: s.size }
63
- }
64
-
65
- }
1
+ /** @deprecated Import from filesystem-storage.client.js */
2
+ export { LocalStorageClient, createFilesystemStorageClient, resolveFilesystemStorageRoot } from './filesystem-storage.client.js'
@@ -0,0 +1,36 @@
1
+ import { assertStorageKey } from './storage-keys.js'
2
+
3
+ function normalizeLogicalKey (keyOrShape) {
4
+ const Key = typeof keyOrShape === 'string' ? keyOrShape : keyOrShape?.Key
5
+ assertStorageKey(Key)
6
+ return Key
7
+ }
8
+
9
+ /**
10
+ * Predictable app-relative read path for a storage key.
11
+ * Original: `/r/{resourceId}` — derivative: `/r/{resourceId}/{variant}`.
12
+ *
13
+ * @param {string | { Key?: string }} keyOrShape
14
+ * @returns {string}
15
+ */
16
+ export function storageKeyToReadPath (keyOrShape) {
17
+ const key = normalizeLogicalKey(keyOrShape)
18
+ const colon = key.indexOf(':')
19
+ if (colon === -1) return `/r/${key}`
20
+ const resourceId = key.slice(0, colon)
21
+ const variant = key.slice(colon + 1)
22
+ return `/r/${resourceId}/${variant}`
23
+ }
24
+
25
+ /**
26
+ * Absolute resource read URL (same shape for filesystem and S3 backends).
27
+ *
28
+ * @param {string | { Key?: string }} keyOrShape
29
+ * @param {NodeJS.ProcessEnv} [env]
30
+ * @returns {string}
31
+ */
32
+ export function createResourceReadUrl (keyOrShape, env = process.env) {
33
+ const path = storageKeyToReadPath(keyOrShape)
34
+ const base = (env.WEB_CLIENT_DOMAIN || env.LOCAL_API_URL || '').replace(/\/$/, '')
35
+ return base ? `${base}${path}` : path
36
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { createResourceReadUrl, storageKeyToReadPath } from './resource-read-url.js'
3
+
4
+ describe('resource-read-url', () => {
5
+ it('maps original key to /r/{resourceId}', () => {
6
+ expect(storageKeyToReadPath('abc123XYZ')).toBe('/r/abc123XYZ')
7
+ })
8
+
9
+ it('maps derivative key to /r/{resourceId}/{variant}', () => {
10
+ expect(storageKeyToReadPath('abc123:galleryLarge')).toBe('/r/abc123/galleryLarge')
11
+ })
12
+
13
+ it('builds absolute URL from WEB_CLIENT_DOMAIN', () => {
14
+ expect(createResourceReadUrl('abc123', { WEB_CLIENT_DOMAIN: 'https://app.example.com' }))
15
+ .toBe('https://app.example.com/r/abc123')
16
+ })
17
+
18
+ it('falls back to LOCAL_API_URL', () => {
19
+ expect(createResourceReadUrl('abc123', { LOCAL_API_URL: 'http://localhost:3006' }))
20
+ .toBe('http://localhost:3006/r/abc123')
21
+ })
22
+ })
@@ -0,0 +1,102 @@
1
+ import { S3, PutObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'
2
+ import { S3RequestPresigner } from '@aws-sdk/s3-request-presigner'
3
+ import { createRequest } from '@aws-sdk/util-create-request'
4
+ import { formatUrl } from '@aws-sdk/util-format-url'
5
+ import { createLogger } from '@ossy/observability'
6
+ import { assertStorageKey } from './storage-keys.js'
7
+ import { createResourceReadUrl } from './resource-read-url.js'
8
+
9
+ const log = createLogger('storage')
10
+
11
+ const REGION = 'eu-north-1'
12
+
13
+ /** S3/CloudFront origin uses `/media` — flat keys become `media/{resourceId}`. */
14
+ const S3_KEY_PREFIX = 'media/'
15
+
16
+ function normalizeLogicalKey (keyOrShape) {
17
+ const Key = typeof keyOrShape === 'string' ? keyOrShape : keyOrShape?.Key
18
+ assertStorageKey(Key)
19
+ return Key
20
+ }
21
+
22
+ function toS3Key (logicalKey) {
23
+ return `${S3_KEY_PREFIX}${logicalKey}`
24
+ }
25
+
26
+ export function createS3StorageClient ({
27
+ bucket,
28
+ cdnDomainName,
29
+ accessKeyId,
30
+ secretAccessKey,
31
+ region = REGION,
32
+ } = {}) {
33
+ if (!bucket) {
34
+ throw new Error('[S3Storage] bucket is required')
35
+ }
36
+
37
+ function createAwsClient () {
38
+ return new S3({
39
+ region,
40
+ credentials: {
41
+ accessKeyId,
42
+ secretAccessKey,
43
+ },
44
+ })
45
+ }
46
+
47
+ return {
48
+ backend: 's3',
49
+ bucket,
50
+
51
+ createDownloadUrl (keyOrShape) {
52
+ return createResourceReadUrl(normalizeLogicalKey(keyOrShape))
53
+ },
54
+
55
+ createPresignedDownloadUrl (keyOrShape) {
56
+ return Promise.resolve(createResourceReadUrl(normalizeLogicalKey(keyOrShape)))
57
+ },
58
+
59
+ createUploadUrl ({ ContentType, ContentLength, Key, expiresIn = 60 * 60 * 24 }) {
60
+ log.info('[S3Storage][createUploadUrl] Creating upload URL')
61
+
62
+ const clientParams = {
63
+ Bucket: bucket,
64
+ Key: toS3Key(normalizeLogicalKey({ Key })),
65
+ ContentType,
66
+ ContentLength,
67
+ }
68
+
69
+ const s3Client = createAwsClient()
70
+ const signedRequest = new S3RequestPresigner(s3Client.config)
71
+
72
+ return createRequest(s3Client, new PutObjectCommand(clientParams))
73
+ .then(request => signedRequest.presign(request, { expiresIn }))
74
+ .then(formatUrl)
75
+ .catch(error => {
76
+ log.error('S3Storage.createUploadUrl error', undefined, error)
77
+ return Promise.reject(error)
78
+ })
79
+ },
80
+
81
+ headObject ({ Key }) {
82
+ const s3Client = createAwsClient()
83
+ return s3Client.send(new HeadObjectCommand({ Bucket: bucket, Key: toS3Key(normalizeLogicalKey({ Key })) }))
84
+ },
85
+
86
+ async load (keyOrShape) {
87
+ const Key = toS3Key(normalizeLogicalKey(keyOrShape))
88
+ const s3Client = createAwsClient()
89
+ try {
90
+ const response = await s3Client.send(new GetObjectCommand({ Bucket: bucket, Key }))
91
+ const bytes = await response.Body?.transformToByteArray?.()
92
+ if (!bytes) return { buffer: null, exists: false }
93
+ return { buffer: Buffer.from(bytes), exists: true }
94
+ } catch (err) {
95
+ if (err?.$metadata?.httpStatusCode === 404 || err?.name === 'NoSuchKey') {
96
+ return { buffer: null, exists: false }
97
+ }
98
+ throw err
99
+ }
100
+ },
101
+ }
102
+ }
@@ -2,8 +2,9 @@ import { S3, PutObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-
2
2
  import { S3RequestPresigner } from '@aws-sdk/s3-request-presigner'
3
3
  import { createRequest } from '@aws-sdk/util-create-request'
4
4
  import { formatUrl } from '@aws-sdk/util-format-url'
5
- import { ConfigService } from '../config.service.js'
6
5
  import { createLogger } from '@ossy/observability'
6
+ import { createResourceReadUrl } from './resource-read-url.js'
7
+ import { ConfigService } from '../config.service.js'
7
8
 
8
9
  const log = createLogger('storage')
9
10
 
@@ -30,35 +31,18 @@ function normalizeKey(keyOrShape) {
30
31
  export class S3Client {
31
32
 
32
33
  /**
33
- * Public CDN URL (CloudFront) for objects under `media/` use only when `access === 'public'`.
34
+ * App read URL same shape as local filesystem (`/r/{resourceId}`).
34
35
  */
35
36
  static createDownloadUrl(keyOrShape) {
36
37
  const Key = normalizeKey(keyOrShape)
37
- const pathAfterMedia = Key.includes('media/') ? Key.split('media/')[1] : Key
38
- const base = ConfigService.MediaCdnDomainName || ''
39
- return `${base.replace(/\/$/, '')}/${pathAfterMedia}`
38
+ return createResourceReadUrl(Key)
40
39
  }
41
40
 
42
41
  /**
43
- * Short-lived presigned GET for private bucket reads (restricted resources, workspace members).
42
+ * Same as createDownloadUrl auth is enforced by `/r/` route handlers.
44
43
  */
45
- static createPresignedDownloadUrl(keyOrShape, { expiresIn = 15 * 60 } = {}) {
46
- const Key = normalizeKey(keyOrShape)
47
- const Bucket = ConfigService.MediaRepository
48
- if (!Bucket) {
49
- return Promise.reject(new Error('[S3Client] MEDIA_REPOSITORY is not configured'))
50
- }
51
-
52
- const s3Client = createS3Client()
53
- const signedRequest = new S3RequestPresigner(s3Client.config)
54
-
55
- return createRequest(s3Client, new GetObjectCommand({ Bucket, Key }))
56
- .then(request => signedRequest.presign(request, { expiresIn }))
57
- .then(formatUrl)
58
- .catch(error => {
59
- log.error('S3Client.createPresignedDownloadUrl error', undefined, error)
60
- return Promise.reject(error)
61
- })
44
+ static createPresignedDownloadUrl(keyOrShape) {
45
+ return Promise.resolve(S3Client.createDownloadUrl(keyOrShape))
62
46
  }
63
47
 
64
48
  static createUploadUrl({ ContentType, ContentLength, Key, Bucket, expiresIn = 60 * 60 * 24 }) {
@@ -97,4 +81,24 @@ export class S3Client {
97
81
  return s3Client.send(new HeadObjectCommand({ Bucket: bucket, Key }))
98
82
  }
99
83
 
84
+ static async load(keyOrShape) {
85
+ const Key = normalizeKey(keyOrShape)
86
+ const Bucket = ConfigService.MediaRepository
87
+ if (!Bucket) {
88
+ return { buffer: null, exists: false }
89
+ }
90
+ const s3Client = createS3Client()
91
+ try {
92
+ const response = await s3Client.send(new GetObjectCommand({ Bucket, Key }))
93
+ const bytes = await response.Body?.transformToByteArray?.()
94
+ if (!bytes) return { buffer: null, exists: false }
95
+ return { buffer: Buffer.from(bytes), exists: true }
96
+ } catch (err) {
97
+ if (err?.$metadata?.httpStatusCode === 404 || err?.name === 'NoSuchKey') {
98
+ return { buffer: null, exists: false }
99
+ }
100
+ throw err
101
+ }
102
+ }
103
+
100
104
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Flat blob keys — folder hierarchy lives in Mongo resource metadata (`location`), not in storage.
3
+ *
4
+ * Original binary: `{resourceId}`
5
+ * Derivatives (thumbnails, etc.): `{resourceId}:{variant}`
6
+ */
7
+
8
+ const KEY_PATTERN = /^[A-Za-z0-9_-]+(:[A-Za-z0-9_-]+)?$/
9
+
10
+ export function originalObjectKey (resourceId) {
11
+ assertResourceId(resourceId)
12
+ return resourceId
13
+ }
14
+
15
+ export function derivativeObjectKey (resourceId, variant) {
16
+ assertResourceId(resourceId)
17
+ if (!variant || typeof variant !== 'string') {
18
+ throw new Error('[storage-keys] variant is required')
19
+ }
20
+ if (!/^[A-Za-z0-9_-]+$/.test(variant)) {
21
+ throw new Error('[storage-keys] invalid variant')
22
+ }
23
+ return `${resourceId}:${variant}`
24
+ }
25
+
26
+ export function assertStorageKey (key) {
27
+ if (!key || typeof key !== 'string' || !KEY_PATTERN.test(key)) {
28
+ throw new Error(`[storage-keys] invalid storage key: ${key}`)
29
+ }
30
+ return key
31
+ }
32
+
33
+ function assertResourceId (resourceId) {
34
+ if (!resourceId || typeof resourceId !== 'string' || !/^[A-Za-z0-9_-]+$/.test(resourceId)) {
35
+ throw new Error(`[storage-keys] invalid resourceId: ${resourceId}`)
36
+ }
37
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { originalObjectKey, derivativeObjectKey, assertStorageKey } from './storage-keys.js'
3
+
4
+ describe('storage-keys', () => {
5
+ it('originalObjectKey returns flat resourceId', () => {
6
+ expect(originalObjectKey('abc123XYZ')).toBe('abc123XYZ')
7
+ })
8
+
9
+ it('derivativeObjectKey uses resourceId:variant', () => {
10
+ expect(derivativeObjectKey('abc123', 'galleryMedium')).toBe('abc123:galleryMedium')
11
+ })
12
+
13
+ it('rejects path-like keys', () => {
14
+ expect(() => assertStorageKey('media/ws/file')).toThrow()
15
+ })
16
+ })
@@ -1,12 +1,56 @@
1
- import { S3Client } from './s3.client.js'
2
- import { LocalStorageClient } from './local-storage.client.js'
1
+ import { IntegrationService } from '../integration.service.js'
3
2
  import { ConfigService } from '../config.service.js'
3
+ import { createFilesystemStorageClient } from './filesystem-storage.client.js'
4
+ import { createS3StorageClient } from './s3-storage.client.js'
5
+
6
+ /** @type {import('./filesystem-storage.client.js').createFilesystemStorageClient extends (...args: any) => infer R ? R : never | null} */
7
+ let fallbackClient = null
8
+
9
+ function getFallbackClient () {
10
+ if (!fallbackClient) {
11
+ if (ConfigService.MediaRepository && ConfigService.awsAccessKeyId && ConfigService.awsSecretAccessKey) {
12
+ fallbackClient = createS3StorageClient({
13
+ bucket: ConfigService.MediaRepository,
14
+ cdnDomainName: ConfigService.MediaCdnDomainName,
15
+ accessKeyId: ConfigService.awsAccessKeyId,
16
+ secretAccessKey: ConfigService.awsSecretAccessKey,
17
+ })
18
+ } else {
19
+ fallbackClient = createFilesystemStorageClient()
20
+ }
21
+ }
22
+ return fallbackClient
23
+ }
24
+
25
+ function getStorageClient () {
26
+ return IntegrationService.get('storage') ?? getFallbackClient()
27
+ }
28
+
29
+ function bindMethod (name) {
30
+ return (...args) => {
31
+ const client = getStorageClient()
32
+ const fn = client[name]
33
+ if (typeof fn !== 'function') {
34
+ throw new Error(`[StorageClient] storage backend missing method "${name}"`)
35
+ }
36
+ return fn.apply(client, args)
37
+ }
38
+ }
4
39
 
5
40
  /**
6
- * Active storage backend.
7
- *
8
- * - When `MEDIA_REPOSITORY` is set (production): delegates to S3.
9
- * - When `MEDIA_REPOSITORY` is unset (local dev): delegates to LocalStorageClient,
10
- * which stores files in /tmp/ossy-local-media/ and serves them via GET /local-storage.
41
+ * Active primary storage backend (filesystem or S3 via `storage` integration).
11
42
  */
12
- export const StorageClient = ConfigService.MediaRepository ? S3Client : LocalStorageClient
43
+ export const StorageClient = {
44
+ get backend () {
45
+ return getStorageClient().backend
46
+ },
47
+ createUploadUrl: bindMethod('createUploadUrl'),
48
+ createPresignedDownloadUrl: bindMethod('createPresignedDownloadUrl'),
49
+ createDownloadUrl: bindMethod('createDownloadUrl'),
50
+ load: bindMethod('load'),
51
+ headObject: bindMethod('headObject'),
52
+ save: bindMethod('save'),
53
+ }
54
+
55
+ export { LocalStorageClient } from './filesystem-storage.client.js'
56
+ export { S3Client } from './s3.client.js'
@@ -0,0 +1,40 @@
1
+ import { createLogger } from '@ossy/observability'
2
+ import { createFilesystemStorageClient } from './filesystem-storage.client.js'
3
+ import { createS3StorageClient } from './s3-storage.client.js'
4
+
5
+ const log = createLogger('storage')
6
+
7
+ export const id = 'storage'
8
+
9
+ /** Always connect — filesystem when S3 is not configured. */
10
+ export const credentials = []
11
+
12
+ /**
13
+ * Primary blob storage: S3 when MEDIA_REPOSITORY is set, otherwise local filesystem.
14
+ *
15
+ * Logical keys are flat resource ids (see storage-keys.js). Mongo holds folder paths.
16
+ *
17
+ * @param {{ env: NodeJS.ProcessEnv }} opts
18
+ */
19
+ export async function connect ({ env }) {
20
+ const bucket = env.MEDIA_REPOSITORY
21
+
22
+ if (bucket) {
23
+ const missing = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'].filter((key) => !(key in env))
24
+ if (missing.length) {
25
+ log.warn(`[storage] MEDIA_REPOSITORY set but missing ${missing.join(', ')} — using filesystem fallback`)
26
+ } else {
27
+ log.info('[storage] Using S3 backend')
28
+ return createS3StorageClient({
29
+ bucket,
30
+ cdnDomainName: env.MEDIA_CDN_DOMAIN_NAME,
31
+ accessKeyId: env.AWS_ACCESS_KEY_ID,
32
+ secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
33
+ })
34
+ }
35
+ }
36
+
37
+ const client = createFilesystemStorageClient({ env })
38
+ log.info(`[storage] Using filesystem backend at ${client.root}`)
39
+ return client
40
+ }
@@ -1,6 +1,6 @@
1
1
  import { TaskService } from './task-service.js'
2
2
  import { createLogger } from '@ossy/observability'
3
- import { resolveMongoUrl } from '@ossy/event-store'
3
+ import { ProjectionRebuild, PushInvalidation, resolveMongoUrl } from '@ossy/event-store'
4
4
 
5
5
  const log = createLogger('platform')
6
6
 
@@ -17,6 +17,10 @@ export class ChangeStream {
17
17
  static _reconnectAttempts = 0
18
18
  static _dbUrl = null
19
19
  static _client = null
20
+ /** @type {import('mongodb').ChangeStream | null} */
21
+ static _changeStream = null
22
+ /** @type {ReturnType<typeof setTimeout> | null} */
23
+ static _reconnectTimer = null
20
24
 
21
25
  /**
22
26
  * Opens the MongoDB changestream and wires up reconnect logic.
@@ -33,12 +37,35 @@ export class ChangeStream {
33
37
  })
34
38
  }
35
39
 
36
- static stop() {
40
+ static async stop () {
37
41
  ChangeStream._stopped = true
38
- if (ChangeStream._client) {
39
- ChangeStream._client.close().catch(() => {})
40
- ChangeStream._client = null
42
+
43
+ if (ChangeStream._reconnectTimer) {
44
+ clearTimeout(ChangeStream._reconnectTimer)
45
+ ChangeStream._reconnectTimer = null
46
+ }
47
+
48
+ const stream = ChangeStream._changeStream
49
+ ChangeStream._changeStream = null
50
+ if (stream) {
51
+ try {
52
+ await stream.close()
53
+ } catch {
54
+ // ignore — stream may already be closed
55
+ }
56
+ }
57
+
58
+ const client = ChangeStream._client
59
+ ChangeStream._client = null
60
+ if (client) {
61
+ try {
62
+ await client.close()
63
+ } catch {
64
+ // ignore
65
+ }
41
66
  }
67
+
68
+ log.info('[ChangeStream] Stopped')
42
69
  }
43
70
 
44
71
  static async _getMongoClient() {
@@ -46,7 +73,10 @@ export class ChangeStream {
46
73
  return MongoClient
47
74
  }
48
75
 
49
- static async _getClient() {
76
+ static async _getClient () {
77
+ if (ChangeStream._stopped) {
78
+ throw new Error('ChangeStream stopped')
79
+ }
50
80
  if (!ChangeStream._client) {
51
81
  const MongoClient = await ChangeStream._getMongoClient()
52
82
  ChangeStream._client = new MongoClient(ChangeStream._dbUrl, {
@@ -56,7 +86,8 @@ export class ChangeStream {
56
86
  return ChangeStream._client
57
87
  }
58
88
 
59
- static async _resetClient() {
89
+ static async _resetClient () {
90
+ if (ChangeStream._stopped) return
60
91
  const MongoClient = await ChangeStream._getMongoClient()
61
92
  const prev = ChangeStream._client
62
93
  ChangeStream._client = new MongoClient(ChangeStream._dbUrl, {
@@ -68,7 +99,7 @@ export class ChangeStream {
68
99
  log.info('[ChangeStream] New MongoClient instance created')
69
100
  }
70
101
 
71
- static _scheduleReconnect() {
102
+ static _scheduleReconnect () {
72
103
  if (ChangeStream._stopped) return
73
104
 
74
105
  const delaySecs = Math.min(Math.pow(2, ChangeStream._reconnectAttempts), 30)
@@ -76,7 +107,12 @@ export class ChangeStream {
76
107
 
77
108
  log.info(`[ChangeStream] Reconnecting in ${delaySecs}s...`)
78
109
 
79
- setTimeout(() => {
110
+ if (ChangeStream._reconnectTimer) {
111
+ clearTimeout(ChangeStream._reconnectTimer)
112
+ }
113
+
114
+ ChangeStream._reconnectTimer = setTimeout(() => {
115
+ ChangeStream._reconnectTimer = null
80
116
  if (ChangeStream._stopped) return
81
117
  ChangeStream._open().catch((error) => {
82
118
  log.error('[ChangeStream] Change stream could not be opened', undefined, error)
@@ -85,16 +121,30 @@ export class ChangeStream {
85
121
  }, delaySecs * 1000)
86
122
  }
87
123
 
88
- static async _open() {
124
+ static async _open () {
125
+ if (ChangeStream._stopped) return
126
+
89
127
  log.info('[ChangeStream] Watching for changes')
90
128
 
91
129
  const dbName = process.env.DB_NAME || 'test'
92
130
  const client = await ChangeStream._getClient()
131
+ if (ChangeStream._stopped) return
132
+
93
133
  const collection = client.db(dbName).collection('eventstore')
94
134
 
135
+ if (ChangeStream._changeStream) {
136
+ try {
137
+ await ChangeStream._changeStream.close()
138
+ } catch {
139
+ // ignore
140
+ }
141
+ ChangeStream._changeStream = null
142
+ }
143
+
95
144
  const changeStream = collection.watch([
96
145
  { $match: { operationType: 'insert' } },
97
146
  ])
147
+ ChangeStream._changeStream = changeStream
98
148
 
99
149
  const wasReconnecting = ChangeStream._reconnectAttempts > 0
100
150
  ChangeStream._reconnectAttempts = 0
@@ -105,6 +155,7 @@ export class ChangeStream {
105
155
  // Ensures only one reconnect is scheduled if both 'error' and 'close' fire for the same failure.
106
156
  let reconnectScheduled = false
107
157
  const scheduleOnce = () => {
158
+ if (ChangeStream._stopped) return
108
159
  if (!reconnectScheduled) {
109
160
  reconnectScheduled = true
110
161
  ChangeStream._scheduleReconnect()
@@ -112,6 +163,7 @@ export class ChangeStream {
112
163
  }
113
164
 
114
165
  changeStream.on('error', (error) => {
166
+ if (ChangeStream._stopped) return
115
167
  log.error('[ChangeStream] Change stream error (server keeps running)', undefined, error)
116
168
  if (isMongoTopologyClosedError(error)) {
117
169
  ChangeStream._resetClient().catch(() => {})
@@ -121,15 +173,30 @@ export class ChangeStream {
121
173
 
122
174
  changeStream.on('change', (change) => {
123
175
  log.info('[ChangeStream] Change detected')
124
- TaskService.dispatch(change.fullDocument)
176
+ const event = change.fullDocument
177
+ if (event) {
178
+ ProjectionRebuild.dispatch(event).catch((error) => {
179
+ log.error('[ChangeStream] Projection rebuild failed', undefined, error)
180
+ })
181
+ PushInvalidation.publish(event)
182
+ }
183
+ TaskService.dispatch(event)
125
184
  })
126
185
 
127
186
  changeStream.on('close', () => {
187
+ if (ChangeStream._changeStream === changeStream) {
188
+ ChangeStream._changeStream = null
189
+ }
190
+ if (ChangeStream._stopped) return
128
191
  log.info('[ChangeStream] close detected')
129
192
  scheduleOnce()
130
193
  })
131
194
 
132
195
  changeStream.on('end', () => {
196
+ if (ChangeStream._changeStream === changeStream) {
197
+ ChangeStream._changeStream = null
198
+ }
199
+ if (ChangeStream._stopped) return
133
200
  log.info('[ChangeStream] end detected')
134
201
  scheduleOnce()
135
202
  })