@realtimex/folio 0.1.15 → 0.1.17

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 (50) hide show
  1. package/api/src/middleware/auth.ts +77 -0
  2. package/api/src/routes/chat.ts +7 -1
  3. package/api/src/routes/index.ts +2 -0
  4. package/api/src/routes/ingestions.ts +51 -6
  5. package/api/src/routes/policies.ts +50 -7
  6. package/api/src/routes/stats.ts +9 -5
  7. package/api/src/routes/workspaces.ts +290 -0
  8. package/api/src/services/ChatService.ts +8 -2
  9. package/api/src/services/IngestionService.ts +38 -26
  10. package/api/src/services/PolicyEngine.ts +4 -1
  11. package/api/src/services/PolicyLearningService.ts +31 -6
  12. package/api/src/services/PolicyLoader.ts +44 -25
  13. package/api/src/services/RAGService.ts +52 -12
  14. package/api/src/services/SDKService.ts +48 -2
  15. package/dist/api/src/middleware/auth.js +59 -0
  16. package/dist/api/src/routes/chat.js +1 -1
  17. package/dist/api/src/routes/index.js +2 -0
  18. package/dist/api/src/routes/ingestions.js +51 -9
  19. package/dist/api/src/routes/policies.js +49 -7
  20. package/dist/api/src/routes/stats.js +9 -5
  21. package/dist/api/src/routes/workspaces.js +220 -0
  22. package/dist/api/src/services/ChatService.js +7 -2
  23. package/dist/api/src/services/IngestionService.js +35 -30
  24. package/dist/api/src/services/PolicyEngine.js +2 -1
  25. package/dist/api/src/services/PolicyLearningService.js +28 -6
  26. package/dist/api/src/services/PolicyLoader.js +29 -25
  27. package/dist/api/src/services/RAGService.js +43 -11
  28. package/dist/api/src/services/SDKService.js +37 -2
  29. package/dist/assets/index-CTn5FcC4.js +113 -0
  30. package/dist/assets/index-Dq9sxoZK.css +1 -0
  31. package/dist/index.html +2 -2
  32. package/docs-dev/ingestion-engine.md +3 -3
  33. package/package.json +1 -1
  34. package/supabase/functions/workspace-invite/index.ts +110 -0
  35. package/supabase/migrations/20260223000000_initial_foundation.sql +5 -0
  36. package/supabase/migrations/20260224000004_add_avatars_storage.sql +4 -0
  37. package/supabase/migrations/20260224000006_add_policies_table.sql +5 -0
  38. package/supabase/migrations/20260224000008_add_ingestions_table.sql +2 -0
  39. package/supabase/migrations/20260225000000_setup_compatible_mode.sql +17 -4
  40. package/supabase/migrations/20260225000003_add_baseline_configs.sql +4 -3
  41. package/supabase/migrations/20260226000000_add_processing_events.sql +1 -0
  42. package/supabase/migrations/20260226000002_add_dynamic_rag.sql +1 -0
  43. package/supabase/migrations/20260226000005_add_chat_tables.sql +3 -0
  44. package/supabase/migrations/20260228000001_add_policy_match_feedback.sql +4 -0
  45. package/supabase/migrations/20260302064608_add_ingestion_llm_settings_compat.sql +15 -0
  46. package/supabase/migrations/20260303000000_add_workspaces_phase1.sql +459 -0
  47. package/supabase/migrations/20260303010000_add_workspace_management_rpc.sql +310 -0
  48. package/supabase/migrations/20260303020000_workspace_scope_document_chunks.sql +139 -0
  49. package/dist/assets/index-Cj989Mcp.js +0 -113
  50. package/dist/assets/index-DzN8-j-e.css +0 -1
@@ -161,7 +161,7 @@ export class IngestionService {
161
161
  action: "Queued synthetic VLM embedding",
162
162
  ...details,
163
163
  }, opts.supabase);
164
- RAGService.chunkAndEmbed(opts.ingestionId, opts.userId, syntheticText, opts.supabase, opts.embedSettings).then(() => {
164
+ RAGService.chunkAndEmbed(opts.ingestionId, opts.userId, syntheticText, opts.supabase, opts.embedSettings, opts.workspaceId).then(() => {
165
165
  Actuator.logEvent(opts.ingestionId, opts.userId, "analysis", "RAG Embedding", {
166
166
  action: "Completed synthetic VLM embedding",
167
167
  ...details,
@@ -257,13 +257,13 @@ export class IngestionService {
257
257
  * Ingest a document using Hybrid Routing Architecture.
258
258
  */
259
259
  static async ingest(opts) {
260
- const { supabase, userId, filename, mimeType, fileSize, source = "upload", filePath, content, fileHash } = opts;
260
+ const { supabase, userId, workspaceId, filename, mimeType, fileSize, source = "upload", filePath, content, fileHash } = opts;
261
261
  // Duplicate detection — check if this exact file content was already ingested
262
262
  if (fileHash) {
263
263
  const { data: existing } = await supabase
264
264
  .from("ingestions")
265
265
  .select("id, filename, created_at")
266
- .eq("user_id", userId)
266
+ .eq("workspace_id", workspaceId)
267
267
  .eq("file_hash", fileHash)
268
268
  .eq("status", "matched")
269
269
  .order("created_at", { ascending: true })
@@ -274,6 +274,7 @@ export class IngestionService {
274
274
  const { data: dupIngestion } = await supabase
275
275
  .from("ingestions")
276
276
  .insert({
277
+ workspace_id: workspaceId,
277
278
  user_id: userId,
278
279
  source,
279
280
  filename,
@@ -293,6 +294,7 @@ export class IngestionService {
293
294
  const { data: ingestion, error: insertErr } = await supabase
294
295
  .from("ingestions")
295
296
  .insert({
297
+ workspace_id: workspaceId,
296
298
  user_id: userId,
297
299
  source,
298
300
  filename,
@@ -397,7 +399,7 @@ export class IngestionService {
397
399
  try {
398
400
  // 3. Fast Path — fetch all dependencies in parallel
399
401
  const [userPolicies, processingSettingsRow, baselineConfig] = await Promise.all([
400
- PolicyLoader.load(false, supabase),
402
+ PolicyLoader.load(false, supabase, workspaceId),
401
403
  supabase.from("user_settings").select("llm_provider, llm_model, ingestion_llm_provider, ingestion_llm_model, embedding_provider, embedding_model").eq("user_id", userId).maybeSingle(),
402
404
  BaselineConfigService.getActive(supabase, userId),
403
405
  ]);
@@ -409,11 +411,11 @@ export class IngestionService {
409
411
  const resolvedProvider = llmSettings.llm_provider ?? llmProvider;
410
412
  const resolvedModel = llmSettings.llm_model ?? llmModel;
411
413
  const runFastPathAttempt = async (attemptContent, attemptType) => {
412
- const doc = { filePath: filePath, text: attemptContent, ingestionId: ingestion.id, userId, supabase };
414
+ const doc = { filePath: filePath, text: attemptContent, ingestionId: ingestion.id, userId, workspaceId, supabase };
413
415
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
414
416
  const baselineTrace = [];
415
417
  // Fire and forget Semantic Embedding Storage
416
- RAGService.chunkAndEmbed(ingestion.id, userId, doc.text, supabase, embedSettings).catch(err => {
418
+ RAGService.chunkAndEmbed(ingestion.id, userId, doc.text, supabase, embedSettings, workspaceId).catch(err => {
417
419
  logger.error(`RAG embedding failed for ${ingestion.id}`, err);
418
420
  });
419
421
  // 4. Stage 1: Baseline extraction (always runs, LLM call 1 of max 2)
@@ -482,6 +484,7 @@ export class IngestionService {
482
484
  const embeddingMeta = this.queueVlmSemanticEmbedding({
483
485
  ingestionId: ingestion.id,
484
486
  userId,
487
+ workspaceId,
485
488
  filename,
486
489
  finalStatus,
487
490
  policyName,
@@ -646,12 +649,12 @@ export class IngestionService {
646
649
  /**
647
650
  * Re-run an existing ingestion
648
651
  */
649
- static async rerun(ingestionId, supabase, userId, opts = {}) {
652
+ static async rerun(ingestionId, supabase, userId, workspaceId, opts = {}) {
650
653
  const { data: ingestion, error } = await supabase
651
654
  .from("ingestions")
652
655
  .select("*")
653
656
  .eq("id", ingestionId)
654
- .eq("user_id", userId)
657
+ .eq("workspace_id", workspaceId)
655
658
  .single();
656
659
  if (error || !ingestion)
657
660
  throw new Error("Ingestion not found");
@@ -747,7 +750,7 @@ export class IngestionService {
747
750
  }
748
751
  if (isFastPath) {
749
752
  const [userPolicies, processingSettingsRow, baselineConfig] = await Promise.all([
750
- PolicyLoader.load(false, supabase),
753
+ PolicyLoader.load(false, supabase, workspaceId),
751
754
  supabase.from("user_settings").select("llm_provider, llm_model, ingestion_llm_provider, ingestion_llm_model, embedding_provider, embedding_model").eq("user_id", userId).maybeSingle(),
752
755
  BaselineConfigService.getActive(supabase, userId),
753
756
  ]);
@@ -759,11 +762,11 @@ export class IngestionService {
759
762
  const resolvedProvider = llmSettings.llm_provider ?? llmProvider;
760
763
  const resolvedModel = llmSettings.llm_model ?? llmModel;
761
764
  const runFastPathAttempt = async (attemptContent, attemptType) => {
762
- const doc = { filePath, text: attemptContent, ingestionId, userId, supabase };
765
+ const doc = { filePath, text: attemptContent, ingestionId, userId, workspaceId, supabase };
763
766
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
764
767
  const baselineTrace = [];
765
768
  // Fire and forget Semantic Embedding Storage for re-runs
766
- RAGService.chunkAndEmbed(ingestionId, userId, doc.text, supabase, embedSettings).catch(err => {
769
+ RAGService.chunkAndEmbed(ingestionId, userId, doc.text, supabase, embedSettings, workspaceId).catch(err => {
767
770
  logger.error(`RAG embedding failed during rerun for ${ingestionId}`, err);
768
771
  });
769
772
  baselineTrace.push({
@@ -842,6 +845,7 @@ export class IngestionService {
842
845
  const embeddingMeta = this.queueVlmSemanticEmbedding({
843
846
  ingestionId,
844
847
  userId,
848
+ workspaceId,
845
849
  filename,
846
850
  finalStatus,
847
851
  policyName,
@@ -982,7 +986,7 @@ export class IngestionService {
982
986
  * Manually assign an ingestion to a policy and optionally persist it as
983
987
  * learning feedback for future automatic matching.
984
988
  */
985
- static async matchToPolicy(ingestionId, policyId, supabase, userId, opts = {}) {
989
+ static async matchToPolicy(ingestionId, policyId, supabase, userId, workspaceId, opts = {}) {
986
990
  const learn = opts.learn !== false;
987
991
  const rerun = opts.rerun !== false;
988
992
  const allowSideEffects = opts.allowSideEffects === true;
@@ -994,7 +998,7 @@ export class IngestionService {
994
998
  .from("ingestions")
995
999
  .select("*")
996
1000
  .eq("id", ingestionId)
997
- .eq("user_id", userId)
1001
+ .eq("workspace_id", workspaceId)
998
1002
  .single();
999
1003
  if (ingestionError || !ingestion) {
1000
1004
  throw new Error("Ingestion not found");
@@ -1002,7 +1006,7 @@ export class IngestionService {
1002
1006
  if (ingestion.status === "processing" || ingestion.status === "pending") {
1003
1007
  throw new Error("Cannot manually match while ingestion is still processing");
1004
1008
  }
1005
- const policies = await PolicyLoader.load(false, supabase);
1009
+ const policies = await PolicyLoader.load(false, supabase, workspaceId);
1006
1010
  const policy = policies.find((item) => item.metadata.id === normalizedPolicyId);
1007
1011
  if (!policy) {
1008
1012
  throw new Error(`Policy "${normalizedPolicyId}" was not found or is disabled.`);
@@ -1021,8 +1025,8 @@ export class IngestionService {
1021
1025
  learn,
1022
1026
  risky_actions: riskyActions,
1023
1027
  }, supabase);
1024
- await this.rerun(ingestionId, supabase, userId, { forcedPolicyId: policy.metadata.id });
1025
- const refreshed = await this.get(ingestionId, supabase, userId);
1028
+ await this.rerun(ingestionId, supabase, userId, workspaceId, { forcedPolicyId: policy.metadata.id });
1029
+ const refreshed = await this.get(ingestionId, supabase, workspaceId);
1026
1030
  if (!refreshed) {
1027
1031
  throw new Error("Ingestion not found after rerun.");
1028
1032
  }
@@ -1052,7 +1056,7 @@ export class IngestionService {
1052
1056
  trace: nextTrace,
1053
1057
  })
1054
1058
  .eq("id", ingestionId)
1055
- .eq("user_id", userId)
1059
+ .eq("workspace_id", workspaceId)
1056
1060
  .select("*")
1057
1061
  .single();
1058
1062
  if (updateError || !updatedIngestion) {
@@ -1071,6 +1075,7 @@ export class IngestionService {
1071
1075
  await PolicyLearningService.recordManualMatch({
1072
1076
  supabase,
1073
1077
  userId,
1078
+ workspaceId,
1074
1079
  ingestion: effectiveIngestion,
1075
1080
  policyId: policy.metadata.id,
1076
1081
  policyName: policy.metadata.name,
@@ -1082,7 +1087,7 @@ export class IngestionService {
1082
1087
  * Generate a user-reviewable refinement draft for an existing policy
1083
1088
  * using evidence from a specific ingestion.
1084
1089
  */
1085
- static async suggestPolicyRefinement(ingestionId, policyId, supabase, userId, opts = {}) {
1090
+ static async suggestPolicyRefinement(ingestionId, policyId, supabase, userId, workspaceId, opts = {}) {
1086
1091
  const normalizedPolicyId = policyId.trim();
1087
1092
  if (!normalizedPolicyId) {
1088
1093
  throw new Error("policy_id is required");
@@ -1091,12 +1096,12 @@ export class IngestionService {
1091
1096
  .from("ingestions")
1092
1097
  .select("id,filename,mime_type,status,tags,summary,extracted,trace")
1093
1098
  .eq("id", ingestionId)
1094
- .eq("user_id", userId)
1099
+ .eq("workspace_id", workspaceId)
1095
1100
  .single();
1096
1101
  if (ingestionError || !ingestion) {
1097
1102
  throw new Error("Ingestion not found");
1098
1103
  }
1099
- const policies = await PolicyLoader.load(false, supabase);
1104
+ const policies = await PolicyLoader.load(false, supabase, workspaceId);
1100
1105
  const targetPolicy = policies.find((policy) => policy.metadata.id === normalizedPolicyId);
1101
1106
  if (!targetPolicy) {
1102
1107
  throw new Error(`Policy "${normalizedPolicyId}" was not found or is disabled.`);
@@ -1125,19 +1130,19 @@ export class IngestionService {
1125
1130
  };
1126
1131
  }
1127
1132
  /**
1128
- * List ingestions for a user, newest first.
1133
+ * List ingestions for an active workspace, newest first.
1129
1134
  * Supports server-side pagination and ILIKE search across native text columns
1130
1135
  * (filename, policy_name, summary). Tags are handled client-side via the
1131
1136
  * filter bar; extracted JSONB search requires a tsvector migration (deferred).
1132
1137
  */
1133
- static async list(supabase, userId, opts = {}) {
1138
+ static async list(supabase, workspaceId, opts = {}) {
1134
1139
  const { page = 1, pageSize = 20, query } = opts;
1135
1140
  const from = (page - 1) * pageSize;
1136
1141
  const to = from + pageSize - 1;
1137
1142
  let q = supabase
1138
1143
  .from("ingestions")
1139
1144
  .select("*", { count: "exact" })
1140
- .eq("user_id", userId)
1145
+ .eq("workspace_id", workspaceId)
1141
1146
  .order("created_at", { ascending: false });
1142
1147
  if (query?.trim()) {
1143
1148
  const term = `%${query.trim()}%`;
@@ -1156,24 +1161,24 @@ export class IngestionService {
1156
1161
  /**
1157
1162
  * Get a single ingestion by ID.
1158
1163
  */
1159
- static async get(id, supabase, userId) {
1164
+ static async get(id, supabase, workspaceId) {
1160
1165
  const { data } = await supabase
1161
1166
  .from("ingestions")
1162
1167
  .select("*")
1163
1168
  .eq("id", id)
1164
- .eq("user_id", userId)
1169
+ .eq("workspace_id", workspaceId)
1165
1170
  .single();
1166
1171
  return data;
1167
1172
  }
1168
1173
  /**
1169
1174
  * Delete an ingestion record.
1170
1175
  */
1171
- static async delete(id, supabase, userId) {
1176
+ static async delete(id, supabase, workspaceId) {
1172
1177
  const { count, error } = await supabase
1173
1178
  .from("ingestions")
1174
1179
  .delete({ count: "exact" })
1175
1180
  .eq("id", id)
1176
- .eq("user_id", userId);
1181
+ .eq("workspace_id", workspaceId);
1177
1182
  if (error)
1178
1183
  throw new Error(`Failed to delete ingestion: ${error.message}`);
1179
1184
  return (count ?? 0) > 0;
@@ -1183,12 +1188,12 @@ export class IngestionService {
1183
1188
  * Builds the prompt from already-extracted entities — no file I/O needed.
1184
1189
  * The result is saved back to ingestion.summary so subsequent calls are instant.
1185
1190
  */
1186
- static async summarize(id, supabase, userId, llmSettings = {}) {
1191
+ static async summarize(id, supabase, userId, workspaceId, llmSettings = {}) {
1187
1192
  const { data: ing } = await supabase
1188
1193
  .from("ingestions")
1189
1194
  .select("id, filename, extracted, summary, status")
1190
1195
  .eq("id", id)
1191
- .eq("user_id", userId)
1196
+ .eq("workspace_id", workspaceId)
1192
1197
  .single();
1193
1198
  if (!ing)
1194
1199
  throw new Error("Ingestion not found");
@@ -1244,7 +1249,7 @@ export class IngestionService {
1244
1249
  .from("ingestions")
1245
1250
  .update({ summary })
1246
1251
  .eq("id", id)
1247
- .eq("user_id", userId);
1252
+ .eq("workspace_id", workspaceId);
1248
1253
  logger.info(`Summary generated and cached for ingestion ${id}`);
1249
1254
  return summary;
1250
1255
  }
@@ -767,7 +767,7 @@ export class PolicyEngine {
767
767
  */
768
768
  static async process(doc, settings = {}, baselineEntities = {}) {
769
769
  logger.info(`Processing document: ${doc.filePath}`);
770
- const policies = await PolicyLoader.load();
770
+ const policies = await PolicyLoader.load(false, doc.supabase, doc.workspaceId);
771
771
  const globalTrace = [{ timestamp: new Date().toISOString(), step: "Loaded policies", details: { count: policies.length } }];
772
772
  Actuator.logEvent(doc.ingestionId, doc.userId, "info", "Triage", { action: "Loaded policies", count: policies.length }, doc.supabase);
773
773
  for (const policy of policies) {
@@ -841,6 +841,7 @@ export class PolicyEngine {
841
841
  const learned = await PolicyLearningService.resolveLearnedCandidate({
842
842
  supabase: doc.supabase,
843
843
  userId: doc.userId,
844
+ workspaceId: doc.workspaceId,
844
845
  policyIds: policies.map((policy) => policy.metadata.id),
845
846
  filePath: doc.filePath,
846
847
  baselineEntities,
@@ -182,7 +182,14 @@ function buildFromIngestionRow(ingestion) {
182
182
  }
183
183
  export class PolicyLearningService {
184
184
  static async recordManualMatch(opts) {
185
- const { supabase, userId, ingestion, policyId, policyName } = opts;
185
+ const { supabase, userId, workspaceId, ingestion, policyId, policyName } = opts;
186
+ if (!workspaceId) {
187
+ logger.warn("Skipping policy learning feedback: missing workspace context", {
188
+ ingestionId: ingestion.id,
189
+ policyId,
190
+ });
191
+ return;
192
+ }
186
193
  const features = buildFromIngestionRow(ingestion);
187
194
  if (features.tokens.length === 0) {
188
195
  logger.warn("Skipping policy learning feedback: no usable tokens", {
@@ -192,6 +199,7 @@ export class PolicyLearningService {
192
199
  return;
193
200
  }
194
201
  const row = {
202
+ workspace_id: workspaceId,
195
203
  user_id: userId,
196
204
  ingestion_id: ingestion.id,
197
205
  policy_id: policyId,
@@ -201,7 +209,7 @@ export class PolicyLearningService {
201
209
  };
202
210
  const { error } = await supabase
203
211
  .from("policy_match_feedback")
204
- .upsert(row, { onConflict: "user_id,ingestion_id,policy_id" });
212
+ .upsert(row, { onConflict: "workspace_id,ingestion_id,policy_id" });
205
213
  if (error) {
206
214
  logger.error("Failed to save policy match feedback", {
207
215
  ingestionId: ingestion.id,
@@ -217,12 +225,15 @@ export class PolicyLearningService {
217
225
  });
218
226
  }
219
227
  static async getPolicyLearningStats(opts) {
220
- const { supabase, userId } = opts;
228
+ const { supabase, userId, workspaceId } = opts;
229
+ if (!workspaceId) {
230
+ return {};
231
+ }
221
232
  const normalizedPolicyIds = (opts.policyIds ?? []).map((id) => id.trim()).filter(Boolean);
222
233
  let query = supabase
223
234
  .from("policy_match_feedback")
224
235
  .select("policy_id,created_at")
225
- .eq("user_id", userId)
236
+ .eq("workspace_id", workspaceId)
226
237
  .order("created_at", { ascending: false })
227
238
  .limit(5000);
228
239
  if (normalizedPolicyIds.length > 0) {
@@ -251,7 +262,7 @@ export class PolicyLearningService {
251
262
  return stats;
252
263
  }
253
264
  static async resolveLearnedCandidate(opts) {
254
- const { supabase, userId, policyIds, filePath, baselineEntities, documentText } = opts;
265
+ const { supabase, userId, workspaceId, policyIds, filePath, baselineEntities, documentText } = opts;
255
266
  if (policyIds.length === 0) {
256
267
  return {
257
268
  candidate: null,
@@ -263,6 +274,17 @@ export class PolicyLearningService {
263
274
  },
264
275
  };
265
276
  }
277
+ if (!workspaceId) {
278
+ return {
279
+ candidate: null,
280
+ diagnostics: {
281
+ reason: "no_feedback_samples",
282
+ evaluatedPolicies: policyIds.length,
283
+ evaluatedSamples: 0,
284
+ topCandidates: [],
285
+ },
286
+ };
287
+ }
266
288
  const docFeatures = buildFromDocInput({ filePath, baselineEntities, documentText });
267
289
  if (docFeatures.tokens.length === 0) {
268
290
  return {
@@ -278,7 +300,7 @@ export class PolicyLearningService {
278
300
  const { data, error } = await supabase
279
301
  .from("policy_match_feedback")
280
302
  .select("policy_id,policy_name,features")
281
- .eq("user_id", userId)
303
+ .eq("workspace_id", workspaceId)
282
304
  .in("policy_id", policyIds)
283
305
  .order("created_at", { ascending: false })
284
306
  .limit(400);
@@ -1,7 +1,7 @@
1
1
  import { createLogger } from "../utils/logger.js";
2
2
  const logger = createLogger("PolicyLoader");
3
3
  // ─── Cache ───────────────────────────────────────────────────────────────────
4
- // Keyed by user_id so one user's policies never bleed into another's.
4
+ // Keyed by workspace_id so one workspace's policies never bleed into another's.
5
5
  const _cache = new Map();
6
6
  const CACHE_TTL_MS = 30_000;
7
7
  // ─── Row → Policy ────────────────────────────────────────────────────────────
@@ -22,19 +22,21 @@ function rowToPolicy(row) {
22
22
  // ─── PolicyLoader ────────────────────────────────────────────────────────────
23
23
  export class PolicyLoader {
24
24
  /**
25
- * Load all policies for the authenticated user from Supabase.
25
+ * Load all policies for the active workspace from Supabase.
26
26
  * Returns [] if no Supabase client is provided (unauthenticated state).
27
27
  */
28
- static async load(forceRefresh = false, supabase) {
28
+ static async load(forceRefresh = false, supabase, workspaceId) {
29
29
  if (!supabase) {
30
30
  logger.info("No Supabase client — policies require authentication");
31
31
  return [];
32
32
  }
33
- // Resolve the user ID to scope the cache correctly
34
- const { data: { user } } = await supabase.auth.getUser();
35
- const userId = user?.id ?? "anonymous";
33
+ const resolvedWorkspaceId = (workspaceId ?? "").trim();
34
+ if (!resolvedWorkspaceId) {
35
+ logger.warn("No workspace context returning empty policy set");
36
+ return [];
37
+ }
36
38
  const now = Date.now();
37
- const cached = _cache.get(userId);
39
+ const cached = _cache.get(resolvedWorkspaceId);
38
40
  if (!forceRefresh && cached && now - cached.loadedAt < CACHE_TTL_MS) {
39
41
  return cached.policies;
40
42
  }
@@ -42,13 +44,14 @@ export class PolicyLoader {
42
44
  const { data, error } = await supabase
43
45
  .from("policies")
44
46
  .select("*")
47
+ .eq("workspace_id", resolvedWorkspaceId)
45
48
  .eq("enabled", true)
46
49
  .order("priority", { ascending: false });
47
50
  if (error)
48
51
  throw error;
49
52
  const policies = (data ?? []).map(rowToPolicy);
50
- _cache.set(userId, { policies, loadedAt: Date.now() });
51
- logger.info(`Loaded ${policies.length} policies from DB for user ${userId}`);
53
+ _cache.set(resolvedWorkspaceId, { policies, loadedAt: Date.now() });
54
+ logger.info(`Loaded ${policies.length} policies from DB for workspace ${resolvedWorkspaceId}`);
52
55
  return policies;
53
56
  }
54
57
  catch (err) {
@@ -56,9 +59,9 @@ export class PolicyLoader {
56
59
  return [];
57
60
  }
58
61
  }
59
- static invalidateCache(userId) {
60
- if (userId) {
61
- _cache.delete(userId);
62
+ static invalidateCache(workspaceId) {
63
+ if (workspaceId) {
64
+ _cache.delete(workspaceId);
62
65
  }
63
66
  else {
64
67
  _cache.clear();
@@ -78,11 +81,12 @@ export class PolicyLoader {
78
81
  * Save (upsert) a policy to Supabase.
79
82
  * Throws if no Supabase client is available.
80
83
  */
81
- static async save(policy, supabase, userId) {
82
- if (!supabase || !userId) {
84
+ static async save(policy, supabase, userId, workspaceId) {
85
+ if (!supabase || !userId || !workspaceId) {
83
86
  throw new Error("Authentication required to save policies");
84
87
  }
85
88
  const row = {
89
+ workspace_id: workspaceId,
86
90
  user_id: userId,
87
91
  policy_id: policy.metadata.id,
88
92
  api_version: policy.apiVersion,
@@ -94,25 +98,25 @@ export class PolicyLoader {
94
98
  };
95
99
  const { error } = await supabase
96
100
  .from("policies")
97
- .upsert(row, { onConflict: "user_id,policy_id" });
101
+ .upsert(row, { onConflict: "workspace_id,policy_id" });
98
102
  if (error)
99
103
  throw new Error(`Failed to save policy: ${error.message}`);
100
- this.invalidateCache();
104
+ this.invalidateCache(workspaceId);
101
105
  logger.info(`Saved policy to DB: ${policy.metadata.id}`);
102
106
  return `db:policies/${policy.metadata.id}`;
103
107
  }
104
108
  /**
105
109
  * Partially update a policy (enabled toggle, name, description, tags, priority).
106
110
  */
107
- static async patch(policyId, patch, supabase, userId) {
108
- if (!supabase || !userId) {
111
+ static async patch(policyId, patch, supabase, userId, workspaceId) {
112
+ if (!supabase || !userId || !workspaceId) {
109
113
  throw new Error("Authentication required to update policies");
110
114
  }
111
115
  const { data: existing, error: fetchErr } = await supabase
112
116
  .from("policies")
113
117
  .select("metadata, priority, enabled")
114
118
  .eq("policy_id", policyId)
115
- .eq("user_id", userId)
119
+ .eq("workspace_id", workspaceId)
116
120
  .single();
117
121
  if (fetchErr || !existing)
118
122
  throw new Error("Policy not found");
@@ -131,10 +135,10 @@ export class PolicyLoader {
131
135
  priority: patch.priority ?? existing.priority,
132
136
  })
133
137
  .eq("policy_id", policyId)
134
- .eq("user_id", userId);
138
+ .eq("workspace_id", workspaceId);
135
139
  if (error)
136
140
  throw new Error(`Failed to patch policy: ${error.message}`);
137
- this.invalidateCache();
141
+ this.invalidateCache(workspaceId);
138
142
  logger.info(`Patched policy: ${policyId}`);
139
143
  return true;
140
144
  }
@@ -142,18 +146,18 @@ export class PolicyLoader {
142
146
  * Delete a policy by ID from Supabase.
143
147
  * Throws if no Supabase client is available.
144
148
  */
145
- static async delete(policyId, supabase, userId) {
146
- if (!supabase || !userId) {
149
+ static async delete(policyId, supabase, userId, workspaceId) {
150
+ if (!supabase || !userId || !workspaceId) {
147
151
  throw new Error("Authentication required to delete policies");
148
152
  }
149
153
  const { error, count } = await supabase
150
154
  .from("policies")
151
155
  .delete({ count: "exact" })
152
156
  .eq("policy_id", policyId)
153
- .eq("user_id", userId);
157
+ .eq("workspace_id", workspaceId);
154
158
  if (error)
155
159
  throw new Error(`Failed to delete policy: ${error.message}`);
156
- this.invalidateCache();
160
+ this.invalidateCache(workspaceId);
157
161
  return (count ?? 0) > 0;
158
162
  }
159
163
  }
@@ -89,7 +89,7 @@ export class RAGService {
89
89
  /**
90
90
  * Process an ingested document's raw text: chunk it, embed it, and store in DB.
91
91
  */
92
- static async chunkAndEmbed(ingestionId, userId, rawText, supabase, settings) {
92
+ static async chunkAndEmbed(ingestionId, userId, rawText, supabase, settings, workspaceId) {
93
93
  if (/^\[VLM_(IMAGE|PDF)_DATA:/.test(rawText)) {
94
94
  logger.info(`Skipping chunking and embedding for VLM base64 multimodal data (Ingestion: ${ingestionId})`);
95
95
  return;
@@ -100,6 +100,21 @@ export class RAGService {
100
100
  return;
101
101
  }
102
102
  const resolvedModel = await this.resolveEmbeddingModel(settings || {});
103
+ let resolvedWorkspaceId = (workspaceId ?? "").trim();
104
+ if (!resolvedWorkspaceId) {
105
+ const { data: ingestionRow, error: ingestionLookupError } = await supabase
106
+ .from("ingestions")
107
+ .select("workspace_id")
108
+ .eq("id", ingestionId)
109
+ .maybeSingle();
110
+ if (ingestionLookupError) {
111
+ throw new Error(`Failed to resolve workspace for ingestion ${ingestionId}: ${ingestionLookupError.message}`);
112
+ }
113
+ resolvedWorkspaceId = String(ingestionRow?.workspace_id ?? "").trim();
114
+ if (!resolvedWorkspaceId) {
115
+ throw new Error(`Workspace context is required to index chunks for ingestion ${ingestionId}`);
116
+ }
117
+ }
103
118
  logger.info(`Extracted ${chunks.length} chunks for ingestion ${ingestionId}. Embedding with ${resolvedModel.provider}/${resolvedModel.model}...`);
104
119
  // Global gate: background fire-and-forget jobs are bounded process-wide.
105
120
  await this.acquireEmbedJobSlot();
@@ -113,6 +128,7 @@ export class RAGService {
113
128
  const { data: existing } = await supabase
114
129
  .from("document_chunks")
115
130
  .select("id")
131
+ .eq("workspace_id", resolvedWorkspaceId)
116
132
  .eq("ingestion_id", ingestionId)
117
133
  .eq("content_hash", hash)
118
134
  .eq("embedding_provider", resolvedModel.provider)
@@ -126,6 +142,7 @@ export class RAGService {
126
142
  const embedding = await this.embedTextWithResolvedModel(content, resolvedModel);
127
143
  const vector_dim = embedding.length;
128
144
  const { error } = await supabase.from("document_chunks").insert({
145
+ workspace_id: resolvedWorkspaceId,
129
146
  user_id: userId,
130
147
  ingestion_id: ingestionId,
131
148
  content,
@@ -155,30 +172,44 @@ export class RAGService {
155
172
  * Semantically search the document chunks using dynamic pgvector partial indexing.
156
173
  */
157
174
  static async runSearchForModel(args) {
158
- const { userId, supabase, modelScope, queryEmbedding, queryDim, similarityThreshold, topK } = args;
159
- const { data, error } = await supabase.rpc("search_documents", {
160
- p_user_id: userId,
175
+ const { userId, workspaceId, supabase, modelScope, queryEmbedding, queryDim, similarityThreshold, topK } = args;
176
+ const basePayload = {
161
177
  p_embedding_provider: modelScope.provider,
162
178
  p_embedding_model: modelScope.model,
163
179
  query_embedding: queryEmbedding,
164
180
  match_threshold: similarityThreshold,
165
181
  match_count: topK,
166
182
  query_dim: queryDim
167
- });
183
+ };
184
+ const { data, error } = workspaceId
185
+ ? await supabase.rpc("search_workspace_documents", {
186
+ p_workspace_id: workspaceId,
187
+ ...basePayload
188
+ })
189
+ : await supabase.rpc("search_documents", {
190
+ p_user_id: userId,
191
+ ...basePayload
192
+ });
168
193
  if (error) {
169
194
  throw new Error(`Knowledge base search failed for ${modelScope.provider}/${modelScope.model}: ${error.message}`);
170
195
  }
171
196
  return (data || []);
172
197
  }
173
- static async listUserModelScopes(userId, supabase) {
174
- const { data, error } = await supabase
198
+ static async listModelScopes(userId, supabase, workspaceId) {
199
+ let query = supabase
175
200
  .from("document_chunks")
176
201
  .select("embedding_provider, embedding_model, vector_dim, created_at")
177
- .eq("user_id", userId)
178
202
  .order("created_at", { ascending: false })
179
203
  .limit(2000);
204
+ if (workspaceId) {
205
+ query = query.eq("workspace_id", workspaceId);
206
+ }
207
+ else {
208
+ query = query.eq("user_id", userId);
209
+ }
210
+ const { data, error } = await query;
180
211
  if (error) {
181
- logger.warn("Failed to list user embedding scopes for RAG fallback", { error });
212
+ logger.warn("Failed to list embedding scopes for RAG fallback", { userId, workspaceId, error });
182
213
  return [];
183
214
  }
184
215
  const scopes = new Map();
@@ -203,7 +234,7 @@ export class RAGService {
203
234
  return Array.from(scopes.values());
204
235
  }
205
236
  static async searchDocuments(query, userId, supabase, options = {}) {
206
- const { topK = 5, similarityThreshold = 0.7, settings } = options;
237
+ const { topK = 5, similarityThreshold = 0.7, settings, workspaceId } = options;
207
238
  const minThreshold = Math.max(0.1, Math.min(similarityThreshold, 0.4));
208
239
  const thresholdLevels = Array.from(new Set([similarityThreshold, minThreshold]));
209
240
  const preferred = await this.resolveEmbeddingModel(settings || {});
@@ -232,6 +263,7 @@ export class RAGService {
232
263
  logger.info(`Searching knowledge base (${scope.provider}/${scope.model}, dim=${queryDim}, topK=${topK}, threshold=${threshold})`);
233
264
  const hits = await this.runSearchForModel({
234
265
  userId,
266
+ workspaceId,
235
267
  supabase,
236
268
  modelScope: scope,
237
269
  queryEmbedding,
@@ -268,7 +300,7 @@ export class RAGService {
268
300
  });
269
301
  }
270
302
  if (collected.size === 0) {
271
- const scopes = await this.listUserModelScopes(userId, supabase);
303
+ const scopes = await this.listModelScopes(userId, supabase, workspaceId);
272
304
  const fallbackScopes = scopes.filter((scope) => !(scope.provider === preferredScope.provider && scope.model === preferredScope.model));
273
305
  for (const scope of fallbackScopes) {
274
306
  try {