@ossy/platform 3.24.1 → 3.26.0

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": "3.24.1",
3
+ "version": "3.26.0",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,18 +49,18 @@
49
49
  "@aws-sdk/util-format-url": "^3.972.17",
50
50
  "@modelcontextprotocol/sdk": "^1.12.1",
51
51
  "@ossy/config": "^3.0.9",
52
- "@ossy/email": "^3.24.1",
53
- "@ossy/event-store": "^3.24.1",
52
+ "@ossy/email": "^3.26.0",
53
+ "@ossy/event-store": "^3.26.0",
54
54
  "@ossy/locale": "^3.4.0",
55
55
  "@ossy/manifest": "^3.24.0",
56
56
  "@ossy/observability": "^3.24.1",
57
57
  "@ossy/policies": "^3.24.1",
58
- "@ossy/router": "^3.0.9",
58
+ "@ossy/router": "^3.25.0",
59
59
  "@ossy/schema": "^3.24.0",
60
- "@ossy/sdk": "^3.24.0",
60
+ "@ossy/sdk": "^3.26.0",
61
61
  "@ossy/tokens": "^3.24.1",
62
62
  "@ossy/users": "^3.24.1",
63
- "@ossy/workspaces": "^3.24.1",
63
+ "@ossy/workspaces": "^3.26.0",
64
64
  "cookie-parser": "^1.4.7",
65
65
  "dotenv": ">=16.0.0 <18.0.0",
66
66
  "express": ">=5.0.0 <6.0.0",
@@ -81,5 +81,5 @@
81
81
  "Dockerfile",
82
82
  "docker-healthcheck.js"
83
83
  ],
84
- "gitHead": "d0e7eda0397239d5e8a8cd5115d9c91959b4103f"
84
+ "gitHead": "0df308a330150a890b9dd4d6ffc2e9f28bd31966"
85
85
  }
package/src/runtime.js CHANGED
@@ -187,7 +187,8 @@ export async function startRuntime ({ port } = {}) {
187
187
  const { language, messages, fallbackMessages } = resolveRequestLocale(pageRouter, requestUrl, buildDir, config)
188
188
  if (typeof pageEntry.redirect === 'string' && pageEntry.redirect.trim()) {
189
189
  const targetId = pageEntry.redirect.trim().replace(/^@/, '')
190
- const targetPath = pageRouter.getPathname({ id: targetId, language })
190
+ const params = pageRouter.getParamsFromUrl(requestUrl)
191
+ const targetPath = pageRouter.getPathname({ id: targetId, language, params })
191
192
  if (targetPath) {
192
193
  const search = new URL(requestUrl, 'http://localhost').search || ''
193
194
  res.redirect(302, `${targetPath}${search}`)
package/src/server.js CHANGED
@@ -530,7 +530,8 @@ export async function startServer (options = {}) {
530
530
  if (typeof pageEntry.redirect === 'string' && pageEntry.redirect.trim()) {
531
531
  const { language } = resolveRequestLocale(pageRouter, requestUrl, buildDir, config)
532
532
  const targetId = pageEntry.redirect.trim().replace(/^@/, '')
533
- const targetPath = pageRouter.getPathname({ id: targetId, language })
533
+ const params = pageRouter.getParamsFromUrl(requestUrl)
534
+ const targetPath = pageRouter.getPathname({ id: targetId, language, params })
534
535
  if (targetPath) {
535
536
  const search = new URL(requestUrl, 'http://localhost').search || ''
536
537
  res.redirect(302, `${targetPath}${search}`)
@@ -1,6 +1,7 @@
1
1
  import { DeleteMessageCommand, ReceiveMessageCommand, SQSClient } from '@aws-sdk/client-sqs'
2
2
  import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
3
3
  import { fileSesReceipt, sesReceiptFromBody } from '@ossy/email/ses-inbound'
4
+ import { canonicalWorkspaceId } from '@ossy/workspaces/server'
4
5
  import { createInProcessSdk } from '../tasks/in-process-sdk.js'
5
6
  import { inboundAwsClientConfig } from './inbound-aws-credentials.js'
6
7
 
@@ -47,9 +48,12 @@ export function startInboundMailPoller ({ queueUrl, bucket, log } = {}) {
47
48
  const file = (body) => fileSesReceipt(sesReceiptFromBody(body), {
48
49
  bucket,
49
50
  getObject: (location) => readObject(objects, location),
50
- invoke: ({ mime, workspaceId }) => createInProcessSdk()
51
- .forWorkspace(workspaceId)
52
- .invoke(ReceiveInbound, { mime, workspaceId }),
51
+ invoke: async ({ mime, workspaceId }) => {
52
+ const canonical = await canonicalWorkspaceId(workspaceId)
53
+ return createInProcessSdk()
54
+ .forWorkspace(canonical)
55
+ .invoke(ReceiveInbound, { mime, workspaceId: canonical })
56
+ },
53
57
  })
54
58
 
55
59
  const run = (async () => {
@@ -2,21 +2,22 @@
2
2
  * Vector embedding store backed by MongoDB.
3
3
  *
4
4
  * Collection: `resource_embeddings`
5
- * Schema: { resourceId, workspaceId, model, dimensions, vector, textSource, textLength, updatedAt }
5
+ * Schema: { resourceId, workspaceId, kind, model, dimensions, vector, textSource, textLength, textHash, updatedAt }
6
6
  *
7
7
  * Search strategy:
8
8
  * - If Atlas Vector Search index `resource_embedding_vector` is present → $vectorSearch
9
- * - Otherwise → in-memory cosine similarity (works fine for <10k resources per workspace)
9
+ * - Otherwise → cosine similarity over the workspace (and kind, when set)
10
10
  *
11
- * Atlas Vector Search index definition (create via Atlas UI or Admin API):
11
+ * `workspaceId` and `kind` must be filter fields on that index. A filter on a
12
+ * field the index does not declare fails the query, and search falls back to
13
+ * the in-memory scan. Atlas index definition:
12
14
  * ```json
13
15
  * {
14
- * "mappings": {
15
- * "dynamic": false,
16
- * "fields": {
17
- * "vector": { "type": "knnVector", "dimensions": 1536, "similarity": "cosine" }
18
- * }
19
- * }
16
+ * "fields": [
17
+ * { "type": "vector", "path": "vector", "numDimensions": 1536, "similarity": "cosine" },
18
+ * { "type": "filter", "path": "workspaceId" },
19
+ * { "type": "filter", "path": "kind" }
20
+ * ]
20
21
  * }
21
22
  * ```
22
23
  */
@@ -67,6 +68,8 @@ function col () {
67
68
  * vector: number[],
68
69
  * textSource: string,
69
70
  * textLength: number,
71
+ * kind?: string,
72
+ * textHash?: string,
70
73
  * }} doc
71
74
  */
72
75
  export async function upsertEmbedding (doc) {
@@ -97,6 +100,43 @@ export async function deleteEmbedding (resourceId) {
97
100
  await col().deleteOne({ resourceId })
98
101
  }
99
102
 
103
+ /**
104
+ * Filter for `$vectorSearch` and the in-memory fallback.
105
+ * `kind` keeps a request vector from matching other requests.
106
+ *
107
+ * Atlas `$vectorSearch` filters do not support `$exists`, so the Atlas query
108
+ * matches `kind` exactly. A changed document writes `kind` when it is embedded
109
+ * or scored. The in-memory fallback can include legacy rows that predate the field.
110
+ *
111
+ * @param {{ workspaceId?: string, kind?: string, includeMissingKind?: boolean }} [scope]
112
+ */
113
+ export function embeddingSearchFilter ({ workspaceId, kind, includeMissingKind = false } = {}) {
114
+ const filter = {}
115
+ if (workspaceId) filter.workspaceId = workspaceId
116
+ if (!kind) return filter
117
+ if (!includeMissingKind) {
118
+ filter.kind = kind
119
+ return filter
120
+ }
121
+ filter.$or = [
122
+ { kind },
123
+ { kind: { $exists: false } },
124
+ { kind: null },
125
+ ]
126
+ return filter
127
+ }
128
+
129
+ /**
130
+ * Record what a stored vector represents without rewriting the vector.
131
+ *
132
+ * @param {string} resourceId
133
+ * @param {string} kind
134
+ */
135
+ export async function setEmbeddingKind (resourceId, kind) {
136
+ if (!resourceId || !kind) return
137
+ await col().updateOne({ resourceId }, { $set: { kind, updatedAt: new Date() } })
138
+ }
139
+
100
140
  /**
101
141
  * Semantic search within a workspace using cosine similarity.
102
142
  * Tries Atlas $vectorSearch first; falls back to in-memory similarity.
@@ -106,11 +146,16 @@ export async function deleteEmbedding (resourceId) {
106
146
  * queryVector: number[],
107
147
  * limit?: number,
108
148
  * threshold?: number,
149
+ * kind?: string,
109
150
  * }} opts
110
151
  * @returns {Promise<Array<{ resourceId: string, score: number }>>}
111
152
  */
112
- export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20, threshold = 0 }) {
153
+ export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20, threshold = 0, kind } = {}) {
154
+ if (!workspaceId || !Array.isArray(queryVector) || !queryVector.length) return []
155
+ const filter = embeddingSearchFilter({ workspaceId, kind })
156
+ const capped = Math.max(1, limit)
113
157
  // Try Atlas Vector Search (requires index on Atlas M10+).
158
+ // An empty result is the index answer. A missing index throws and falls back.
114
159
  try {
115
160
  const pipeline = [
116
161
  {
@@ -118,9 +163,9 @@ export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20,
118
163
  index: ATLAS_INDEX,
119
164
  path: 'vector',
120
165
  queryVector,
121
- numCandidates: limit * 10,
122
- limit,
123
- filter: { workspaceId },
166
+ numCandidates: capped * 10,
167
+ limit: capped,
168
+ filter,
124
169
  },
125
170
  },
126
171
  {
@@ -132,17 +177,14 @@ export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20,
132
177
  },
133
178
  ]
134
179
  const results = await col().aggregate(pipeline).toArray()
135
- if (results.length > 0) {
136
- return results.filter((r) => r.score >= threshold)
137
- }
180
+ return results.filter((r) => r.score >= threshold)
138
181
  } catch {
139
182
  // Atlas Vector Search not available — fall through to cosine similarity.
140
183
  }
141
184
 
142
- // In-memory cosine similarity fallback.
143
185
  log.debug('[VectorStore] Atlas $vectorSearch unavailable — using in-memory cosine similarity')
144
186
  const docs = await col()
145
- .find({ workspaceId })
187
+ .find(embeddingSearchFilter({ workspaceId, kind, includeMissingKind: true }))
146
188
  .project({ resourceId: 1, vector: 1 })
147
189
  .toArray()
148
190
 
@@ -153,7 +195,7 @@ export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20,
153
195
  }))
154
196
  .filter((r) => r.score >= threshold)
155
197
  .sort((a, b) => b.score - a.score)
156
- .slice(0, limit)
198
+ .slice(0, capped)
157
199
  }
158
200
 
159
201
  /**
@@ -178,5 +220,6 @@ export async function ensureEmbeddingIndexes () {
178
220
  await c.createIndex({ resourceId: 1 }, { name: 'resourceId', unique: true, background: true })
179
221
  await c.createIndex({ workspaceId: 1 }, { name: 'workspaceId', background: true })
180
222
  await c.createIndex({ workspaceId: 1, updatedAt: -1 }, { name: 'workspaceId_updatedAt', background: true })
223
+ await c.createIndex({ workspaceId: 1, kind: 1 }, { name: 'workspaceId_kind', background: true })
181
224
  log.info('[VectorStore] resource_embeddings indexes ensured')
182
225
  }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it, jest } from '@jest/globals'
2
+
3
+ jest.unstable_mockModule('@ossy/observability', () => ({
4
+ createLogger: () => ({ debug () {}, info () {}, warn () {}, error () {} }),
5
+ }))
6
+
7
+ jest.unstable_mockModule('@ossy/event-store', () => ({
8
+ Mongo: { db: { collection () { return {} } } },
9
+ }))
10
+
11
+ const { embeddingSearchFilter } = await import('./vector-store.js')
12
+
13
+ describe('embeddingSearchFilter', () => {
14
+ it('scopes by workspace and kind for Atlas-compatible filters', () => {
15
+ expect(embeddingSearchFilter({ workspaceId: 'ws-1', kind: 'resume' })).toEqual({
16
+ workspaceId: 'ws-1',
17
+ kind: 'resume',
18
+ })
19
+ })
20
+
21
+ it('includes legacy rows without kind for the in-memory fallback', () => {
22
+ expect(embeddingSearchFilter({
23
+ workspaceId: 'ws-1',
24
+ kind: 'resume',
25
+ includeMissingKind: true,
26
+ })).toEqual({
27
+ workspaceId: 'ws-1',
28
+ $or: [
29
+ { kind: 'resume' },
30
+ { kind: { $exists: false } },
31
+ { kind: null },
32
+ ],
33
+ })
34
+ })
35
+ })