@stoneforge/quarry 0.1.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/LICENSE +13 -0
- package/README.md +160 -0
- package/dist/api/index.d.ts +8 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +8 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/quarry-api.d.ts +268 -0
- package/dist/api/quarry-api.d.ts.map +1 -0
- package/dist/api/quarry-api.js +3905 -0
- package/dist/api/quarry-api.js.map +1 -0
- package/dist/api/types.d.ts +1359 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +204 -0
- package/dist/api/types.js.map +1 -0
- package/dist/bin/sf.d.ts +3 -0
- package/dist/bin/sf.d.ts.map +1 -0
- package/dist/bin/sf.js +9 -0
- package/dist/bin/sf.js.map +1 -0
- package/dist/cli/commands/admin.d.ts +11 -0
- package/dist/cli/commands/admin.d.ts.map +1 -0
- package/dist/cli/commands/admin.js +465 -0
- package/dist/cli/commands/admin.js.map +1 -0
- package/dist/cli/commands/alias.d.ts +8 -0
- package/dist/cli/commands/alias.d.ts.map +1 -0
- package/dist/cli/commands/alias.js +70 -0
- package/dist/cli/commands/alias.js.map +1 -0
- package/dist/cli/commands/channel.d.ts +13 -0
- package/dist/cli/commands/channel.d.ts.map +1 -0
- package/dist/cli/commands/channel.js +680 -0
- package/dist/cli/commands/channel.js.map +1 -0
- package/dist/cli/commands/completion.d.ts +8 -0
- package/dist/cli/commands/completion.d.ts.map +1 -0
- package/dist/cli/commands/completion.js +87 -0
- package/dist/cli/commands/completion.js.map +1 -0
- package/dist/cli/commands/config.d.ts +12 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +242 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/crud.d.ts +64 -0
- package/dist/cli/commands/crud.d.ts.map +1 -0
- package/dist/cli/commands/crud.js +805 -0
- package/dist/cli/commands/crud.js.map +1 -0
- package/dist/cli/commands/dep.d.ts +16 -0
- package/dist/cli/commands/dep.d.ts.map +1 -0
- package/dist/cli/commands/dep.js +499 -0
- package/dist/cli/commands/dep.js.map +1 -0
- package/dist/cli/commands/document.d.ts +12 -0
- package/dist/cli/commands/document.d.ts.map +1 -0
- package/dist/cli/commands/document.js +1039 -0
- package/dist/cli/commands/document.js.map +1 -0
- package/dist/cli/commands/embeddings.d.ts +12 -0
- package/dist/cli/commands/embeddings.d.ts.map +1 -0
- package/dist/cli/commands/embeddings.js +273 -0
- package/dist/cli/commands/embeddings.js.map +1 -0
- package/dist/cli/commands/entity.d.ts +16 -0
- package/dist/cli/commands/entity.d.ts.map +1 -0
- package/dist/cli/commands/entity.js +522 -0
- package/dist/cli/commands/entity.js.map +1 -0
- package/dist/cli/commands/gc.d.ts +10 -0
- package/dist/cli/commands/gc.d.ts.map +1 -0
- package/dist/cli/commands/gc.js +257 -0
- package/dist/cli/commands/gc.js.map +1 -0
- package/dist/cli/commands/help.d.ts +11 -0
- package/dist/cli/commands/help.d.ts.map +1 -0
- package/dist/cli/commands/help.js +169 -0
- package/dist/cli/commands/help.js.map +1 -0
- package/dist/cli/commands/history.d.ts +9 -0
- package/dist/cli/commands/history.d.ts.map +1 -0
- package/dist/cli/commands/history.js +160 -0
- package/dist/cli/commands/history.js.map +1 -0
- package/dist/cli/commands/identity.d.ts +18 -0
- package/dist/cli/commands/identity.d.ts.map +1 -0
- package/dist/cli/commands/identity.js +698 -0
- package/dist/cli/commands/identity.js.map +1 -0
- package/dist/cli/commands/inbox.d.ts +20 -0
- package/dist/cli/commands/inbox.d.ts.map +1 -0
- package/dist/cli/commands/inbox.js +493 -0
- package/dist/cli/commands/inbox.js.map +1 -0
- package/dist/cli/commands/init.d.ts +20 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +144 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/install.d.ts +9 -0
- package/dist/cli/commands/install.d.ts.map +1 -0
- package/dist/cli/commands/install.js +200 -0
- package/dist/cli/commands/install.js.map +1 -0
- package/dist/cli/commands/library.d.ts +12 -0
- package/dist/cli/commands/library.d.ts.map +1 -0
- package/dist/cli/commands/library.js +665 -0
- package/dist/cli/commands/library.js.map +1 -0
- package/dist/cli/commands/message.d.ts +11 -0
- package/dist/cli/commands/message.d.ts.map +1 -0
- package/dist/cli/commands/message.js +608 -0
- package/dist/cli/commands/message.js.map +1 -0
- package/dist/cli/commands/plan.d.ts +17 -0
- package/dist/cli/commands/plan.d.ts.map +1 -0
- package/dist/cli/commands/plan.js +698 -0
- package/dist/cli/commands/plan.js.map +1 -0
- package/dist/cli/commands/playbook.d.ts +12 -0
- package/dist/cli/commands/playbook.d.ts.map +1 -0
- package/dist/cli/commands/playbook.js +730 -0
- package/dist/cli/commands/playbook.js.map +1 -0
- package/dist/cli/commands/reset.d.ts +12 -0
- package/dist/cli/commands/reset.d.ts.map +1 -0
- package/dist/cli/commands/reset.js +306 -0
- package/dist/cli/commands/reset.js.map +1 -0
- package/dist/cli/commands/serve.d.ts +11 -0
- package/dist/cli/commands/serve.d.ts.map +1 -0
- package/dist/cli/commands/serve.js +106 -0
- package/dist/cli/commands/serve.js.map +1 -0
- package/dist/cli/commands/stats.d.ts +8 -0
- package/dist/cli/commands/stats.d.ts.map +1 -0
- package/dist/cli/commands/stats.js +82 -0
- package/dist/cli/commands/stats.js.map +1 -0
- package/dist/cli/commands/sync.d.ts +14 -0
- package/dist/cli/commands/sync.d.ts.map +1 -0
- package/dist/cli/commands/sync.js +370 -0
- package/dist/cli/commands/sync.js.map +1 -0
- package/dist/cli/commands/task.d.ts +25 -0
- package/dist/cli/commands/task.d.ts.map +1 -0
- package/dist/cli/commands/task.js +1153 -0
- package/dist/cli/commands/task.js.map +1 -0
- package/dist/cli/commands/team.d.ts +13 -0
- package/dist/cli/commands/team.d.ts.map +1 -0
- package/dist/cli/commands/team.js +471 -0
- package/dist/cli/commands/team.js.map +1 -0
- package/dist/cli/commands/workflow.d.ts +16 -0
- package/dist/cli/commands/workflow.d.ts.map +1 -0
- package/dist/cli/commands/workflow.js +753 -0
- package/dist/cli/commands/workflow.js.map +1 -0
- package/dist/cli/completion.d.ts +28 -0
- package/dist/cli/completion.d.ts.map +1 -0
- package/dist/cli/completion.js +295 -0
- package/dist/cli/completion.js.map +1 -0
- package/dist/cli/db.d.ts +38 -0
- package/dist/cli/db.d.ts.map +1 -0
- package/dist/cli/db.js +90 -0
- package/dist/cli/db.js.map +1 -0
- package/dist/cli/formatter.d.ts +87 -0
- package/dist/cli/formatter.d.ts.map +1 -0
- package/dist/cli/formatter.js +464 -0
- package/dist/cli/formatter.js.map +1 -0
- package/dist/cli/index.d.ts +33 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +38 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/parser.d.ts +45 -0
- package/dist/cli/parser.d.ts.map +1 -0
- package/dist/cli/parser.js +256 -0
- package/dist/cli/parser.js.map +1 -0
- package/dist/cli/plugin-loader.d.ts +39 -0
- package/dist/cli/plugin-loader.d.ts.map +1 -0
- package/dist/cli/plugin-loader.js +165 -0
- package/dist/cli/plugin-loader.js.map +1 -0
- package/dist/cli/plugin-registry.d.ts +50 -0
- package/dist/cli/plugin-registry.d.ts.map +1 -0
- package/dist/cli/plugin-registry.js +206 -0
- package/dist/cli/plugin-registry.js.map +1 -0
- package/dist/cli/plugin-types.d.ts +106 -0
- package/dist/cli/plugin-types.d.ts.map +1 -0
- package/dist/cli/plugin-types.js +103 -0
- package/dist/cli/plugin-types.js.map +1 -0
- package/dist/cli/runner.d.ts +35 -0
- package/dist/cli/runner.d.ts.map +1 -0
- package/dist/cli/runner.js +340 -0
- package/dist/cli/runner.js.map +1 -0
- package/dist/cli/suggest.d.ts +15 -0
- package/dist/cli/suggest.d.ts.map +1 -0
- package/dist/cli/suggest.js +49 -0
- package/dist/cli/suggest.js.map +1 -0
- package/dist/cli/types.d.ts +138 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/cli/types.js +63 -0
- package/dist/cli/types.js.map +1 -0
- package/dist/config/config.d.ts +86 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +348 -0
- package/dist/config/config.js.map +1 -0
- package/dist/config/defaults.d.ts +66 -0
- package/dist/config/defaults.d.ts.map +1 -0
- package/dist/config/defaults.js +114 -0
- package/dist/config/defaults.js.map +1 -0
- package/dist/config/duration.d.ts +75 -0
- package/dist/config/duration.d.ts.map +1 -0
- package/dist/config/duration.js +190 -0
- package/dist/config/duration.js.map +1 -0
- package/dist/config/env.d.ts +67 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +207 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/file.d.ts +97 -0
- package/dist/config/file.d.ts.map +1 -0
- package/dist/config/file.js +365 -0
- package/dist/config/file.js.map +1 -0
- package/dist/config/index.d.ts +35 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +41 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/merge.d.ts +53 -0
- package/dist/config/merge.d.ts.map +1 -0
- package/dist/config/merge.js +226 -0
- package/dist/config/merge.js.map +1 -0
- package/dist/config/types.d.ts +257 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +72 -0
- package/dist/config/types.js.map +1 -0
- package/dist/config/validation.d.ts +55 -0
- package/dist/config/validation.d.ts.map +1 -0
- package/dist/config/validation.js +251 -0
- package/dist/config/validation.js.map +1 -0
- package/dist/http/index.d.ts +8 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +12 -0
- package/dist/http/index.js.map +1 -0
- package/dist/http/sync-handlers.d.ts +162 -0
- package/dist/http/sync-handlers.d.ts.map +1 -0
- package/dist/http/sync-handlers.js +271 -0
- package/dist/http/sync-handlers.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/server/index.d.ts +34 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +3329 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/static.d.ts +18 -0
- package/dist/server/static.d.ts.map +1 -0
- package/dist/server/static.js +71 -0
- package/dist/server/static.js.map +1 -0
- package/dist/server/ws/broadcaster.d.ts +8 -0
- package/dist/server/ws/broadcaster.d.ts.map +1 -0
- package/dist/server/ws/broadcaster.js +7 -0
- package/dist/server/ws/broadcaster.js.map +1 -0
- package/dist/server/ws/handler.d.ts +55 -0
- package/dist/server/ws/handler.d.ts.map +1 -0
- package/dist/server/ws/handler.js +160 -0
- package/dist/server/ws/handler.js.map +1 -0
- package/dist/services/blocked-cache.d.ts +297 -0
- package/dist/services/blocked-cache.d.ts.map +1 -0
- package/dist/services/blocked-cache.js +755 -0
- package/dist/services/blocked-cache.js.map +1 -0
- package/dist/services/dependency.d.ts +205 -0
- package/dist/services/dependency.d.ts.map +1 -0
- package/dist/services/dependency.js +566 -0
- package/dist/services/dependency.js.map +1 -0
- package/dist/services/embeddings/fusion.d.ts +33 -0
- package/dist/services/embeddings/fusion.d.ts.map +1 -0
- package/dist/services/embeddings/fusion.js +34 -0
- package/dist/services/embeddings/fusion.js.map +1 -0
- package/dist/services/embeddings/index.d.ts +12 -0
- package/dist/services/embeddings/index.d.ts.map +1 -0
- package/dist/services/embeddings/index.js +10 -0
- package/dist/services/embeddings/index.js.map +1 -0
- package/dist/services/embeddings/local-provider.d.ts +31 -0
- package/dist/services/embeddings/local-provider.d.ts.map +1 -0
- package/dist/services/embeddings/local-provider.js +80 -0
- package/dist/services/embeddings/local-provider.js.map +1 -0
- package/dist/services/embeddings/service.d.ts +76 -0
- package/dist/services/embeddings/service.d.ts.map +1 -0
- package/dist/services/embeddings/service.js +153 -0
- package/dist/services/embeddings/service.js.map +1 -0
- package/dist/services/embeddings/types.d.ts +70 -0
- package/dist/services/embeddings/types.d.ts.map +1 -0
- package/dist/services/embeddings/types.js +8 -0
- package/dist/services/embeddings/types.js.map +1 -0
- package/dist/services/id-length-cache.d.ts +156 -0
- package/dist/services/id-length-cache.d.ts.map +1 -0
- package/dist/services/id-length-cache.js +197 -0
- package/dist/services/id-length-cache.js.map +1 -0
- package/dist/services/inbox.d.ts +147 -0
- package/dist/services/inbox.d.ts.map +1 -0
- package/dist/services/inbox.js +428 -0
- package/dist/services/inbox.js.map +1 -0
- package/dist/services/index.d.ts +10 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +10 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/priority-service.d.ts +145 -0
- package/dist/services/priority-service.d.ts.map +1 -0
- package/dist/services/priority-service.js +272 -0
- package/dist/services/priority-service.js.map +1 -0
- package/dist/services/search-utils.d.ts +47 -0
- package/dist/services/search-utils.d.ts.map +1 -0
- package/dist/services/search-utils.js +83 -0
- package/dist/services/search-utils.js.map +1 -0
- package/dist/sync/hash.d.ts +48 -0
- package/dist/sync/hash.d.ts.map +1 -0
- package/dist/sync/hash.js +136 -0
- package/dist/sync/hash.js.map +1 -0
- package/dist/sync/index.d.ts +11 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +16 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/merge.d.ts +80 -0
- package/dist/sync/merge.d.ts.map +1 -0
- package/dist/sync/merge.js +310 -0
- package/dist/sync/merge.js.map +1 -0
- package/dist/sync/serialization.d.ts +132 -0
- package/dist/sync/serialization.d.ts.map +1 -0
- package/dist/sync/serialization.js +306 -0
- package/dist/sync/serialization.js.map +1 -0
- package/dist/sync/service.d.ts +102 -0
- package/dist/sync/service.d.ts.map +1 -0
- package/dist/sync/service.js +493 -0
- package/dist/sync/service.js.map +1 -0
- package/dist/sync/types.d.ts +275 -0
- package/dist/sync/types.d.ts.map +1 -0
- package/dist/sync/types.js +76 -0
- package/dist/sync/types.js.map +1 -0
- package/dist/systems/identity.d.ts +479 -0
- package/dist/systems/identity.d.ts.map +1 -0
- package/dist/systems/identity.js +817 -0
- package/dist/systems/identity.js.map +1 -0
- package/dist/systems/index.d.ts +8 -0
- package/dist/systems/index.d.ts.map +1 -0
- package/dist/systems/index.js +29 -0
- package/dist/systems/index.js.map +1 -0
- package/package.json +121 -0
- package/web/assets/charts-vendor-D1YcbGux.js +55 -0
- package/web/assets/dnd-vendor-DmxE-_ZH.js +5 -0
- package/web/assets/editor-vendor-BxraAWts.js +279 -0
- package/web/assets/index-B77vv208.js +341 -0
- package/web/assets/index-CF_XnVLh.css +1 -0
- package/web/assets/router-vendor-BCKpRBrB.js +41 -0
- package/web/assets/ui-vendor-DUahGnbT.js +45 -0
- package/web/assets/utils-vendor-CfYKiENT.js +813 -0
- package/web/favicon.ico +0 -0
- package/web/index.html +23 -0
- package/web/logo.png +0 -0
|
@@ -0,0 +1,3905 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stoneforge API Implementation
|
|
3
|
+
*
|
|
4
|
+
* This module provides the concrete implementation of the QuarryAPI interface,
|
|
5
|
+
* connecting the type system to the storage layer with full CRUD operations.
|
|
6
|
+
*/
|
|
7
|
+
import { isDocument, reconstructStateAtTime, generateTimelineSnapshots, createTimestamp, isTask, TaskStatus as TaskStatusEnum, isPlan, PlanStatus as PlanStatusEnum, calculatePlanProgress, createEvent, LifecycleEventType, MembershipEventType, NotFoundError, ConflictError, ConstraintError, StorageError, ValidationError, ErrorCode, ChannelTypeValue, createDirectChannel, isMember, canModifyMembers, isDirectChannel, DirectChannelMembershipError, NotAMemberError, CannotModifyMembersError, createMessage, isMessage, isLibrary, isTeamDeleted, isTeamMember, extractMentionedNames, validateMentions, InboxSourceType, isWorkflow, WorkflowStatus as WorkflowStatusEnum, generateChildId, createTask, validateManager, getManagementChain as getManagementChainUtil, buildOrgChart, updateEntity, isEntityActive, } from '@stoneforge/core';
|
|
8
|
+
import { createBlockedCacheService } from '../services/blocked-cache.js';
|
|
9
|
+
import { createPriorityService } from '../services/priority-service.js';
|
|
10
|
+
import { createInboxService } from '../services/inbox.js';
|
|
11
|
+
import { SyncService } from '../sync/service.js';
|
|
12
|
+
import { computeContentHashSync } from '../sync/hash.js';
|
|
13
|
+
import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, } from './types.js';
|
|
14
|
+
import { applyAdaptiveTopK, escapeFts5Query } from '../services/search-utils.js';
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Helper Functions
|
|
17
|
+
// ============================================================================
|
|
18
|
+
/**
|
|
19
|
+
* Serialize an element to database format
|
|
20
|
+
*/
|
|
21
|
+
function serializeElement(element) {
|
|
22
|
+
// Extract base element fields and type-specific data
|
|
23
|
+
const { id, type, createdAt, updatedAt, createdBy, tags, metadata, ...typeData } = element;
|
|
24
|
+
// Store type-specific fields in data JSON
|
|
25
|
+
const data = JSON.stringify({
|
|
26
|
+
...typeData,
|
|
27
|
+
tags,
|
|
28
|
+
metadata,
|
|
29
|
+
});
|
|
30
|
+
// Check for deletedAt (tombstone status)
|
|
31
|
+
const deletedAt = 'deletedAt' in element ? element.deletedAt : null;
|
|
32
|
+
// Compute content hash for conflict detection
|
|
33
|
+
const { hash: contentHash } = computeContentHashSync(element);
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
type,
|
|
37
|
+
data,
|
|
38
|
+
content_hash: contentHash,
|
|
39
|
+
created_at: createdAt,
|
|
40
|
+
updated_at: updatedAt,
|
|
41
|
+
created_by: createdBy,
|
|
42
|
+
deleted_at: deletedAt ?? null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Deserialize a database row to an element
|
|
47
|
+
*/
|
|
48
|
+
function deserializeElement(row, tags) {
|
|
49
|
+
let data;
|
|
50
|
+
try {
|
|
51
|
+
data = JSON.parse(row.data);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
console.warn(`[stoneforge] Corrupt data for element ${row.id}, skipping:`, error);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
id: row.id,
|
|
59
|
+
type: row.type,
|
|
60
|
+
createdAt: row.created_at,
|
|
61
|
+
updatedAt: row.updated_at,
|
|
62
|
+
createdBy: row.created_by,
|
|
63
|
+
tags,
|
|
64
|
+
metadata: data.metadata ?? {},
|
|
65
|
+
...data,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Build WHERE clause from ElementFilter
|
|
70
|
+
*/
|
|
71
|
+
function buildWhereClause(filter, params) {
|
|
72
|
+
const conditions = [];
|
|
73
|
+
// Type filter
|
|
74
|
+
if (filter.type !== undefined) {
|
|
75
|
+
const types = Array.isArray(filter.type) ? filter.type : [filter.type];
|
|
76
|
+
const placeholders = types.map(() => '?').join(', ');
|
|
77
|
+
conditions.push(`e.type IN (${placeholders})`);
|
|
78
|
+
params.push(...types);
|
|
79
|
+
}
|
|
80
|
+
// Creator filter
|
|
81
|
+
if (filter.createdBy !== undefined) {
|
|
82
|
+
conditions.push('e.created_by = ?');
|
|
83
|
+
params.push(filter.createdBy);
|
|
84
|
+
}
|
|
85
|
+
// Created date filters
|
|
86
|
+
if (filter.createdAfter !== undefined) {
|
|
87
|
+
conditions.push('e.created_at >= ?');
|
|
88
|
+
params.push(filter.createdAfter);
|
|
89
|
+
}
|
|
90
|
+
if (filter.createdBefore !== undefined) {
|
|
91
|
+
conditions.push('e.created_at < ?');
|
|
92
|
+
params.push(filter.createdBefore);
|
|
93
|
+
}
|
|
94
|
+
// Updated date filters
|
|
95
|
+
if (filter.updatedAfter !== undefined) {
|
|
96
|
+
conditions.push('e.updated_at >= ?');
|
|
97
|
+
params.push(filter.updatedAfter);
|
|
98
|
+
}
|
|
99
|
+
if (filter.updatedBefore !== undefined) {
|
|
100
|
+
conditions.push('e.updated_at < ?');
|
|
101
|
+
params.push(filter.updatedBefore);
|
|
102
|
+
}
|
|
103
|
+
// Include deleted filter
|
|
104
|
+
if (!filter.includeDeleted) {
|
|
105
|
+
conditions.push('e.deleted_at IS NULL');
|
|
106
|
+
}
|
|
107
|
+
const where = conditions.length > 0 ? conditions.join(' AND ') : '1=1';
|
|
108
|
+
return { where, params };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Build task-specific WHERE clause additions
|
|
112
|
+
*/
|
|
113
|
+
function buildTaskWhereClause(filter, params) {
|
|
114
|
+
const conditions = [];
|
|
115
|
+
// Status filter
|
|
116
|
+
if (filter.status !== undefined) {
|
|
117
|
+
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
|
|
118
|
+
// Status is stored in data JSON, use JSON_EXTRACT
|
|
119
|
+
const statusConditions = statuses.map(() => "JSON_EXTRACT(e.data, '$.status') = ?").join(' OR ');
|
|
120
|
+
conditions.push(`(${statusConditions})`);
|
|
121
|
+
params.push(...statuses);
|
|
122
|
+
}
|
|
123
|
+
// Priority filter
|
|
124
|
+
if (filter.priority !== undefined) {
|
|
125
|
+
const priorities = Array.isArray(filter.priority) ? filter.priority : [filter.priority];
|
|
126
|
+
const priorityConditions = priorities.map(() => "JSON_EXTRACT(e.data, '$.priority') = ?").join(' OR ');
|
|
127
|
+
conditions.push(`(${priorityConditions})`);
|
|
128
|
+
params.push(...priorities);
|
|
129
|
+
}
|
|
130
|
+
// Complexity filter
|
|
131
|
+
if (filter.complexity !== undefined) {
|
|
132
|
+
const complexities = Array.isArray(filter.complexity) ? filter.complexity : [filter.complexity];
|
|
133
|
+
const complexityConditions = complexities.map(() => "JSON_EXTRACT(e.data, '$.complexity') = ?").join(' OR ');
|
|
134
|
+
conditions.push(`(${complexityConditions})`);
|
|
135
|
+
params.push(...complexities);
|
|
136
|
+
}
|
|
137
|
+
// Assignee filter
|
|
138
|
+
if (filter.assignee !== undefined) {
|
|
139
|
+
conditions.push("JSON_EXTRACT(e.data, '$.assignee') = ?");
|
|
140
|
+
params.push(filter.assignee);
|
|
141
|
+
}
|
|
142
|
+
// Owner filter
|
|
143
|
+
if (filter.owner !== undefined) {
|
|
144
|
+
conditions.push("JSON_EXTRACT(e.data, '$.owner') = ?");
|
|
145
|
+
params.push(filter.owner);
|
|
146
|
+
}
|
|
147
|
+
// Task type filter
|
|
148
|
+
if (filter.taskType !== undefined) {
|
|
149
|
+
const taskTypes = Array.isArray(filter.taskType) ? filter.taskType : [filter.taskType];
|
|
150
|
+
const typeConditions = taskTypes.map(() => "JSON_EXTRACT(e.data, '$.taskType') = ?").join(' OR ');
|
|
151
|
+
conditions.push(`(${typeConditions})`);
|
|
152
|
+
params.push(...taskTypes);
|
|
153
|
+
}
|
|
154
|
+
// Deadline filters
|
|
155
|
+
if (filter.hasDeadline !== undefined) {
|
|
156
|
+
if (filter.hasDeadline) {
|
|
157
|
+
conditions.push("JSON_EXTRACT(e.data, '$.deadline') IS NOT NULL");
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
conditions.push("JSON_EXTRACT(e.data, '$.deadline') IS NULL");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (filter.deadlineBefore !== undefined) {
|
|
164
|
+
conditions.push("JSON_EXTRACT(e.data, '$.deadline') < ?");
|
|
165
|
+
params.push(filter.deadlineBefore);
|
|
166
|
+
}
|
|
167
|
+
const where = conditions.length > 0 ? conditions.join(' AND ') : '';
|
|
168
|
+
return { where, params };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Build channel-specific WHERE clause additions
|
|
172
|
+
*/
|
|
173
|
+
function buildChannelWhereClause(filter, params) {
|
|
174
|
+
const conditions = [];
|
|
175
|
+
// Channel type filter (direct or group)
|
|
176
|
+
if (filter.channelType !== undefined) {
|
|
177
|
+
conditions.push("JSON_EXTRACT(e.data, '$.channelType') = ?");
|
|
178
|
+
params.push(filter.channelType);
|
|
179
|
+
}
|
|
180
|
+
// Visibility filter
|
|
181
|
+
if (filter.visibility !== undefined) {
|
|
182
|
+
conditions.push("JSON_EXTRACT(e.data, '$.permissions.visibility') = ?");
|
|
183
|
+
params.push(filter.visibility);
|
|
184
|
+
}
|
|
185
|
+
// Join policy filter
|
|
186
|
+
if (filter.joinPolicy !== undefined) {
|
|
187
|
+
conditions.push("JSON_EXTRACT(e.data, '$.permissions.joinPolicy') = ?");
|
|
188
|
+
params.push(filter.joinPolicy);
|
|
189
|
+
}
|
|
190
|
+
// Member filter - check if entity is in members array
|
|
191
|
+
if (filter.member !== undefined) {
|
|
192
|
+
// Using LIKE for JSON array membership check
|
|
193
|
+
conditions.push("JSON_EXTRACT(e.data, '$.members') LIKE ?");
|
|
194
|
+
params.push(`%"${filter.member}"%`);
|
|
195
|
+
}
|
|
196
|
+
const where = conditions.length > 0 ? conditions.join(' AND ') : '';
|
|
197
|
+
return { where, params };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Build document-specific WHERE clause additions
|
|
201
|
+
*/
|
|
202
|
+
function buildDocumentWhereClause(filter, params) {
|
|
203
|
+
const conditions = [];
|
|
204
|
+
// Content type filter
|
|
205
|
+
if (filter.contentType !== undefined) {
|
|
206
|
+
const contentTypes = Array.isArray(filter.contentType) ? filter.contentType : [filter.contentType];
|
|
207
|
+
const typeConditions = contentTypes.map(() => "JSON_EXTRACT(e.data, '$.contentType') = ?").join(' OR ');
|
|
208
|
+
conditions.push(`(${typeConditions})`);
|
|
209
|
+
params.push(...contentTypes);
|
|
210
|
+
}
|
|
211
|
+
// Exact version filter
|
|
212
|
+
if (filter.version !== undefined) {
|
|
213
|
+
conditions.push("JSON_EXTRACT(e.data, '$.version') = ?");
|
|
214
|
+
params.push(filter.version);
|
|
215
|
+
}
|
|
216
|
+
// Minimum version filter (inclusive)
|
|
217
|
+
if (filter.minVersion !== undefined) {
|
|
218
|
+
conditions.push("JSON_EXTRACT(e.data, '$.version') >= ?");
|
|
219
|
+
params.push(filter.minVersion);
|
|
220
|
+
}
|
|
221
|
+
// Maximum version filter (inclusive)
|
|
222
|
+
if (filter.maxVersion !== undefined) {
|
|
223
|
+
conditions.push("JSON_EXTRACT(e.data, '$.version') <= ?");
|
|
224
|
+
params.push(filter.maxVersion);
|
|
225
|
+
}
|
|
226
|
+
// Category filter
|
|
227
|
+
if (filter.category !== undefined) {
|
|
228
|
+
const categories = Array.isArray(filter.category) ? filter.category : [filter.category];
|
|
229
|
+
const catConditions = categories.map(() => "JSON_EXTRACT(e.data, '$.category') = ?").join(' OR ');
|
|
230
|
+
conditions.push(`(${catConditions})`);
|
|
231
|
+
params.push(...categories);
|
|
232
|
+
}
|
|
233
|
+
// Status filter (default: active only)
|
|
234
|
+
if (filter.status !== undefined) {
|
|
235
|
+
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
|
|
236
|
+
const statusConditions = statuses.map(() => "JSON_EXTRACT(e.data, '$.status') = ?").join(' OR ');
|
|
237
|
+
conditions.push(`(${statusConditions})`);
|
|
238
|
+
params.push(...statuses);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
// Default: only show active documents
|
|
242
|
+
conditions.push("JSON_EXTRACT(e.data, '$.status') = ?");
|
|
243
|
+
params.push('active');
|
|
244
|
+
}
|
|
245
|
+
const where = conditions.length > 0 ? conditions.join(' AND ') : '';
|
|
246
|
+
return { where, params };
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Build message-specific WHERE clause additions
|
|
250
|
+
*/
|
|
251
|
+
function buildMessageWhereClause(filter, params) {
|
|
252
|
+
const conditions = [];
|
|
253
|
+
// Channel filter
|
|
254
|
+
if (filter.channelId !== undefined) {
|
|
255
|
+
const channelIds = Array.isArray(filter.channelId) ? filter.channelId : [filter.channelId];
|
|
256
|
+
const channelConditions = channelIds.map(() => "JSON_EXTRACT(e.data, '$.channelId') = ?").join(' OR ');
|
|
257
|
+
conditions.push(`(${channelConditions})`);
|
|
258
|
+
params.push(...channelIds);
|
|
259
|
+
}
|
|
260
|
+
// Sender filter
|
|
261
|
+
if (filter.sender !== undefined) {
|
|
262
|
+
const senders = Array.isArray(filter.sender) ? filter.sender : [filter.sender];
|
|
263
|
+
const senderConditions = senders.map(() => "JSON_EXTRACT(e.data, '$.sender') = ?").join(' OR ');
|
|
264
|
+
conditions.push(`(${senderConditions})`);
|
|
265
|
+
params.push(...senders);
|
|
266
|
+
}
|
|
267
|
+
// Thread filter
|
|
268
|
+
if (filter.threadId !== undefined) {
|
|
269
|
+
if (filter.threadId === null) {
|
|
270
|
+
// Root messages only
|
|
271
|
+
conditions.push("JSON_EXTRACT(e.data, '$.threadId') IS NULL");
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
// Messages in a specific thread
|
|
275
|
+
conditions.push("JSON_EXTRACT(e.data, '$.threadId') = ?");
|
|
276
|
+
params.push(filter.threadId);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Has attachments filter
|
|
280
|
+
if (filter.hasAttachments !== undefined) {
|
|
281
|
+
if (filter.hasAttachments) {
|
|
282
|
+
// Has at least one attachment
|
|
283
|
+
conditions.push("JSON_ARRAY_LENGTH(JSON_EXTRACT(e.data, '$.attachments')) > 0");
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
// No attachments
|
|
287
|
+
conditions.push("(JSON_EXTRACT(e.data, '$.attachments') IS NULL OR JSON_ARRAY_LENGTH(JSON_EXTRACT(e.data, '$.attachments')) = 0)");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const where = conditions.length > 0 ? conditions.join(' AND ') : '';
|
|
291
|
+
return { where, params };
|
|
292
|
+
}
|
|
293
|
+
// ============================================================================
|
|
294
|
+
// QuarryAPI Implementation
|
|
295
|
+
// ============================================================================
|
|
296
|
+
/**
|
|
297
|
+
* Implementation of the QuarryAPI interface
|
|
298
|
+
*/
|
|
299
|
+
export class QuarryAPIImpl {
|
|
300
|
+
backend;
|
|
301
|
+
blockedCache;
|
|
302
|
+
priorityService;
|
|
303
|
+
syncService;
|
|
304
|
+
inboxService;
|
|
305
|
+
embeddingService;
|
|
306
|
+
constructor(backend) {
|
|
307
|
+
this.backend = backend;
|
|
308
|
+
this.blockedCache = createBlockedCacheService(backend);
|
|
309
|
+
this.priorityService = createPriorityService(backend);
|
|
310
|
+
this.syncService = new SyncService(backend);
|
|
311
|
+
this.inboxService = createInboxService(backend);
|
|
312
|
+
// Set up automatic status transitions for blocked/unblocked states
|
|
313
|
+
this.blockedCache.setStatusTransitionCallback({
|
|
314
|
+
onBlock: (elementId, previousStatus) => {
|
|
315
|
+
this.updateTaskStatusInternal(elementId, TaskStatusEnum.BLOCKED, previousStatus);
|
|
316
|
+
},
|
|
317
|
+
onUnblock: (elementId, statusToRestore) => {
|
|
318
|
+
this.updateTaskStatusInternal(elementId, statusToRestore, null);
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Internal method to update task status without triggering additional blocked cache updates.
|
|
324
|
+
* Used for automatic blocked/unblocked status transitions.
|
|
325
|
+
*/
|
|
326
|
+
updateTaskStatusInternal(elementId, newStatus, _previousStatus) {
|
|
327
|
+
// Get current element
|
|
328
|
+
const row = this.backend.queryOne('SELECT * FROM elements WHERE id = ?', [elementId]);
|
|
329
|
+
if (!row || row.type !== 'task') {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Parse current data
|
|
333
|
+
const data = JSON.parse(row.data);
|
|
334
|
+
const oldStatus = data.status;
|
|
335
|
+
// Don't update if already at target status
|
|
336
|
+
if (oldStatus === newStatus) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
// Update status in data
|
|
340
|
+
data.status = newStatus;
|
|
341
|
+
// Update timestamps based on transition
|
|
342
|
+
const now = createTimestamp();
|
|
343
|
+
if (newStatus === TaskStatusEnum.CLOSED && !data.closedAt) {
|
|
344
|
+
data.closedAt = now;
|
|
345
|
+
}
|
|
346
|
+
else if (newStatus !== TaskStatusEnum.CLOSED && data.closedAt) {
|
|
347
|
+
data.closedAt = null;
|
|
348
|
+
}
|
|
349
|
+
// Update in database
|
|
350
|
+
this.backend.run(`UPDATE elements SET data = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(data), now, elementId]);
|
|
351
|
+
// Record event for automatic status transition
|
|
352
|
+
const eventType = newStatus === TaskStatusEnum.BLOCKED
|
|
353
|
+
? 'auto_blocked'
|
|
354
|
+
: 'auto_unblocked';
|
|
355
|
+
const event = createEvent({
|
|
356
|
+
elementId,
|
|
357
|
+
eventType,
|
|
358
|
+
actor: 'system:blocked-cache',
|
|
359
|
+
oldValue: { status: oldStatus },
|
|
360
|
+
newValue: { status: newStatus },
|
|
361
|
+
});
|
|
362
|
+
this.backend.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
|
|
363
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
364
|
+
event.elementId,
|
|
365
|
+
event.eventType,
|
|
366
|
+
event.actor,
|
|
367
|
+
JSON.stringify(event.oldValue),
|
|
368
|
+
JSON.stringify(event.newValue),
|
|
369
|
+
event.createdAt,
|
|
370
|
+
]);
|
|
371
|
+
// Mark as dirty for sync
|
|
372
|
+
this.backend.markDirty(elementId);
|
|
373
|
+
}
|
|
374
|
+
// --------------------------------------------------------------------------
|
|
375
|
+
// CRUD Operations
|
|
376
|
+
// --------------------------------------------------------------------------
|
|
377
|
+
async get(id, options) {
|
|
378
|
+
// Query the element
|
|
379
|
+
const row = this.backend.queryOne('SELECT * FROM elements WHERE id = ?', [id]);
|
|
380
|
+
if (!row) {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
// Get tags for this element
|
|
384
|
+
const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [id]);
|
|
385
|
+
const tags = tagRows.map((r) => r.tag);
|
|
386
|
+
// Deserialize the element
|
|
387
|
+
let element = deserializeElement(row, tags);
|
|
388
|
+
if (!element)
|
|
389
|
+
return null;
|
|
390
|
+
// Handle hydration if requested
|
|
391
|
+
if (options?.hydrate) {
|
|
392
|
+
if (isTask(element)) {
|
|
393
|
+
element = await this.hydrateTask(element, options.hydrate);
|
|
394
|
+
}
|
|
395
|
+
else if (isMessage(element)) {
|
|
396
|
+
element = await this.hydrateMessage(element, options.hydrate);
|
|
397
|
+
}
|
|
398
|
+
else if (isLibrary(element)) {
|
|
399
|
+
element = await this.hydrateLibrary(element, options.hydrate);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return element;
|
|
403
|
+
}
|
|
404
|
+
async list(filter) {
|
|
405
|
+
const result = await this.listPaginated(filter);
|
|
406
|
+
return result.items;
|
|
407
|
+
}
|
|
408
|
+
async listPaginated(filter) {
|
|
409
|
+
const effectiveFilter = filter ?? {};
|
|
410
|
+
// Build base WHERE clause (params will be accumulated here)
|
|
411
|
+
const params = [];
|
|
412
|
+
const { where: baseWhere } = buildWhereClause(effectiveFilter, params);
|
|
413
|
+
// Build task-specific WHERE clause if filtering tasks
|
|
414
|
+
let taskWhere = '';
|
|
415
|
+
if (effectiveFilter.type === 'task' || (Array.isArray(effectiveFilter.type) && effectiveFilter.type.includes('task'))) {
|
|
416
|
+
const taskFilter = effectiveFilter;
|
|
417
|
+
const { where: tw } = buildTaskWhereClause(taskFilter, params);
|
|
418
|
+
if (tw) {
|
|
419
|
+
taskWhere = ` AND ${tw}`;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// Build document-specific WHERE clause if filtering documents
|
|
423
|
+
let documentWhere = '';
|
|
424
|
+
if (effectiveFilter.type === 'document' || (Array.isArray(effectiveFilter.type) && effectiveFilter.type.includes('document'))) {
|
|
425
|
+
const documentFilter = effectiveFilter;
|
|
426
|
+
const { where: dw } = buildDocumentWhereClause(documentFilter, params);
|
|
427
|
+
if (dw) {
|
|
428
|
+
// When filtering multiple types, scope document clauses to document rows only
|
|
429
|
+
const isMultiType = Array.isArray(effectiveFilter.type) && effectiveFilter.type.length > 1;
|
|
430
|
+
documentWhere = isMultiType ? ` AND (e.type != 'document' OR (${dw}))` : ` AND ${dw}`;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// Build message-specific WHERE clause if filtering messages
|
|
434
|
+
let messageWhere = '';
|
|
435
|
+
if (effectiveFilter.type === 'message' || (Array.isArray(effectiveFilter.type) && effectiveFilter.type.includes('message'))) {
|
|
436
|
+
const messageFilter = effectiveFilter;
|
|
437
|
+
const { where: mw } = buildMessageWhereClause(messageFilter, params);
|
|
438
|
+
if (mw) {
|
|
439
|
+
messageWhere = ` AND ${mw}`;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Handle tag filtering
|
|
443
|
+
let tagJoin = '';
|
|
444
|
+
let tagWhere = '';
|
|
445
|
+
if (effectiveFilter.tags && effectiveFilter.tags.length > 0) {
|
|
446
|
+
// Must have ALL tags - use GROUP BY with HAVING COUNT
|
|
447
|
+
tagJoin = ' JOIN tags t ON e.id = t.element_id';
|
|
448
|
+
const placeholders = effectiveFilter.tags.map(() => '?').join(', ');
|
|
449
|
+
tagWhere = ` AND t.tag IN (${placeholders})`;
|
|
450
|
+
params.push(...effectiveFilter.tags);
|
|
451
|
+
}
|
|
452
|
+
if (effectiveFilter.tagsAny && effectiveFilter.tagsAny.length > 0) {
|
|
453
|
+
// Must have ANY tag
|
|
454
|
+
if (!tagJoin) {
|
|
455
|
+
tagJoin = ' JOIN tags t ON e.id = t.element_id';
|
|
456
|
+
}
|
|
457
|
+
const placeholders = effectiveFilter.tagsAny.map(() => '?').join(', ');
|
|
458
|
+
tagWhere += ` AND t.tag IN (${placeholders})`;
|
|
459
|
+
params.push(...effectiveFilter.tagsAny);
|
|
460
|
+
}
|
|
461
|
+
// Count total matching elements
|
|
462
|
+
const countSql = `
|
|
463
|
+
SELECT COUNT(DISTINCT e.id) as count
|
|
464
|
+
FROM elements e${tagJoin}
|
|
465
|
+
WHERE ${baseWhere}${taskWhere}${documentWhere}${messageWhere}${tagWhere}
|
|
466
|
+
`;
|
|
467
|
+
const countRow = this.backend.queryOne(countSql, params);
|
|
468
|
+
const total = countRow?.count ?? 0;
|
|
469
|
+
// Build ORDER BY
|
|
470
|
+
const orderBy = effectiveFilter.orderBy ?? 'created_at';
|
|
471
|
+
const orderDir = effectiveFilter.orderDir ?? 'desc';
|
|
472
|
+
// Map field names to SQL expressions
|
|
473
|
+
// Fields on the elements table can be referenced directly
|
|
474
|
+
// Fields stored in JSON data need JSON_EXTRACT
|
|
475
|
+
const columnMap = {
|
|
476
|
+
created_at: 'e.created_at',
|
|
477
|
+
updated_at: 'e.updated_at',
|
|
478
|
+
type: 'e.type',
|
|
479
|
+
id: 'e.id',
|
|
480
|
+
// Task-specific JSON fields
|
|
481
|
+
title: "JSON_EXTRACT(e.data, '$.title')",
|
|
482
|
+
status: "JSON_EXTRACT(e.data, '$.status')",
|
|
483
|
+
priority: "JSON_EXTRACT(e.data, '$.priority')",
|
|
484
|
+
complexity: "JSON_EXTRACT(e.data, '$.complexity')",
|
|
485
|
+
taskType: "JSON_EXTRACT(e.data, '$.taskType')",
|
|
486
|
+
assignee: "JSON_EXTRACT(e.data, '$.assignee')",
|
|
487
|
+
owner: "JSON_EXTRACT(e.data, '$.owner')",
|
|
488
|
+
// Document-specific JSON fields
|
|
489
|
+
name: "JSON_EXTRACT(e.data, '$.name')",
|
|
490
|
+
contentType: "JSON_EXTRACT(e.data, '$.contentType')",
|
|
491
|
+
version: "JSON_EXTRACT(e.data, '$.version')",
|
|
492
|
+
};
|
|
493
|
+
const orderColumn = columnMap[orderBy] ?? `e.${orderBy}`;
|
|
494
|
+
const orderClause = `ORDER BY ${orderColumn} ${orderDir.toUpperCase()}`;
|
|
495
|
+
// Apply pagination
|
|
496
|
+
const limit = Math.min(effectiveFilter.limit ?? DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE);
|
|
497
|
+
const offset = effectiveFilter.offset ?? 0;
|
|
498
|
+
// Query elements
|
|
499
|
+
const sql = `
|
|
500
|
+
SELECT DISTINCT e.*
|
|
501
|
+
FROM elements e${tagJoin}
|
|
502
|
+
WHERE ${baseWhere}${taskWhere}${documentWhere}${messageWhere}${tagWhere}
|
|
503
|
+
${orderClause}
|
|
504
|
+
LIMIT ? OFFSET ?
|
|
505
|
+
`;
|
|
506
|
+
const rows = this.backend.query(sql, [...params, limit, offset]);
|
|
507
|
+
// Batch fetch tags for all returned elements (eliminates N+1 query issue)
|
|
508
|
+
const elementIds = rows.map((row) => row.id);
|
|
509
|
+
const tagsMap = this.batchFetchTags(elementIds);
|
|
510
|
+
// Deserialize elements with their tags
|
|
511
|
+
const items = rows.map((row) => {
|
|
512
|
+
const tags = tagsMap.get(row.id) ?? [];
|
|
513
|
+
return deserializeElement(row, tags);
|
|
514
|
+
}).filter((el) => el !== null);
|
|
515
|
+
// Check if tags filter requires all tags
|
|
516
|
+
let filteredItems = items;
|
|
517
|
+
if (effectiveFilter.tags && effectiveFilter.tags.length > 1) {
|
|
518
|
+
// Filter to elements that have ALL tags
|
|
519
|
+
filteredItems = items.filter((item) => effectiveFilter.tags.every((tag) => item.tags.includes(tag)));
|
|
520
|
+
}
|
|
521
|
+
// Apply hydration if requested
|
|
522
|
+
let finalItems = filteredItems;
|
|
523
|
+
if (effectiveFilter.hydrate) {
|
|
524
|
+
// Hydrate tasks
|
|
525
|
+
const tasks = filteredItems.filter((item) => isTask(item));
|
|
526
|
+
if (tasks.length > 0) {
|
|
527
|
+
const hydratedTasks = this.hydrateTasks(tasks, effectiveFilter.hydrate);
|
|
528
|
+
// Create a map for efficient lookup
|
|
529
|
+
const hydratedMap = new Map(hydratedTasks.map((t) => [t.id, t]));
|
|
530
|
+
// Replace tasks with hydrated versions, keeping non-tasks as-is
|
|
531
|
+
finalItems = filteredItems.map((item) => {
|
|
532
|
+
const hydrated = hydratedMap.get(item.id);
|
|
533
|
+
return hydrated ? hydrated : item;
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
// Hydrate messages
|
|
537
|
+
const messages = filteredItems.filter((item) => isMessage(item));
|
|
538
|
+
if (messages.length > 0) {
|
|
539
|
+
const hydratedMessages = this.hydrateMessages(messages, effectiveFilter.hydrate);
|
|
540
|
+
// Create a map for efficient lookup
|
|
541
|
+
const hydratedMsgMap = new Map(hydratedMessages.map((m) => [m.id, m]));
|
|
542
|
+
// Replace messages with hydrated versions
|
|
543
|
+
finalItems = finalItems.map((item) => {
|
|
544
|
+
const hydrated = hydratedMsgMap.get(item.id);
|
|
545
|
+
return hydrated ? hydrated : item;
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
// Hydrate libraries
|
|
549
|
+
const libraries = finalItems.filter((item) => isLibrary(item));
|
|
550
|
+
if (libraries.length > 0) {
|
|
551
|
+
const hydratedLibraries = this.hydrateLibraries(libraries, effectiveFilter.hydrate);
|
|
552
|
+
// Create a map for efficient lookup
|
|
553
|
+
const hydratedLibMap = new Map(hydratedLibraries.map((l) => [l.id, l]));
|
|
554
|
+
// Replace libraries with hydrated versions
|
|
555
|
+
finalItems = finalItems.map((item) => {
|
|
556
|
+
const hydrated = hydratedLibMap.get(item.id);
|
|
557
|
+
return hydrated ? hydrated : item;
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
items: finalItems,
|
|
563
|
+
total,
|
|
564
|
+
offset,
|
|
565
|
+
limit,
|
|
566
|
+
hasMore: offset + finalItems.length < total,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
async create(input) {
|
|
570
|
+
// The input should already be a validated element from the factory functions
|
|
571
|
+
// We just need to persist it
|
|
572
|
+
const element = input;
|
|
573
|
+
// Entity name uniqueness validation
|
|
574
|
+
if (element.type === 'entity') {
|
|
575
|
+
const entityData = element;
|
|
576
|
+
if (entityData.name) {
|
|
577
|
+
const existing = await this.lookupEntityByName(entityData.name);
|
|
578
|
+
if (existing) {
|
|
579
|
+
throw new ConflictError(`Entity with name "${entityData.name}" already exists`, ErrorCode.DUPLICATE_NAME, { name: entityData.name, existingId: existing.id });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
// Channel name uniqueness validation (group channels only)
|
|
584
|
+
if (element.type === 'channel') {
|
|
585
|
+
const channelData = element;
|
|
586
|
+
// Only validate group channels (direct channels have deterministic names)
|
|
587
|
+
if (channelData.channelType === ChannelTypeValue.GROUP && channelData.name) {
|
|
588
|
+
const visibility = channelData.permissions?.visibility ?? 'private';
|
|
589
|
+
// Check for existing channel with same name and visibility scope
|
|
590
|
+
const existingRow = this.backend.queryOne(`SELECT * FROM elements
|
|
591
|
+
WHERE type = 'channel'
|
|
592
|
+
AND JSON_EXTRACT(data, '$.channelType') = 'group'
|
|
593
|
+
AND JSON_EXTRACT(data, '$.name') = ?
|
|
594
|
+
AND JSON_EXTRACT(data, '$.permissions.visibility') = ?
|
|
595
|
+
AND deleted_at IS NULL`, [channelData.name, visibility]);
|
|
596
|
+
if (existingRow) {
|
|
597
|
+
throw new ConflictError(`Channel with name "${channelData.name}" already exists in ${visibility} scope`, ErrorCode.DUPLICATE_NAME, { name: channelData.name, visibility, existingId: existingRow.id });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// Message validation (sender membership, document refs, thread integrity)
|
|
602
|
+
if (element.type === 'message') {
|
|
603
|
+
const messageData = element;
|
|
604
|
+
// 1. Validate channel exists and sender is a member
|
|
605
|
+
const channelRow = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND deleted_at IS NULL`, [messageData.channelId]);
|
|
606
|
+
if (!channelRow) {
|
|
607
|
+
throw new NotFoundError(`Channel not found: ${messageData.channelId}`, ErrorCode.NOT_FOUND, { elementId: messageData.channelId });
|
|
608
|
+
}
|
|
609
|
+
const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [channelRow.id]);
|
|
610
|
+
const tags = tagRows.map((r) => r.tag);
|
|
611
|
+
const channel = deserializeElement(channelRow, tags);
|
|
612
|
+
if (!channel) {
|
|
613
|
+
throw new NotFoundError(`Channel data corrupt: ${messageData.channelId}`);
|
|
614
|
+
}
|
|
615
|
+
// Validate sender is a channel member
|
|
616
|
+
if (!isMember(channel, messageData.sender)) {
|
|
617
|
+
throw new NotAMemberError(channel.id, messageData.sender);
|
|
618
|
+
}
|
|
619
|
+
// 2. Validate contentRef points to a valid Document
|
|
620
|
+
const contentDoc = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND type = 'document' AND deleted_at IS NULL`, [messageData.contentRef]);
|
|
621
|
+
if (!contentDoc) {
|
|
622
|
+
throw new NotFoundError(`Content document not found: ${messageData.contentRef}`, ErrorCode.DOCUMENT_NOT_FOUND, { elementId: messageData.contentRef, field: 'contentRef' });
|
|
623
|
+
}
|
|
624
|
+
// 3. Validate all attachments point to valid Documents
|
|
625
|
+
if (messageData.attachments && messageData.attachments.length > 0) {
|
|
626
|
+
for (const attachmentId of messageData.attachments) {
|
|
627
|
+
const attachmentDoc = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND type = 'document' AND deleted_at IS NULL`, [attachmentId]);
|
|
628
|
+
if (!attachmentDoc) {
|
|
629
|
+
throw new NotFoundError(`Attachment document not found: ${attachmentId}`, ErrorCode.DOCUMENT_NOT_FOUND, { elementId: attachmentId, field: 'attachments' });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// 4. Validate threadId (if present) points to a message in the same channel
|
|
634
|
+
if (messageData.threadId !== null) {
|
|
635
|
+
const threadParent = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND type = 'message' AND deleted_at IS NULL`, [messageData.threadId]);
|
|
636
|
+
if (!threadParent) {
|
|
637
|
+
throw new NotFoundError(`Thread parent message not found: ${messageData.threadId}`, ErrorCode.NOT_FOUND, { elementId: messageData.threadId, field: 'threadId' });
|
|
638
|
+
}
|
|
639
|
+
// Deserialize to check channel
|
|
640
|
+
const parentTags = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [threadParent.id]);
|
|
641
|
+
const parentMessage = deserializeElement(threadParent, parentTags.map(r => r.tag));
|
|
642
|
+
if (!parentMessage) {
|
|
643
|
+
throw new NotFoundError(`Thread parent message data corrupt: ${messageData.threadId}`);
|
|
644
|
+
}
|
|
645
|
+
if (parentMessage.channelId !== messageData.channelId) {
|
|
646
|
+
throw new ConstraintError(`Thread parent message is in a different channel`, ErrorCode.INVALID_PARENT, {
|
|
647
|
+
field: 'threadId',
|
|
648
|
+
threadId: messageData.threadId,
|
|
649
|
+
threadChannelId: parentMessage.channelId,
|
|
650
|
+
messageChannelId: messageData.channelId,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// Serialize for storage
|
|
656
|
+
const serialized = serializeElement(element);
|
|
657
|
+
// Insert in a transaction
|
|
658
|
+
this.backend.transaction((tx) => {
|
|
659
|
+
// Insert the element
|
|
660
|
+
tx.run(`INSERT INTO elements (id, type, data, content_hash, created_at, updated_at, created_by, deleted_at)
|
|
661
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
662
|
+
serialized.id,
|
|
663
|
+
serialized.type,
|
|
664
|
+
serialized.data,
|
|
665
|
+
serialized.content_hash,
|
|
666
|
+
serialized.created_at,
|
|
667
|
+
serialized.updated_at,
|
|
668
|
+
serialized.created_by,
|
|
669
|
+
serialized.deleted_at,
|
|
670
|
+
]);
|
|
671
|
+
// Insert tags
|
|
672
|
+
if (element.tags.length > 0) {
|
|
673
|
+
for (const tag of element.tags) {
|
|
674
|
+
tx.run('INSERT INTO tags (element_id, tag) VALUES (?, ?)', [element.id, tag]);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// Record creation event
|
|
678
|
+
const event = createEvent({
|
|
679
|
+
elementId: element.id,
|
|
680
|
+
eventType: 'created',
|
|
681
|
+
actor: element.createdBy,
|
|
682
|
+
oldValue: null,
|
|
683
|
+
newValue: element,
|
|
684
|
+
});
|
|
685
|
+
tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
|
|
686
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
687
|
+
event.elementId,
|
|
688
|
+
event.eventType,
|
|
689
|
+
event.actor,
|
|
690
|
+
null,
|
|
691
|
+
JSON.stringify(event.newValue),
|
|
692
|
+
event.createdAt,
|
|
693
|
+
]);
|
|
694
|
+
// For messages with threadId, create a replies-to dependency
|
|
695
|
+
if (isMessage(element) && element.threadId !== null) {
|
|
696
|
+
const now = createTimestamp();
|
|
697
|
+
tx.run(`INSERT INTO dependencies (blocked_id, blocker_id, type, created_at, created_by, metadata)
|
|
698
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
699
|
+
element.id,
|
|
700
|
+
element.threadId,
|
|
701
|
+
'replies-to',
|
|
702
|
+
now,
|
|
703
|
+
element.sender,
|
|
704
|
+
null,
|
|
705
|
+
]);
|
|
706
|
+
// Record dependency_added event
|
|
707
|
+
const depEvent = createEvent({
|
|
708
|
+
elementId: element.id,
|
|
709
|
+
eventType: 'dependency_added',
|
|
710
|
+
actor: element.sender,
|
|
711
|
+
oldValue: null,
|
|
712
|
+
newValue: {
|
|
713
|
+
blockedId: element.id,
|
|
714
|
+
blockerId: element.threadId,
|
|
715
|
+
type: 'replies-to',
|
|
716
|
+
metadata: {},
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
|
|
720
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
721
|
+
depEvent.elementId,
|
|
722
|
+
depEvent.eventType,
|
|
723
|
+
depEvent.actor,
|
|
724
|
+
null,
|
|
725
|
+
JSON.stringify(depEvent.newValue),
|
|
726
|
+
depEvent.createdAt,
|
|
727
|
+
]);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
// Process mentions and inbox for messages
|
|
731
|
+
if (isMessage(element)) {
|
|
732
|
+
const messageData = element;
|
|
733
|
+
const messageMetadata = messageData.metadata;
|
|
734
|
+
// Skip inbox item creation if the message has suppressInbox flag set.
|
|
735
|
+
// This is used by dispatch notifications (task-assignment, task-reassignment)
|
|
736
|
+
// to prevent cluttering the operator/director's inbox.
|
|
737
|
+
const suppressInbox = messageMetadata?.suppressInbox === true;
|
|
738
|
+
// Get the channel to determine type and members
|
|
739
|
+
const channelRow = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND deleted_at IS NULL`, [messageData.channelId]);
|
|
740
|
+
if (channelRow) {
|
|
741
|
+
const channelTags = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [channelRow.id]);
|
|
742
|
+
const channel = deserializeElement(channelRow, channelTags.map((r) => r.tag));
|
|
743
|
+
if (!suppressInbox) {
|
|
744
|
+
// For direct channels: Create inbox item for the OTHER member (not the sender)
|
|
745
|
+
if (channel && isDirectChannel(channel)) {
|
|
746
|
+
for (const memberId of channel.members) {
|
|
747
|
+
// Skip the sender - they don't need an inbox item for their own message
|
|
748
|
+
if (memberId !== messageData.sender) {
|
|
749
|
+
try {
|
|
750
|
+
this.inboxService.addToInbox({
|
|
751
|
+
recipientId: memberId,
|
|
752
|
+
messageId: messageData.id,
|
|
753
|
+
channelId: messageData.channelId,
|
|
754
|
+
sourceType: InboxSourceType.DIRECT,
|
|
755
|
+
createdBy: messageData.sender,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
// Ignore errors (e.g., duplicate inbox item)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
// Parse and process @mentions from the content document
|
|
766
|
+
const contentDocRow = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND type = 'document' AND deleted_at IS NULL`, [messageData.contentRef]);
|
|
767
|
+
if (contentDocRow) {
|
|
768
|
+
const contentDoc = deserializeElement(contentDocRow, []);
|
|
769
|
+
const mentionedNames = contentDoc ? extractMentionedNames(contentDoc.content) : [];
|
|
770
|
+
if (mentionedNames.length > 0) {
|
|
771
|
+
// Get all entities to validate mentions against
|
|
772
|
+
const entityRows = this.backend.query(`SELECT * FROM elements WHERE type = 'entity' AND deleted_at IS NULL`, []);
|
|
773
|
+
const entities = [];
|
|
774
|
+
for (const row of entityRows) {
|
|
775
|
+
const entityTags = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
|
|
776
|
+
const entity = deserializeElement(row, entityTags.map((r) => r.tag));
|
|
777
|
+
if (entity)
|
|
778
|
+
entities.push(entity);
|
|
779
|
+
}
|
|
780
|
+
const { valid: validMentionIds } = validateMentions(mentionedNames, entities);
|
|
781
|
+
// Create mentions dependencies and inbox items for each valid mention
|
|
782
|
+
const now = createTimestamp();
|
|
783
|
+
for (const mentionedEntityId of validMentionIds) {
|
|
784
|
+
// Create 'mentions' dependency: message -> entity
|
|
785
|
+
try {
|
|
786
|
+
this.backend.run(`INSERT INTO dependencies (blocked_id, blocker_id, type, created_at, created_by, metadata)
|
|
787
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
788
|
+
messageData.id,
|
|
789
|
+
mentionedEntityId,
|
|
790
|
+
'mentions',
|
|
791
|
+
now,
|
|
792
|
+
messageData.sender,
|
|
793
|
+
null,
|
|
794
|
+
]);
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
// Ignore duplicate dependency errors
|
|
798
|
+
}
|
|
799
|
+
// Create inbox item for the mentioned entity (if not the sender)
|
|
800
|
+
if (!suppressInbox && mentionedEntityId !== messageData.sender) {
|
|
801
|
+
try {
|
|
802
|
+
this.inboxService.addToInbox({
|
|
803
|
+
recipientId: mentionedEntityId,
|
|
804
|
+
messageId: messageData.id,
|
|
805
|
+
channelId: messageData.channelId,
|
|
806
|
+
sourceType: InboxSourceType.MENTION,
|
|
807
|
+
createdBy: messageData.sender,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
// Ignore errors (e.g., duplicate inbox item if already added for direct message)
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
// For thread replies: Notify the parent message sender
|
|
818
|
+
if (!suppressInbox && messageData.threadId) {
|
|
819
|
+
const parentMessageRow = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND type = 'message' AND deleted_at IS NULL`, [messageData.threadId]);
|
|
820
|
+
if (parentMessageRow) {
|
|
821
|
+
const parentMessage = deserializeElement(parentMessageRow, []);
|
|
822
|
+
// Notify parent message sender (if not replying to yourself)
|
|
823
|
+
if (parentMessage && parentMessage.sender !== messageData.sender) {
|
|
824
|
+
try {
|
|
825
|
+
this.inboxService.addToInbox({
|
|
826
|
+
recipientId: parentMessage.sender,
|
|
827
|
+
messageId: messageData.id,
|
|
828
|
+
channelId: messageData.channelId,
|
|
829
|
+
sourceType: InboxSourceType.THREAD_REPLY,
|
|
830
|
+
createdBy: messageData.sender,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
catch {
|
|
834
|
+
// Ignore errors (e.g., duplicate inbox item)
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
// Mark as dirty for sync
|
|
842
|
+
this.backend.markDirty(element.id);
|
|
843
|
+
// Index document for FTS
|
|
844
|
+
if (isDocument(element)) {
|
|
845
|
+
this.indexDocumentForFTS(element);
|
|
846
|
+
}
|
|
847
|
+
return element;
|
|
848
|
+
}
|
|
849
|
+
async update(id, updates, options) {
|
|
850
|
+
// Get the existing element
|
|
851
|
+
const existing = await this.get(id);
|
|
852
|
+
if (!existing) {
|
|
853
|
+
throw new NotFoundError(`Element not found: ${id}`, ErrorCode.NOT_FOUND, { elementId: id });
|
|
854
|
+
}
|
|
855
|
+
// Optimistic concurrency check - fail if element was modified since it was read
|
|
856
|
+
if (options?.expectedUpdatedAt && existing.updatedAt !== options.expectedUpdatedAt) {
|
|
857
|
+
throw new ConflictError(`Element was modified by another process: ${id}. Expected updatedAt: ${options.expectedUpdatedAt}, actual: ${existing.updatedAt}`, ErrorCode.CONCURRENT_MODIFICATION, { elementId: id, expectedUpdatedAt: options.expectedUpdatedAt, actualUpdatedAt: existing.updatedAt });
|
|
858
|
+
}
|
|
859
|
+
// Check if element is immutable (Messages cannot be updated)
|
|
860
|
+
if (existing.type === 'message') {
|
|
861
|
+
throw new ConstraintError('Messages are immutable and cannot be updated', ErrorCode.IMMUTABLE, { elementId: id, type: 'message' });
|
|
862
|
+
}
|
|
863
|
+
// Check if document is immutable and content is being updated
|
|
864
|
+
if (isDocument(existing)) {
|
|
865
|
+
const doc = existing;
|
|
866
|
+
if (doc.immutable && updates.content !== undefined) {
|
|
867
|
+
throw new ConstraintError('Cannot update content of immutable document', ErrorCode.IMMUTABLE, { elementId: id, type: 'document' });
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
// Resolve actor - use provided actor or fall back to element's creator
|
|
871
|
+
const actor = options?.actor ?? existing.createdBy;
|
|
872
|
+
// Apply updates
|
|
873
|
+
const now = createTimestamp();
|
|
874
|
+
let updated = {
|
|
875
|
+
...existing,
|
|
876
|
+
...updates,
|
|
877
|
+
id: existing.id, // Cannot change ID
|
|
878
|
+
type: existing.type, // Cannot change type
|
|
879
|
+
createdAt: existing.createdAt, // Cannot change creation time
|
|
880
|
+
createdBy: existing.createdBy, // Cannot change creator
|
|
881
|
+
updatedAt: now,
|
|
882
|
+
};
|
|
883
|
+
// For documents, auto-increment version and link to previous version (only on content changes)
|
|
884
|
+
if (isDocument(existing)) {
|
|
885
|
+
const doc = existing;
|
|
886
|
+
const isContentUpdate = 'content' in updates || 'contentType' in updates;
|
|
887
|
+
if (isContentUpdate) {
|
|
888
|
+
updated = {
|
|
889
|
+
...updated,
|
|
890
|
+
version: doc.version + 1,
|
|
891
|
+
previousVersionId: doc.id,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// Serialize for storage
|
|
896
|
+
const serialized = serializeElement(updated);
|
|
897
|
+
// Update in a transaction
|
|
898
|
+
this.backend.transaction((tx) => {
|
|
899
|
+
// For documents, save current version to version history before updating (only on content changes)
|
|
900
|
+
if (isDocument(existing) && ('content' in updates || 'contentType' in updates)) {
|
|
901
|
+
const doc = existing;
|
|
902
|
+
// Serialize the current document data for version storage
|
|
903
|
+
const versionData = JSON.stringify({
|
|
904
|
+
contentType: doc.contentType,
|
|
905
|
+
content: doc.content,
|
|
906
|
+
version: doc.version,
|
|
907
|
+
previousVersionId: doc.previousVersionId,
|
|
908
|
+
createdBy: doc.createdBy,
|
|
909
|
+
tags: doc.tags,
|
|
910
|
+
metadata: doc.metadata,
|
|
911
|
+
title: doc.title,
|
|
912
|
+
category: doc.category,
|
|
913
|
+
status: doc.status,
|
|
914
|
+
immutable: doc.immutable,
|
|
915
|
+
});
|
|
916
|
+
tx.run(`INSERT INTO document_versions (id, version, data, created_at) VALUES (?, ?, ?, ?)`, [doc.id, doc.version, versionData, doc.updatedAt]);
|
|
917
|
+
}
|
|
918
|
+
// Update the element
|
|
919
|
+
tx.run(`UPDATE elements SET data = ?, content_hash = ?, updated_at = ?, deleted_at = ?
|
|
920
|
+
WHERE id = ?`, [serialized.data, serialized.content_hash, serialized.updated_at, serialized.deleted_at, id]);
|
|
921
|
+
// Update tags if they changed
|
|
922
|
+
if (updates.tags !== undefined) {
|
|
923
|
+
// Remove old tags
|
|
924
|
+
tx.run('DELETE FROM tags WHERE element_id = ?', [id]);
|
|
925
|
+
// Insert new tags
|
|
926
|
+
for (const tag of updated.tags) {
|
|
927
|
+
tx.run('INSERT INTO tags (element_id, tag) VALUES (?, ?)', [id, tag]);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
// Determine the appropriate event type based on status changes
|
|
931
|
+
const existingData = existing;
|
|
932
|
+
const updatedData = updated;
|
|
933
|
+
const oldStatus = existingData.status;
|
|
934
|
+
const newStatus = updatedData.status;
|
|
935
|
+
let eventType = LifecycleEventType.UPDATED;
|
|
936
|
+
if (oldStatus !== newStatus && newStatus !== undefined) {
|
|
937
|
+
// Handle Task status changes
|
|
938
|
+
if (isTask(existing)) {
|
|
939
|
+
if (newStatus === TaskStatusEnum.CLOSED) {
|
|
940
|
+
// Transitioning TO closed status
|
|
941
|
+
eventType = LifecycleEventType.CLOSED;
|
|
942
|
+
}
|
|
943
|
+
else if (oldStatus === TaskStatusEnum.CLOSED) {
|
|
944
|
+
// Transitioning FROM closed status (reopening)
|
|
945
|
+
eventType = LifecycleEventType.REOPENED;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
// Handle Plan status changes
|
|
949
|
+
else if (isPlan(existing)) {
|
|
950
|
+
if (newStatus === PlanStatusEnum.COMPLETED || newStatus === PlanStatusEnum.CANCELLED) {
|
|
951
|
+
// Transitioning TO completed or cancelled status (terminal states)
|
|
952
|
+
eventType = LifecycleEventType.CLOSED;
|
|
953
|
+
}
|
|
954
|
+
else if (oldStatus === PlanStatusEnum.COMPLETED || oldStatus === PlanStatusEnum.CANCELLED) {
|
|
955
|
+
// Transitioning FROM completed/cancelled status (reopening/restarting)
|
|
956
|
+
eventType = LifecycleEventType.REOPENED;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
// Handle Workflow status changes
|
|
960
|
+
else if (isWorkflow(existing)) {
|
|
961
|
+
const terminalStatuses = [
|
|
962
|
+
WorkflowStatusEnum.COMPLETED,
|
|
963
|
+
WorkflowStatusEnum.FAILED,
|
|
964
|
+
WorkflowStatusEnum.CANCELLED,
|
|
965
|
+
];
|
|
966
|
+
if (terminalStatuses.includes(newStatus)) {
|
|
967
|
+
// Transitioning TO completed, failed, or cancelled status (terminal states)
|
|
968
|
+
eventType = LifecycleEventType.CLOSED;
|
|
969
|
+
}
|
|
970
|
+
else if (terminalStatuses.includes(oldStatus)) {
|
|
971
|
+
// Transitioning FROM a terminal status (restarting - though not normally allowed by workflow transitions)
|
|
972
|
+
eventType = LifecycleEventType.REOPENED;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
// Handle Document status changes
|
|
976
|
+
else if (isDocument(existing)) {
|
|
977
|
+
if (newStatus === 'archived') {
|
|
978
|
+
eventType = LifecycleEventType.CLOSED;
|
|
979
|
+
}
|
|
980
|
+
else if (oldStatus === 'archived' && newStatus === 'active') {
|
|
981
|
+
eventType = LifecycleEventType.REOPENED;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
// Record the event with the determined type
|
|
986
|
+
const event = createEvent({
|
|
987
|
+
elementId: id,
|
|
988
|
+
eventType,
|
|
989
|
+
actor,
|
|
990
|
+
oldValue: existing,
|
|
991
|
+
newValue: updated,
|
|
992
|
+
});
|
|
993
|
+
tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
|
|
994
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
995
|
+
event.elementId,
|
|
996
|
+
event.eventType,
|
|
997
|
+
event.actor,
|
|
998
|
+
JSON.stringify(event.oldValue),
|
|
999
|
+
JSON.stringify(event.newValue),
|
|
1000
|
+
event.createdAt,
|
|
1001
|
+
]);
|
|
1002
|
+
});
|
|
1003
|
+
// Mark as dirty for sync
|
|
1004
|
+
this.backend.markDirty(id);
|
|
1005
|
+
// Check if status changed and update blocked cache
|
|
1006
|
+
const existingDataPost = existing;
|
|
1007
|
+
const updatedDataPost = updated;
|
|
1008
|
+
const oldStatusPost = existingDataPost.status;
|
|
1009
|
+
const newStatusPost = updatedDataPost.status;
|
|
1010
|
+
if (oldStatusPost !== newStatusPost && newStatusPost !== undefined) {
|
|
1011
|
+
this.blockedCache.onStatusChanged(id, oldStatusPost ?? null, newStatusPost);
|
|
1012
|
+
}
|
|
1013
|
+
// Re-index document for FTS only when content-relevant fields change
|
|
1014
|
+
if (isDocument(updated)) {
|
|
1015
|
+
const ftsRelevantUpdate = 'content' in updates || 'contentType' in updates ||
|
|
1016
|
+
'tags' in updates || 'category' in updates || 'metadata' in updates || 'title' in updates;
|
|
1017
|
+
if (ftsRelevantUpdate) {
|
|
1018
|
+
this.indexDocumentForFTS(updated);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return updated;
|
|
1022
|
+
}
|
|
1023
|
+
async delete(id, options) {
|
|
1024
|
+
// Get the existing element
|
|
1025
|
+
const existing = await this.get(id);
|
|
1026
|
+
if (!existing) {
|
|
1027
|
+
throw new NotFoundError(`Element not found: ${id}`, ErrorCode.NOT_FOUND, { elementId: id });
|
|
1028
|
+
}
|
|
1029
|
+
// Check if element is immutable (Messages cannot be deleted)
|
|
1030
|
+
if (existing.type === 'message') {
|
|
1031
|
+
throw new ConstraintError('Messages are immutable and cannot be deleted', ErrorCode.IMMUTABLE, { elementId: id, type: 'message' });
|
|
1032
|
+
}
|
|
1033
|
+
// Resolve actor - use provided actor or fall back to element's creator
|
|
1034
|
+
const actor = options?.actor ?? existing.createdBy;
|
|
1035
|
+
const reason = options?.reason;
|
|
1036
|
+
const now = createTimestamp();
|
|
1037
|
+
// Collect elements that will need cache updates BEFORE deleting dependencies
|
|
1038
|
+
// For `blocks` deps: when deleting the source (blocker), targets become unblocked
|
|
1039
|
+
const affectedTargets = this.backend.query(`SELECT DISTINCT blocked_id FROM dependencies WHERE blocker_id = ? AND type = 'blocks'`, [id]).map(row => row.blocked_id);
|
|
1040
|
+
// For `parent-child` and `awaits` deps: when deleting the target, sources need recheck
|
|
1041
|
+
const affectedSources = this.backend.query(`SELECT DISTINCT blocked_id FROM dependencies WHERE blocker_id = ? AND type IN ('parent-child', 'awaits')`, [id]).map(row => row.blocked_id);
|
|
1042
|
+
// Soft delete by setting deleted_at and updating status to tombstone
|
|
1043
|
+
this.backend.transaction((tx) => {
|
|
1044
|
+
// Get current data and update status
|
|
1045
|
+
const data = JSON.parse((this.backend.queryOne('SELECT data FROM elements WHERE id = ?', [id]))?.data ?? '{}');
|
|
1046
|
+
data.status = 'tombstone';
|
|
1047
|
+
data.deletedAt = now;
|
|
1048
|
+
data.deleteReason = reason;
|
|
1049
|
+
tx.run(`UPDATE elements SET data = ?, updated_at = ?, deleted_at = ?
|
|
1050
|
+
WHERE id = ?`, [JSON.stringify(data), now, now, id]);
|
|
1051
|
+
// Cascade delete: Remove all dependencies where this element is the source or target
|
|
1052
|
+
// This prevents orphan dependency records pointing to/from deleted elements
|
|
1053
|
+
tx.run('DELETE FROM dependencies WHERE blocked_id = ?', [id]);
|
|
1054
|
+
tx.run('DELETE FROM dependencies WHERE blocker_id = ?', [id]);
|
|
1055
|
+
// Record delete event with the resolved actor
|
|
1056
|
+
const event = createEvent({
|
|
1057
|
+
elementId: id,
|
|
1058
|
+
eventType: 'deleted',
|
|
1059
|
+
actor,
|
|
1060
|
+
oldValue: existing,
|
|
1061
|
+
newValue: null,
|
|
1062
|
+
});
|
|
1063
|
+
tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
|
|
1064
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
1065
|
+
event.elementId,
|
|
1066
|
+
event.eventType,
|
|
1067
|
+
event.actor,
|
|
1068
|
+
JSON.stringify(event.oldValue),
|
|
1069
|
+
reason ? JSON.stringify({ reason }) : null,
|
|
1070
|
+
event.createdAt,
|
|
1071
|
+
]);
|
|
1072
|
+
// Clean up document-specific data (inside transaction for atomicity)
|
|
1073
|
+
if (existing.type === 'document') {
|
|
1074
|
+
tx.run('DELETE FROM document_versions WHERE id = ?', [id]);
|
|
1075
|
+
tx.run('DELETE FROM comments WHERE document_id = ?', [id]);
|
|
1076
|
+
if (this.checkFTSAvailable()) {
|
|
1077
|
+
try {
|
|
1078
|
+
tx.run('DELETE FROM documents_fts WHERE document_id = ?', [id]);
|
|
1079
|
+
}
|
|
1080
|
+
catch (error) {
|
|
1081
|
+
console.warn(`[stoneforge] FTS removal failed for ${id}:`, error);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
// Mark as dirty for sync
|
|
1087
|
+
this.backend.markDirty(id);
|
|
1088
|
+
// Remove embedding (outside transaction — async/best-effort, doesn't affect DB consistency)
|
|
1089
|
+
if (existing.type === 'document' && this.embeddingService) {
|
|
1090
|
+
try {
|
|
1091
|
+
this.embeddingService.removeDocument(id);
|
|
1092
|
+
}
|
|
1093
|
+
catch (error) {
|
|
1094
|
+
console.warn(`[stoneforge] Embedding removal failed for ${id}:`, error);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
// Update blocked cache for the deleted element and all affected elements
|
|
1098
|
+
// This must happen AFTER the transaction so the element is already tombstoned
|
|
1099
|
+
this.blockedCache.removeBlocked(id);
|
|
1100
|
+
for (const blockerId of affectedTargets) {
|
|
1101
|
+
this.blockedCache.invalidateElement(blockerId);
|
|
1102
|
+
}
|
|
1103
|
+
for (const blockedId of affectedSources) {
|
|
1104
|
+
this.blockedCache.invalidateElement(blockedId);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
// --------------------------------------------------------------------------
|
|
1108
|
+
// Entity Operations
|
|
1109
|
+
// --------------------------------------------------------------------------
|
|
1110
|
+
async lookupEntityByName(name) {
|
|
1111
|
+
// Query for entity with matching name in data JSON
|
|
1112
|
+
const row = this.backend.queryOne(`SELECT * FROM elements
|
|
1113
|
+
WHERE type = 'entity'
|
|
1114
|
+
AND JSON_EXTRACT(data, '$.name') = ?
|
|
1115
|
+
AND deleted_at IS NULL`, [name]);
|
|
1116
|
+
if (!row) {
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
// Get tags for this element
|
|
1120
|
+
const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
|
|
1121
|
+
const tags = tagRows.map((r) => r.tag);
|
|
1122
|
+
return deserializeElement(row, tags);
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Sets the manager (reportsTo) for an entity.
|
|
1126
|
+
*
|
|
1127
|
+
* Validates:
|
|
1128
|
+
* - Entity exists and is an entity type
|
|
1129
|
+
* - Manager entity exists and is active
|
|
1130
|
+
* - No self-reference (entity cannot report to itself)
|
|
1131
|
+
* - No circular chains
|
|
1132
|
+
*
|
|
1133
|
+
* @param entityId - The entity to set the manager for
|
|
1134
|
+
* @param managerId - The manager entity ID
|
|
1135
|
+
* @param actor - Entity performing this action (for audit trail)
|
|
1136
|
+
* @returns The updated entity
|
|
1137
|
+
*/
|
|
1138
|
+
async setEntityManager(entityId, managerId, actor) {
|
|
1139
|
+
// Get the entity (cast through unknown since EntityId and ElementId are different branded types)
|
|
1140
|
+
const entity = await this.get(entityId);
|
|
1141
|
+
if (!entity) {
|
|
1142
|
+
throw new NotFoundError(`Entity not found: ${entityId}`, ErrorCode.ENTITY_NOT_FOUND, { elementId: entityId });
|
|
1143
|
+
}
|
|
1144
|
+
if (entity.type !== 'entity') {
|
|
1145
|
+
throw new ConstraintError(`Element is not an entity: ${entityId}`, ErrorCode.TYPE_MISMATCH, { elementId: entityId, actualType: entity.type, expectedType: 'entity' });
|
|
1146
|
+
}
|
|
1147
|
+
// Create a getEntity function for validation
|
|
1148
|
+
const getEntity = (id) => {
|
|
1149
|
+
const row = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND type = 'entity' AND deleted_at IS NULL`, [id]);
|
|
1150
|
+
if (!row)
|
|
1151
|
+
return null;
|
|
1152
|
+
const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
|
|
1153
|
+
return deserializeElement(row, tagRows.map((r) => r.tag));
|
|
1154
|
+
};
|
|
1155
|
+
// Validate the manager assignment
|
|
1156
|
+
const validation = validateManager(entityId, managerId, getEntity);
|
|
1157
|
+
if (!validation.valid) {
|
|
1158
|
+
switch (validation.errorCode) {
|
|
1159
|
+
case 'SELF_REFERENCE':
|
|
1160
|
+
throw new ValidationError(validation.errorMessage, ErrorCode.INVALID_INPUT, { entityId, managerId });
|
|
1161
|
+
case 'ENTITY_NOT_FOUND':
|
|
1162
|
+
throw new NotFoundError(validation.errorMessage, ErrorCode.ENTITY_NOT_FOUND, { elementId: managerId });
|
|
1163
|
+
case 'ENTITY_DEACTIVATED':
|
|
1164
|
+
throw new ValidationError(validation.errorMessage, ErrorCode.INVALID_INPUT, { entityId, managerId, reason: 'manager_deactivated' });
|
|
1165
|
+
case 'CYCLE_DETECTED':
|
|
1166
|
+
throw new ConflictError(validation.errorMessage, ErrorCode.CYCLE_DETECTED, { entityId, managerId, cyclePath: validation.cyclePath });
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
// Update the entity with the new reportsTo value
|
|
1170
|
+
const updatedEntity = updateEntity(entity, { reportsTo: managerId });
|
|
1171
|
+
// Save the updated entity
|
|
1172
|
+
await this.update(entityId, updatedEntity, { actor });
|
|
1173
|
+
return updatedEntity;
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Clears the manager (reportsTo) for an entity.
|
|
1177
|
+
*
|
|
1178
|
+
* @param entityId - The entity to clear the manager for
|
|
1179
|
+
* @param actor - Entity performing this action (for audit trail)
|
|
1180
|
+
* @returns The updated entity
|
|
1181
|
+
*/
|
|
1182
|
+
async clearEntityManager(entityId, actor) {
|
|
1183
|
+
// Get the entity (cast through unknown since EntityId and ElementId are different branded types)
|
|
1184
|
+
const entity = await this.get(entityId);
|
|
1185
|
+
if (!entity) {
|
|
1186
|
+
throw new NotFoundError(`Entity not found: ${entityId}`, ErrorCode.ENTITY_NOT_FOUND, { elementId: entityId });
|
|
1187
|
+
}
|
|
1188
|
+
if (entity.type !== 'entity') {
|
|
1189
|
+
throw new ConstraintError(`Element is not an entity: ${entityId}`, ErrorCode.TYPE_MISMATCH, { elementId: entityId, actualType: entity.type, expectedType: 'entity' });
|
|
1190
|
+
}
|
|
1191
|
+
// Update the entity with null reportsTo (clears it)
|
|
1192
|
+
const updatedEntity = updateEntity(entity, { reportsTo: null });
|
|
1193
|
+
// Save the updated entity
|
|
1194
|
+
await this.update(entityId, updatedEntity, { actor });
|
|
1195
|
+
return updatedEntity;
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Gets all entities that report directly to a manager.
|
|
1199
|
+
*
|
|
1200
|
+
* @param managerId - The manager entity ID
|
|
1201
|
+
* @returns Array of entities that report to the manager
|
|
1202
|
+
*/
|
|
1203
|
+
async getDirectReports(managerId) {
|
|
1204
|
+
// Query for entities where reportsTo matches the managerId
|
|
1205
|
+
const rows = this.backend.query(`SELECT * FROM elements
|
|
1206
|
+
WHERE type = 'entity'
|
|
1207
|
+
AND JSON_EXTRACT(data, '$.reportsTo') = ?
|
|
1208
|
+
AND deleted_at IS NULL`, [managerId]);
|
|
1209
|
+
// Get tags for each entity
|
|
1210
|
+
const entities = [];
|
|
1211
|
+
for (const row of rows) {
|
|
1212
|
+
const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
|
|
1213
|
+
const entity = deserializeElement(row, tagRows.map((r) => r.tag));
|
|
1214
|
+
if (entity)
|
|
1215
|
+
entities.push(entity);
|
|
1216
|
+
}
|
|
1217
|
+
return entities;
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Gets the management chain for an entity (from entity up to root).
|
|
1221
|
+
*
|
|
1222
|
+
* Returns an ordered array starting with the entity's direct manager
|
|
1223
|
+
* and ending with the root entity (an entity with no reportsTo).
|
|
1224
|
+
*
|
|
1225
|
+
* @param entityId - The entity to get the management chain for
|
|
1226
|
+
* @returns Array of entities in the management chain (empty if no manager)
|
|
1227
|
+
*/
|
|
1228
|
+
async getManagementChain(entityId) {
|
|
1229
|
+
// Get the entity (cast through unknown since EntityId and ElementId are different branded types)
|
|
1230
|
+
const entity = await this.get(entityId);
|
|
1231
|
+
if (!entity) {
|
|
1232
|
+
throw new NotFoundError(`Entity not found: ${entityId}`, ErrorCode.ENTITY_NOT_FOUND, { elementId: entityId });
|
|
1233
|
+
}
|
|
1234
|
+
if (entity.type !== 'entity') {
|
|
1235
|
+
throw new ConstraintError(`Element is not an entity: ${entityId}`, ErrorCode.TYPE_MISMATCH, { elementId: entityId, actualType: entity.type, expectedType: 'entity' });
|
|
1236
|
+
}
|
|
1237
|
+
// Create a getEntity function
|
|
1238
|
+
const getEntity = (id) => {
|
|
1239
|
+
const row = this.backend.queryOne(`SELECT * FROM elements WHERE id = ? AND type = 'entity' AND deleted_at IS NULL`, [id]);
|
|
1240
|
+
if (!row)
|
|
1241
|
+
return null;
|
|
1242
|
+
const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
|
|
1243
|
+
return deserializeElement(row, tagRows.map((r) => r.tag));
|
|
1244
|
+
};
|
|
1245
|
+
return getManagementChainUtil(entity, getEntity);
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Gets the organizational chart structure.
|
|
1249
|
+
*
|
|
1250
|
+
* @param rootId - Optional root entity ID (if not provided, returns all root entities)
|
|
1251
|
+
* @returns Array of org chart nodes (hierarchical structure)
|
|
1252
|
+
*/
|
|
1253
|
+
async getOrgChart(rootId) {
|
|
1254
|
+
// Get all entities
|
|
1255
|
+
const rows = this.backend.query(`SELECT * FROM elements
|
|
1256
|
+
WHERE type = 'entity'
|
|
1257
|
+
AND deleted_at IS NULL`, []);
|
|
1258
|
+
// Get all entities with tags
|
|
1259
|
+
const entities = [];
|
|
1260
|
+
for (const row of rows) {
|
|
1261
|
+
const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
|
|
1262
|
+
const entity = deserializeElement(row, tagRows.map((r) => r.tag));
|
|
1263
|
+
// Only include active entities
|
|
1264
|
+
if (entity && isEntityActive(entity)) {
|
|
1265
|
+
entities.push(entity);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return buildOrgChart(entities, rootId);
|
|
1269
|
+
}
|
|
1270
|
+
// --------------------------------------------------------------------------
|
|
1271
|
+
// Plan Operations
|
|
1272
|
+
// --------------------------------------------------------------------------
|
|
1273
|
+
async addTaskToPlan(taskId, planId, options) {
|
|
1274
|
+
// Verify task exists and is a task
|
|
1275
|
+
const task = await this.get(taskId);
|
|
1276
|
+
if (!task) {
|
|
1277
|
+
throw new NotFoundError(`Task not found: ${taskId}`, ErrorCode.NOT_FOUND, { elementId: taskId });
|
|
1278
|
+
}
|
|
1279
|
+
if (task.type !== 'task') {
|
|
1280
|
+
throw new ConstraintError(`Element is not a task: ${taskId}`, ErrorCode.TYPE_MISMATCH, { elementId: taskId, actualType: task.type, expectedType: 'task' });
|
|
1281
|
+
}
|
|
1282
|
+
// Verify plan exists and is a plan
|
|
1283
|
+
const plan = await this.get(planId);
|
|
1284
|
+
if (!plan) {
|
|
1285
|
+
throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
|
|
1286
|
+
}
|
|
1287
|
+
if (plan.type !== 'plan') {
|
|
1288
|
+
throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
|
|
1289
|
+
}
|
|
1290
|
+
// Check if task is already in any plan
|
|
1291
|
+
const existingParentDeps = await this.getDependencies(taskId, ['parent-child']);
|
|
1292
|
+
if (existingParentDeps.length > 0) {
|
|
1293
|
+
const existingPlanId = existingParentDeps[0].blockerId;
|
|
1294
|
+
throw new ConstraintError(`Task is already in plan: ${existingPlanId}`, ErrorCode.ALREADY_IN_PLAN, { taskId, existingPlanId });
|
|
1295
|
+
}
|
|
1296
|
+
// Resolve actor
|
|
1297
|
+
const actor = options?.actor ?? task.createdBy;
|
|
1298
|
+
// Create parent-child dependency from task to plan
|
|
1299
|
+
const dependency = await this.addDependency({
|
|
1300
|
+
blockedId: taskId,
|
|
1301
|
+
blockerId: planId,
|
|
1302
|
+
type: 'parent-child',
|
|
1303
|
+
actor,
|
|
1304
|
+
});
|
|
1305
|
+
return dependency;
|
|
1306
|
+
}
|
|
1307
|
+
async removeTaskFromPlan(taskId, planId, actor) {
|
|
1308
|
+
// Check if the task-plan relationship exists
|
|
1309
|
+
const existingDeps = await this.getDependencies(taskId, ['parent-child']);
|
|
1310
|
+
const hasRelation = existingDeps.some((d) => d.blockerId === planId);
|
|
1311
|
+
if (!hasRelation) {
|
|
1312
|
+
throw new NotFoundError(`Task ${taskId} is not in plan ${planId}`, ErrorCode.DEPENDENCY_NOT_FOUND, { taskId, planId });
|
|
1313
|
+
}
|
|
1314
|
+
// Remove the parent-child dependency
|
|
1315
|
+
await this.removeDependency(taskId, planId, 'parent-child', actor);
|
|
1316
|
+
}
|
|
1317
|
+
async getTasksInPlan(planId, filter) {
|
|
1318
|
+
// Verify plan exists
|
|
1319
|
+
const plan = await this.get(planId);
|
|
1320
|
+
if (!plan) {
|
|
1321
|
+
throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
|
|
1322
|
+
}
|
|
1323
|
+
if (plan.type !== 'plan') {
|
|
1324
|
+
throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
|
|
1325
|
+
}
|
|
1326
|
+
// Get all elements that have parent-child dependency to this plan
|
|
1327
|
+
const dependents = await this.getDependents(planId, ['parent-child']);
|
|
1328
|
+
// If no dependents, return empty array
|
|
1329
|
+
if (dependents.length === 0) {
|
|
1330
|
+
return [];
|
|
1331
|
+
}
|
|
1332
|
+
// Fetch tasks by their IDs
|
|
1333
|
+
const taskIds = dependents.map((d) => d.blockedId);
|
|
1334
|
+
const tasks = [];
|
|
1335
|
+
for (const taskId of taskIds) {
|
|
1336
|
+
const task = await this.get(taskId);
|
|
1337
|
+
if (task && task.type === 'task') {
|
|
1338
|
+
tasks.push(task);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
// Apply filters if provided
|
|
1342
|
+
let filteredTasks = tasks;
|
|
1343
|
+
if (filter?.status) {
|
|
1344
|
+
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
|
|
1345
|
+
filteredTasks = filteredTasks.filter((t) => statuses.includes(t.status));
|
|
1346
|
+
}
|
|
1347
|
+
if (filter?.priority) {
|
|
1348
|
+
const priorities = Array.isArray(filter.priority) ? filter.priority : [filter.priority];
|
|
1349
|
+
filteredTasks = filteredTasks.filter((t) => priorities.includes(t.priority));
|
|
1350
|
+
}
|
|
1351
|
+
if (filter?.assignee) {
|
|
1352
|
+
filteredTasks = filteredTasks.filter((t) => t.assignee === filter.assignee);
|
|
1353
|
+
}
|
|
1354
|
+
if (filter?.owner) {
|
|
1355
|
+
filteredTasks = filteredTasks.filter((t) => t.owner === filter.owner);
|
|
1356
|
+
}
|
|
1357
|
+
if (filter?.tags && filter.tags.length > 0) {
|
|
1358
|
+
filteredTasks = filteredTasks.filter((t) => filter.tags.every((tag) => t.tags.includes(tag)));
|
|
1359
|
+
}
|
|
1360
|
+
if (filter?.includeDeleted !== true) {
|
|
1361
|
+
filteredTasks = filteredTasks.filter((t) => t.status !== 'tombstone');
|
|
1362
|
+
}
|
|
1363
|
+
return filteredTasks;
|
|
1364
|
+
}
|
|
1365
|
+
async getPlanProgress(planId) {
|
|
1366
|
+
// Verify plan exists
|
|
1367
|
+
const plan = await this.get(planId);
|
|
1368
|
+
if (!plan) {
|
|
1369
|
+
throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
|
|
1370
|
+
}
|
|
1371
|
+
if (plan.type !== 'plan') {
|
|
1372
|
+
throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
|
|
1373
|
+
}
|
|
1374
|
+
// Get all tasks in the plan (excluding tombstones)
|
|
1375
|
+
const tasks = await this.getTasksInPlan(planId, { includeDeleted: false });
|
|
1376
|
+
// Count tasks by status
|
|
1377
|
+
const statusCounts = {
|
|
1378
|
+
open: 0,
|
|
1379
|
+
in_progress: 0,
|
|
1380
|
+
blocked: 0,
|
|
1381
|
+
closed: 0,
|
|
1382
|
+
deferred: 0,
|
|
1383
|
+
tombstone: 0,
|
|
1384
|
+
};
|
|
1385
|
+
for (const task of tasks) {
|
|
1386
|
+
if (task.status in statusCounts) {
|
|
1387
|
+
statusCounts[task.status]++;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
// Use the calculatePlanProgress utility
|
|
1391
|
+
return calculatePlanProgress(statusCounts);
|
|
1392
|
+
}
|
|
1393
|
+
async createTaskInPlan(planId, taskInput, options) {
|
|
1394
|
+
// Verify plan exists
|
|
1395
|
+
const plan = await this.get(planId);
|
|
1396
|
+
if (!plan) {
|
|
1397
|
+
throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
|
|
1398
|
+
}
|
|
1399
|
+
if (plan.type !== 'plan') {
|
|
1400
|
+
throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
|
|
1401
|
+
}
|
|
1402
|
+
// Check plan is in valid status for adding tasks
|
|
1403
|
+
if (plan.status !== PlanStatusEnum.DRAFT && plan.status !== PlanStatusEnum.ACTIVE) {
|
|
1404
|
+
throw new ValidationError(`Cannot add tasks to plan in status: ${plan.status}`, ErrorCode.INVALID_STATUS, { planId, status: plan.status, allowedStatuses: ['draft', 'active'] });
|
|
1405
|
+
}
|
|
1406
|
+
// Generate hierarchical ID if requested (default: true)
|
|
1407
|
+
const useHierarchical = options?.useHierarchicalId !== false;
|
|
1408
|
+
let taskId;
|
|
1409
|
+
if (useHierarchical) {
|
|
1410
|
+
// Get next child number atomically
|
|
1411
|
+
const childNumber = this.backend.getNextChildNumber(planId);
|
|
1412
|
+
taskId = generateChildId(planId, childNumber);
|
|
1413
|
+
}
|
|
1414
|
+
// Create a properly-formed task using the createTask factory
|
|
1415
|
+
const taskElement = await createTask({
|
|
1416
|
+
...taskInput,
|
|
1417
|
+
id: taskId,
|
|
1418
|
+
});
|
|
1419
|
+
const task = await this.create(taskElement);
|
|
1420
|
+
// Create parent-child dependency
|
|
1421
|
+
const actor = options?.actor ?? taskInput.createdBy;
|
|
1422
|
+
await this.addDependency({
|
|
1423
|
+
blockedId: task.id,
|
|
1424
|
+
blockerId: planId,
|
|
1425
|
+
type: 'parent-child',
|
|
1426
|
+
actor,
|
|
1427
|
+
});
|
|
1428
|
+
return task;
|
|
1429
|
+
}
|
|
1430
|
+
// --------------------------------------------------------------------------
|
|
1431
|
+
// Plan Bulk Operations
|
|
1432
|
+
// --------------------------------------------------------------------------
|
|
1433
|
+
async bulkClosePlanTasks(planId, options) {
|
|
1434
|
+
// Verify plan exists
|
|
1435
|
+
const plan = await this.get(planId);
|
|
1436
|
+
if (!plan) {
|
|
1437
|
+
throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
|
|
1438
|
+
}
|
|
1439
|
+
if (plan.type !== 'plan') {
|
|
1440
|
+
throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
|
|
1441
|
+
}
|
|
1442
|
+
// Get all tasks in the plan
|
|
1443
|
+
const tasks = await this.getTasksInPlan(planId, options?.filter);
|
|
1444
|
+
const result = {
|
|
1445
|
+
updated: 0,
|
|
1446
|
+
skipped: 0,
|
|
1447
|
+
updatedIds: [],
|
|
1448
|
+
skippedIds: [],
|
|
1449
|
+
errors: [],
|
|
1450
|
+
};
|
|
1451
|
+
const actor = options?.actor ?? plan.createdBy;
|
|
1452
|
+
const closeReason = options?.closeReason;
|
|
1453
|
+
for (const task of tasks) {
|
|
1454
|
+
// Skip tasks that are already closed or tombstoned
|
|
1455
|
+
if (task.status === TaskStatusEnum.CLOSED || task.status === TaskStatusEnum.TOMBSTONE) {
|
|
1456
|
+
result.skipped++;
|
|
1457
|
+
result.skippedIds.push(task.id);
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
try {
|
|
1461
|
+
// Update task status to closed
|
|
1462
|
+
const updates = {
|
|
1463
|
+
status: TaskStatusEnum.CLOSED,
|
|
1464
|
+
closedAt: createTimestamp(),
|
|
1465
|
+
};
|
|
1466
|
+
if (closeReason) {
|
|
1467
|
+
updates.closeReason = closeReason;
|
|
1468
|
+
}
|
|
1469
|
+
await this.update(task.id, updates, { actor });
|
|
1470
|
+
result.updated++;
|
|
1471
|
+
result.updatedIds.push(task.id);
|
|
1472
|
+
}
|
|
1473
|
+
catch (error) {
|
|
1474
|
+
result.errors.push({
|
|
1475
|
+
taskId: task.id,
|
|
1476
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
return result;
|
|
1481
|
+
}
|
|
1482
|
+
async bulkDeferPlanTasks(planId, options) {
|
|
1483
|
+
// Verify plan exists
|
|
1484
|
+
const plan = await this.get(planId);
|
|
1485
|
+
if (!plan) {
|
|
1486
|
+
throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
|
|
1487
|
+
}
|
|
1488
|
+
if (plan.type !== 'plan') {
|
|
1489
|
+
throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
|
|
1490
|
+
}
|
|
1491
|
+
// Get all tasks in the plan
|
|
1492
|
+
const tasks = await this.getTasksInPlan(planId, options?.filter);
|
|
1493
|
+
const result = {
|
|
1494
|
+
updated: 0,
|
|
1495
|
+
skipped: 0,
|
|
1496
|
+
updatedIds: [],
|
|
1497
|
+
skippedIds: [],
|
|
1498
|
+
errors: [],
|
|
1499
|
+
};
|
|
1500
|
+
const actor = options?.actor ?? plan.createdBy;
|
|
1501
|
+
// Valid statuses for defer transition
|
|
1502
|
+
const deferableStatuses = [TaskStatusEnum.OPEN, TaskStatusEnum.IN_PROGRESS, TaskStatusEnum.BLOCKED];
|
|
1503
|
+
for (const task of tasks) {
|
|
1504
|
+
// Skip tasks that can't be deferred
|
|
1505
|
+
if (!deferableStatuses.includes(task.status)) {
|
|
1506
|
+
result.skipped++;
|
|
1507
|
+
result.skippedIds.push(task.id);
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
try {
|
|
1511
|
+
await this.update(task.id, { status: TaskStatusEnum.DEFERRED }, { actor });
|
|
1512
|
+
result.updated++;
|
|
1513
|
+
result.updatedIds.push(task.id);
|
|
1514
|
+
}
|
|
1515
|
+
catch (error) {
|
|
1516
|
+
result.errors.push({
|
|
1517
|
+
taskId: task.id,
|
|
1518
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
return result;
|
|
1523
|
+
}
|
|
1524
|
+
async bulkReassignPlanTasks(planId, newAssignee, options) {
|
|
1525
|
+
// Verify plan exists
|
|
1526
|
+
const plan = await this.get(planId);
|
|
1527
|
+
if (!plan) {
|
|
1528
|
+
throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
|
|
1529
|
+
}
|
|
1530
|
+
if (plan.type !== 'plan') {
|
|
1531
|
+
throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
|
|
1532
|
+
}
|
|
1533
|
+
// Get all tasks in the plan
|
|
1534
|
+
const tasks = await this.getTasksInPlan(planId, options?.filter);
|
|
1535
|
+
const result = {
|
|
1536
|
+
updated: 0,
|
|
1537
|
+
skipped: 0,
|
|
1538
|
+
updatedIds: [],
|
|
1539
|
+
skippedIds: [],
|
|
1540
|
+
errors: [],
|
|
1541
|
+
};
|
|
1542
|
+
const actor = options?.actor ?? plan.createdBy;
|
|
1543
|
+
for (const task of tasks) {
|
|
1544
|
+
// Skip tasks that already have the same assignee
|
|
1545
|
+
if (task.assignee === newAssignee) {
|
|
1546
|
+
result.skipped++;
|
|
1547
|
+
result.skippedIds.push(task.id);
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
// Skip tombstone tasks
|
|
1551
|
+
if (task.status === TaskStatusEnum.TOMBSTONE) {
|
|
1552
|
+
result.skipped++;
|
|
1553
|
+
result.skippedIds.push(task.id);
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
try {
|
|
1557
|
+
await this.update(task.id, { assignee: newAssignee }, { actor });
|
|
1558
|
+
result.updated++;
|
|
1559
|
+
result.updatedIds.push(task.id);
|
|
1560
|
+
}
|
|
1561
|
+
catch (error) {
|
|
1562
|
+
result.errors.push({
|
|
1563
|
+
taskId: task.id,
|
|
1564
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
return result;
|
|
1569
|
+
}
|
|
1570
|
+
async bulkTagPlanTasks(planId, options) {
|
|
1571
|
+
// Verify plan exists
|
|
1572
|
+
const plan = await this.get(planId);
|
|
1573
|
+
if (!plan) {
|
|
1574
|
+
throw new NotFoundError(`Plan not found: ${planId}`, ErrorCode.NOT_FOUND, { elementId: planId });
|
|
1575
|
+
}
|
|
1576
|
+
if (plan.type !== 'plan') {
|
|
1577
|
+
throw new ConstraintError(`Element is not a plan: ${planId}`, ErrorCode.TYPE_MISMATCH, { elementId: planId, actualType: plan.type, expectedType: 'plan' });
|
|
1578
|
+
}
|
|
1579
|
+
// Validate that at least one tag operation is specified
|
|
1580
|
+
if ((!options.addTags || options.addTags.length === 0) &&
|
|
1581
|
+
(!options.removeTags || options.removeTags.length === 0)) {
|
|
1582
|
+
throw new ValidationError('At least one of addTags or removeTags must be specified', ErrorCode.INVALID_INPUT, { addTags: options.addTags, removeTags: options.removeTags });
|
|
1583
|
+
}
|
|
1584
|
+
// Get all tasks in the plan
|
|
1585
|
+
const tasks = await this.getTasksInPlan(planId, options?.filter);
|
|
1586
|
+
const result = {
|
|
1587
|
+
updated: 0,
|
|
1588
|
+
skipped: 0,
|
|
1589
|
+
updatedIds: [],
|
|
1590
|
+
skippedIds: [],
|
|
1591
|
+
errors: [],
|
|
1592
|
+
};
|
|
1593
|
+
const actor = options?.actor ?? plan.createdBy;
|
|
1594
|
+
const tagsToAdd = options.addTags ?? [];
|
|
1595
|
+
const tagsToRemove = new Set(options.removeTags ?? []);
|
|
1596
|
+
for (const task of tasks) {
|
|
1597
|
+
// Skip tombstone tasks
|
|
1598
|
+
if (task.status === TaskStatusEnum.TOMBSTONE) {
|
|
1599
|
+
result.skipped++;
|
|
1600
|
+
result.skippedIds.push(task.id);
|
|
1601
|
+
continue;
|
|
1602
|
+
}
|
|
1603
|
+
// Calculate new tags
|
|
1604
|
+
const existingTags = new Set(task.tags);
|
|
1605
|
+
// Remove tags first
|
|
1606
|
+
for (const tag of tagsToRemove) {
|
|
1607
|
+
existingTags.delete(tag);
|
|
1608
|
+
}
|
|
1609
|
+
// Then add tags
|
|
1610
|
+
for (const tag of tagsToAdd) {
|
|
1611
|
+
existingTags.add(tag);
|
|
1612
|
+
}
|
|
1613
|
+
const newTags = Array.from(existingTags).sort();
|
|
1614
|
+
const oldTags = [...task.tags].sort();
|
|
1615
|
+
// Skip if tags haven't changed
|
|
1616
|
+
if (newTags.length === oldTags.length && newTags.every((t, i) => t === oldTags[i])) {
|
|
1617
|
+
result.skipped++;
|
|
1618
|
+
result.skippedIds.push(task.id);
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
try {
|
|
1622
|
+
await this.update(task.id, { tags: newTags }, { actor });
|
|
1623
|
+
result.updated++;
|
|
1624
|
+
result.updatedIds.push(task.id);
|
|
1625
|
+
}
|
|
1626
|
+
catch (error) {
|
|
1627
|
+
result.errors.push({
|
|
1628
|
+
taskId: task.id,
|
|
1629
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
return result;
|
|
1634
|
+
}
|
|
1635
|
+
// --------------------------------------------------------------------------
|
|
1636
|
+
// Task Operations
|
|
1637
|
+
// --------------------------------------------------------------------------
|
|
1638
|
+
async ready(filter) {
|
|
1639
|
+
// Extract limit to apply after sorting
|
|
1640
|
+
const limit = filter?.limit;
|
|
1641
|
+
// For team-based assignee filtering:
|
|
1642
|
+
// If an assignee is specified, also find tasks assigned to teams the entity belongs to
|
|
1643
|
+
let teamIds = [];
|
|
1644
|
+
if (filter?.assignee) {
|
|
1645
|
+
// Find all teams the entity is a member of
|
|
1646
|
+
const teams = await this.list({ type: 'team' });
|
|
1647
|
+
teamIds = teams
|
|
1648
|
+
.filter((team) => isTeamMember(team, filter.assignee))
|
|
1649
|
+
.map((team) => team.id);
|
|
1650
|
+
}
|
|
1651
|
+
// Build effective filter - remove assignee since we'll handle it manually
|
|
1652
|
+
const effectiveFilter = {
|
|
1653
|
+
...filter,
|
|
1654
|
+
type: 'task',
|
|
1655
|
+
status: [TaskStatusEnum.OPEN, TaskStatusEnum.IN_PROGRESS],
|
|
1656
|
+
limit: undefined, // Don't limit at DB level - we'll apply after sorting
|
|
1657
|
+
assignee: undefined, // Handle assignee filtering manually for team support
|
|
1658
|
+
};
|
|
1659
|
+
// Get tasks matching filter
|
|
1660
|
+
let tasks = await this.list(effectiveFilter);
|
|
1661
|
+
// Apply team-aware assignee filtering if specified
|
|
1662
|
+
if (filter?.assignee) {
|
|
1663
|
+
const validAssignees = new Set([filter.assignee, ...teamIds]);
|
|
1664
|
+
tasks = tasks.filter((task) => task.assignee && validAssignees.has(task.assignee));
|
|
1665
|
+
}
|
|
1666
|
+
// Filter out blocked tasks
|
|
1667
|
+
const blockedIds = new Set(this.backend.query('SELECT element_id FROM blocked_cache').map((r) => r.element_id));
|
|
1668
|
+
// Filter out tasks whose parent plan is in DRAFT status
|
|
1669
|
+
// Uses a single SQL join to find task IDs that are children of draft plans
|
|
1670
|
+
const draftPlanTaskIds = new Set(this.backend.query(`SELECT d.blocked_id FROM dependencies d
|
|
1671
|
+
JOIN elements e ON d.blocker_id = e.id
|
|
1672
|
+
WHERE d.type = 'parent-child'
|
|
1673
|
+
AND e.deleted_at IS NULL
|
|
1674
|
+
AND e.type = 'plan'
|
|
1675
|
+
AND JSON_EXTRACT(e.data, '$.status') = 'draft'`).map((r) => r.blocked_id));
|
|
1676
|
+
// Get tasks that are children of ephemeral workflows (to exclude from ready list)
|
|
1677
|
+
// Find all ephemeral workflows
|
|
1678
|
+
const workflows = await this.list({ type: 'workflow' });
|
|
1679
|
+
const ephemeralWorkflowIds = new Set(workflows.filter((w) => w.ephemeral).map((w) => w.id));
|
|
1680
|
+
// Find all tasks that are children of ephemeral workflows
|
|
1681
|
+
let ephemeralTaskIds = new Set();
|
|
1682
|
+
if (ephemeralWorkflowIds.size > 0) {
|
|
1683
|
+
const deps = await this.getAllDependencies();
|
|
1684
|
+
for (const dep of deps) {
|
|
1685
|
+
if (dep.type === 'parent-child' && ephemeralWorkflowIds.has(dep.blockerId)) {
|
|
1686
|
+
ephemeralTaskIds.add(dep.blockedId);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
// Filter out scheduled-for-future tasks, tasks from ephemeral workflows, and draft plan tasks
|
|
1691
|
+
const now = new Date();
|
|
1692
|
+
const includeEphemeral = filter?.includeEphemeral ?? false;
|
|
1693
|
+
const readyTasks = tasks.filter((task) => {
|
|
1694
|
+
// Not blocked
|
|
1695
|
+
if (blockedIds.has(task.id)) {
|
|
1696
|
+
return false;
|
|
1697
|
+
}
|
|
1698
|
+
// Not in a draft plan
|
|
1699
|
+
if (draftPlanTaskIds.has(task.id)) {
|
|
1700
|
+
return false;
|
|
1701
|
+
}
|
|
1702
|
+
// Not scheduled for future
|
|
1703
|
+
if (task.scheduledFor && new Date(task.scheduledFor) > now) {
|
|
1704
|
+
return false;
|
|
1705
|
+
}
|
|
1706
|
+
// Not a child of an ephemeral workflow (unless includeEphemeral is true)
|
|
1707
|
+
if (!includeEphemeral && ephemeralTaskIds.has(task.id)) {
|
|
1708
|
+
return false;
|
|
1709
|
+
}
|
|
1710
|
+
return true;
|
|
1711
|
+
});
|
|
1712
|
+
// Calculate effective priorities based on dependency relationships
|
|
1713
|
+
// Tasks blocking high-priority work inherit that urgency
|
|
1714
|
+
const tasksWithPriority = this.priorityService.enhanceTasksWithEffectivePriority(readyTasks);
|
|
1715
|
+
// Sort by effective priority ascending (1 = highest/critical, 5 = lowest/minimal)
|
|
1716
|
+
// Secondary sort by base priority for ties
|
|
1717
|
+
this.priorityService.sortByEffectivePriority(tasksWithPriority);
|
|
1718
|
+
// Apply limit after sorting
|
|
1719
|
+
if (limit !== undefined) {
|
|
1720
|
+
return tasksWithPriority.slice(0, limit);
|
|
1721
|
+
}
|
|
1722
|
+
return tasksWithPriority;
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* Get tasks in backlog (not ready for work, needs triage)
|
|
1726
|
+
*/
|
|
1727
|
+
async backlog(filter) {
|
|
1728
|
+
const limit = filter?.limit;
|
|
1729
|
+
const effectiveFilter = {
|
|
1730
|
+
...filter,
|
|
1731
|
+
type: 'task',
|
|
1732
|
+
status: TaskStatusEnum.BACKLOG,
|
|
1733
|
+
limit: undefined, // Don't limit at DB level
|
|
1734
|
+
};
|
|
1735
|
+
let tasks = await this.list(effectiveFilter);
|
|
1736
|
+
// Sort by priority (highest first), then by creation date (oldest first)
|
|
1737
|
+
tasks.sort((a, b) => {
|
|
1738
|
+
const priorityDiff = a.priority - b.priority;
|
|
1739
|
+
if (priorityDiff !== 0)
|
|
1740
|
+
return priorityDiff;
|
|
1741
|
+
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
|
1742
|
+
});
|
|
1743
|
+
// Apply limit after sorting
|
|
1744
|
+
if (limit) {
|
|
1745
|
+
tasks = tasks.slice(0, limit);
|
|
1746
|
+
}
|
|
1747
|
+
return tasks;
|
|
1748
|
+
}
|
|
1749
|
+
async blocked(filter) {
|
|
1750
|
+
// Extract limit to apply after filtering
|
|
1751
|
+
const limit = filter?.limit;
|
|
1752
|
+
const effectiveFilter = {
|
|
1753
|
+
...filter,
|
|
1754
|
+
type: 'task',
|
|
1755
|
+
limit: undefined, // Don't limit at DB level - we'll apply after filtering
|
|
1756
|
+
};
|
|
1757
|
+
// Get tasks matching filter
|
|
1758
|
+
const tasks = await this.list(effectiveFilter);
|
|
1759
|
+
// Get blocked cache entries
|
|
1760
|
+
const blockedRows = this.backend.query('SELECT * FROM blocked_cache');
|
|
1761
|
+
const blockedMap = new Map(blockedRows.map((r) => [r.element_id, r]));
|
|
1762
|
+
// Filter to blocked tasks and add blocking info
|
|
1763
|
+
const blockedTasks = [];
|
|
1764
|
+
for (const task of tasks) {
|
|
1765
|
+
const blockInfo = blockedMap.get(task.id);
|
|
1766
|
+
if (blockInfo) {
|
|
1767
|
+
blockedTasks.push({
|
|
1768
|
+
...task,
|
|
1769
|
+
blockedBy: blockInfo.blocked_by,
|
|
1770
|
+
blockReason: blockInfo.reason ?? 'Blocked by dependency',
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
// Apply limit after filtering
|
|
1775
|
+
if (limit !== undefined) {
|
|
1776
|
+
return blockedTasks.slice(0, limit);
|
|
1777
|
+
}
|
|
1778
|
+
return blockedTasks;
|
|
1779
|
+
}
|
|
1780
|
+
// --------------------------------------------------------------------------
|
|
1781
|
+
// Dependency Operations
|
|
1782
|
+
// --------------------------------------------------------------------------
|
|
1783
|
+
async addDependency(dep) {
|
|
1784
|
+
// Verify blocked element exists
|
|
1785
|
+
const source = await this.get(dep.blockedId);
|
|
1786
|
+
if (!source) {
|
|
1787
|
+
throw new NotFoundError(`Source element not found: ${dep.blockedId}`, ErrorCode.NOT_FOUND, { elementId: dep.blockedId });
|
|
1788
|
+
}
|
|
1789
|
+
// Check for existing dependency
|
|
1790
|
+
const existing = this.backend.queryOne('SELECT * FROM dependencies WHERE blocked_id = ? AND blocker_id = ? AND type = ?', [dep.blockedId, dep.blockerId, dep.type]);
|
|
1791
|
+
if (existing) {
|
|
1792
|
+
throw new ConflictError('Dependency already exists', ErrorCode.DUPLICATE_DEPENDENCY, {
|
|
1793
|
+
blockedId: dep.blockedId,
|
|
1794
|
+
blockerId: dep.blockerId,
|
|
1795
|
+
dependencyType: dep.type,
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
// TODO: Check for cycles (for blocking dependency types)
|
|
1799
|
+
// Resolve actor - use provided actor or fall back to source element's creator
|
|
1800
|
+
const actor = dep.actor ?? source.createdBy;
|
|
1801
|
+
const now = createTimestamp();
|
|
1802
|
+
const dependency = {
|
|
1803
|
+
blockedId: dep.blockedId,
|
|
1804
|
+
blockerId: dep.blockerId,
|
|
1805
|
+
type: dep.type,
|
|
1806
|
+
createdAt: now,
|
|
1807
|
+
createdBy: actor,
|
|
1808
|
+
metadata: dep.metadata ?? {},
|
|
1809
|
+
};
|
|
1810
|
+
// Insert dependency and record event in a transaction
|
|
1811
|
+
this.backend.transaction((tx) => {
|
|
1812
|
+
// Insert dependency
|
|
1813
|
+
tx.run(`INSERT INTO dependencies (blocked_id, blocker_id, type, created_at, created_by, metadata)
|
|
1814
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
1815
|
+
dependency.blockedId,
|
|
1816
|
+
dependency.blockerId,
|
|
1817
|
+
dependency.type,
|
|
1818
|
+
dependency.createdAt,
|
|
1819
|
+
dependency.createdBy,
|
|
1820
|
+
dependency.metadata ? JSON.stringify(dependency.metadata) : null,
|
|
1821
|
+
]);
|
|
1822
|
+
// Record dependency_added event
|
|
1823
|
+
const event = createEvent({
|
|
1824
|
+
elementId: dependency.blockedId,
|
|
1825
|
+
eventType: 'dependency_added',
|
|
1826
|
+
actor: dependency.createdBy,
|
|
1827
|
+
oldValue: null,
|
|
1828
|
+
newValue: {
|
|
1829
|
+
blockedId: dependency.blockedId,
|
|
1830
|
+
blockerId: dependency.blockerId,
|
|
1831
|
+
type: dependency.type,
|
|
1832
|
+
metadata: dependency.metadata,
|
|
1833
|
+
},
|
|
1834
|
+
});
|
|
1835
|
+
tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
|
|
1836
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
1837
|
+
event.elementId,
|
|
1838
|
+
event.eventType,
|
|
1839
|
+
event.actor,
|
|
1840
|
+
null,
|
|
1841
|
+
JSON.stringify(event.newValue),
|
|
1842
|
+
event.createdAt,
|
|
1843
|
+
]);
|
|
1844
|
+
});
|
|
1845
|
+
// Update blocked cache using the service (handles transitive blocking, gate satisfaction, etc.)
|
|
1846
|
+
this.blockedCache.onDependencyAdded(dep.blockedId, dep.blockerId, dep.type, dep.metadata);
|
|
1847
|
+
// Mark source as dirty
|
|
1848
|
+
this.backend.markDirty(dep.blockedId);
|
|
1849
|
+
return dependency;
|
|
1850
|
+
}
|
|
1851
|
+
async removeDependency(blockedId, blockerId, type, actor) {
|
|
1852
|
+
// Check dependency exists and capture for event
|
|
1853
|
+
const existing = this.backend.queryOne('SELECT * FROM dependencies WHERE blocked_id = ? AND blocker_id = ? AND type = ?', [blockedId, blockerId, type]);
|
|
1854
|
+
if (!existing) {
|
|
1855
|
+
throw new NotFoundError('Dependency not found', ErrorCode.DEPENDENCY_NOT_FOUND, { blockedId, blockerId, dependencyType: type });
|
|
1856
|
+
}
|
|
1857
|
+
// Get actor for event - use provided actor or fall back to the dependency creator
|
|
1858
|
+
const eventActor = actor ?? existing.created_by;
|
|
1859
|
+
// Remove dependency and record event in a transaction
|
|
1860
|
+
this.backend.transaction((tx) => {
|
|
1861
|
+
// Remove dependency
|
|
1862
|
+
tx.run('DELETE FROM dependencies WHERE blocked_id = ? AND blocker_id = ? AND type = ?', [blockedId, blockerId, type]);
|
|
1863
|
+
// Record dependency_removed event
|
|
1864
|
+
const event = createEvent({
|
|
1865
|
+
elementId: blockedId,
|
|
1866
|
+
eventType: 'dependency_removed',
|
|
1867
|
+
actor: eventActor,
|
|
1868
|
+
oldValue: {
|
|
1869
|
+
blockedId: existing.blocked_id,
|
|
1870
|
+
blockerId: existing.blocker_id,
|
|
1871
|
+
type: existing.type,
|
|
1872
|
+
metadata: existing.metadata ? JSON.parse(existing.metadata) : {},
|
|
1873
|
+
},
|
|
1874
|
+
newValue: null,
|
|
1875
|
+
});
|
|
1876
|
+
tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
|
|
1877
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
1878
|
+
event.elementId,
|
|
1879
|
+
event.eventType,
|
|
1880
|
+
event.actor,
|
|
1881
|
+
JSON.stringify(event.oldValue),
|
|
1882
|
+
null,
|
|
1883
|
+
event.createdAt,
|
|
1884
|
+
]);
|
|
1885
|
+
});
|
|
1886
|
+
// Update blocked cache using the service (recomputes blocking state)
|
|
1887
|
+
this.blockedCache.onDependencyRemoved(blockedId, blockerId, type);
|
|
1888
|
+
// Mark source as dirty
|
|
1889
|
+
this.backend.markDirty(blockedId);
|
|
1890
|
+
}
|
|
1891
|
+
async getDependencies(id, types) {
|
|
1892
|
+
let sql = 'SELECT * FROM dependencies WHERE blocked_id = ?';
|
|
1893
|
+
const params = [id];
|
|
1894
|
+
if (types && types.length > 0) {
|
|
1895
|
+
const placeholders = types.map(() => '?').join(', ');
|
|
1896
|
+
sql += ` AND type IN (${placeholders})`;
|
|
1897
|
+
params.push(...types);
|
|
1898
|
+
}
|
|
1899
|
+
const rows = this.backend.query(sql, params);
|
|
1900
|
+
return rows.map((row) => ({
|
|
1901
|
+
blockedId: row.blocked_id,
|
|
1902
|
+
blockerId: row.blocker_id,
|
|
1903
|
+
type: row.type,
|
|
1904
|
+
createdAt: row.created_at,
|
|
1905
|
+
createdBy: row.created_by,
|
|
1906
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
|
1907
|
+
}));
|
|
1908
|
+
}
|
|
1909
|
+
async getDependents(id, types) {
|
|
1910
|
+
let sql = 'SELECT * FROM dependencies WHERE blocker_id = ?';
|
|
1911
|
+
const params = [id];
|
|
1912
|
+
if (types && types.length > 0) {
|
|
1913
|
+
const placeholders = types.map(() => '?').join(', ');
|
|
1914
|
+
sql += ` AND type IN (${placeholders})`;
|
|
1915
|
+
params.push(...types);
|
|
1916
|
+
}
|
|
1917
|
+
const rows = this.backend.query(sql, params);
|
|
1918
|
+
return rows.map((row) => ({
|
|
1919
|
+
blockedId: row.blocked_id,
|
|
1920
|
+
blockerId: row.blocker_id,
|
|
1921
|
+
type: row.type,
|
|
1922
|
+
createdAt: row.created_at,
|
|
1923
|
+
createdBy: row.created_by,
|
|
1924
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
|
1925
|
+
}));
|
|
1926
|
+
}
|
|
1927
|
+
async getDependencyTree(id) {
|
|
1928
|
+
const element = await this.get(id);
|
|
1929
|
+
if (!element) {
|
|
1930
|
+
throw new NotFoundError(`Element not found: ${id}`, ErrorCode.NOT_FOUND, { elementId: id });
|
|
1931
|
+
}
|
|
1932
|
+
// Build tree recursively (with depth limit to prevent infinite loops)
|
|
1933
|
+
const maxDepth = 10;
|
|
1934
|
+
const visited = new Set();
|
|
1935
|
+
const buildNode = async (elem, depth, direction) => {
|
|
1936
|
+
const node = {
|
|
1937
|
+
element: elem,
|
|
1938
|
+
dependencies: [],
|
|
1939
|
+
dependents: [],
|
|
1940
|
+
};
|
|
1941
|
+
if (depth >= maxDepth || visited.has(elem.id)) {
|
|
1942
|
+
return node;
|
|
1943
|
+
}
|
|
1944
|
+
visited.add(elem.id);
|
|
1945
|
+
if (direction === 'deps' || depth === 0) {
|
|
1946
|
+
const deps = await this.getDependencies(elem.id);
|
|
1947
|
+
for (const dep of deps) {
|
|
1948
|
+
const targetElem = await this.get(dep.blockerId);
|
|
1949
|
+
if (targetElem) {
|
|
1950
|
+
const childNode = await buildNode(targetElem, depth + 1, 'deps');
|
|
1951
|
+
node.dependencies.push(childNode);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
if (direction === 'dependents' || depth === 0) {
|
|
1956
|
+
const dependents = await this.getDependents(elem.id);
|
|
1957
|
+
for (const dep of dependents) {
|
|
1958
|
+
const sourceElem = await this.get(dep.blockedId);
|
|
1959
|
+
if (sourceElem) {
|
|
1960
|
+
const parentNode = await buildNode(sourceElem, depth + 1, 'dependents');
|
|
1961
|
+
node.dependents.push(parentNode);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
return node;
|
|
1966
|
+
};
|
|
1967
|
+
const root = await buildNode(element, 0, 'deps');
|
|
1968
|
+
// Calculate depths
|
|
1969
|
+
const countDepth = (node, direction) => {
|
|
1970
|
+
const children = direction === 'deps' ? node.dependencies : node.dependents;
|
|
1971
|
+
if (children.length === 0)
|
|
1972
|
+
return 0;
|
|
1973
|
+
return 1 + Math.max(...children.map((c) => countDepth(c, direction)));
|
|
1974
|
+
};
|
|
1975
|
+
const countNodes = (node, visited) => {
|
|
1976
|
+
if (visited.has(node.element.id))
|
|
1977
|
+
return 0;
|
|
1978
|
+
visited.add(node.element.id);
|
|
1979
|
+
let count = 1;
|
|
1980
|
+
for (const child of node.dependencies) {
|
|
1981
|
+
count += countNodes(child, visited);
|
|
1982
|
+
}
|
|
1983
|
+
for (const child of node.dependents) {
|
|
1984
|
+
count += countNodes(child, visited);
|
|
1985
|
+
}
|
|
1986
|
+
return count;
|
|
1987
|
+
};
|
|
1988
|
+
return {
|
|
1989
|
+
root,
|
|
1990
|
+
dependencyDepth: countDepth(root, 'deps'),
|
|
1991
|
+
dependentDepth: countDepth(root, 'dependents'),
|
|
1992
|
+
nodeCount: countNodes(root, new Set()),
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
// --------------------------------------------------------------------------
|
|
1996
|
+
// Gate Satisfaction
|
|
1997
|
+
// --------------------------------------------------------------------------
|
|
1998
|
+
async satisfyGate(blockedId, blockerId, actor) {
|
|
1999
|
+
return this.blockedCache.satisfyGate(blockedId, blockerId, actor);
|
|
2000
|
+
}
|
|
2001
|
+
async recordApproval(blockedId, blockerId, approver) {
|
|
2002
|
+
return this.blockedCache.recordApproval(blockedId, blockerId, approver);
|
|
2003
|
+
}
|
|
2004
|
+
async removeApproval(blockedId, blockerId, approver) {
|
|
2005
|
+
return this.blockedCache.removeApproval(blockedId, blockerId, approver);
|
|
2006
|
+
}
|
|
2007
|
+
// --------------------------------------------------------------------------
|
|
2008
|
+
// Search
|
|
2009
|
+
// --------------------------------------------------------------------------
|
|
2010
|
+
async search(query, filter) {
|
|
2011
|
+
// Simple LIKE-based search for now
|
|
2012
|
+
const searchPattern = `%${query}%`;
|
|
2013
|
+
const params = [];
|
|
2014
|
+
// Build base WHERE clause from filter (params accumulates in place)
|
|
2015
|
+
const { where: filterWhere } = buildWhereClause(filter ?? {}, params);
|
|
2016
|
+
// Search in title (stored in data JSON)
|
|
2017
|
+
const sql = `
|
|
2018
|
+
SELECT DISTINCT e.*
|
|
2019
|
+
FROM elements e
|
|
2020
|
+
LEFT JOIN tags t ON e.id = t.element_id
|
|
2021
|
+
WHERE ${filterWhere}
|
|
2022
|
+
AND (
|
|
2023
|
+
JSON_EXTRACT(e.data, '$.title') LIKE ?
|
|
2024
|
+
OR JSON_EXTRACT(e.data, '$.content') LIKE ?
|
|
2025
|
+
OR t.tag LIKE ?
|
|
2026
|
+
)
|
|
2027
|
+
ORDER BY e.updated_at DESC
|
|
2028
|
+
LIMIT 100
|
|
2029
|
+
`;
|
|
2030
|
+
params.push(searchPattern, searchPattern, searchPattern);
|
|
2031
|
+
const rows = this.backend.query(sql, params);
|
|
2032
|
+
// Fetch tags and deserialize
|
|
2033
|
+
const results = [];
|
|
2034
|
+
for (const row of rows) {
|
|
2035
|
+
const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
|
|
2036
|
+
const tags = tagRows.map((r) => r.tag);
|
|
2037
|
+
const el = deserializeElement(row, tags);
|
|
2038
|
+
if (el)
|
|
2039
|
+
results.push(el);
|
|
2040
|
+
}
|
|
2041
|
+
return results;
|
|
2042
|
+
}
|
|
2043
|
+
async searchChannels(query, filter) {
|
|
2044
|
+
const searchPattern = `%${query}%`;
|
|
2045
|
+
const params = [];
|
|
2046
|
+
// Build base WHERE clause from filter (params accumulates in place)
|
|
2047
|
+
// Force type to 'channel'
|
|
2048
|
+
const channelFilter = { ...filter, type: 'channel' };
|
|
2049
|
+
const { where: filterWhere } = buildWhereClause(channelFilter, params);
|
|
2050
|
+
// Build channel-specific WHERE clause
|
|
2051
|
+
const { where: channelWhere } = buildChannelWhereClause(filter ?? {}, params);
|
|
2052
|
+
// Combine base and channel-specific conditions
|
|
2053
|
+
let fullWhere = filterWhere;
|
|
2054
|
+
if (channelWhere) {
|
|
2055
|
+
fullWhere = `${filterWhere} AND ${channelWhere}`;
|
|
2056
|
+
}
|
|
2057
|
+
// Search in channel name
|
|
2058
|
+
const sql = `
|
|
2059
|
+
SELECT DISTINCT e.*
|
|
2060
|
+
FROM elements e
|
|
2061
|
+
LEFT JOIN tags t ON e.id = t.element_id
|
|
2062
|
+
WHERE ${fullWhere}
|
|
2063
|
+
AND (
|
|
2064
|
+
JSON_EXTRACT(e.data, '$.name') LIKE ?
|
|
2065
|
+
OR t.tag LIKE ?
|
|
2066
|
+
)
|
|
2067
|
+
ORDER BY e.updated_at DESC
|
|
2068
|
+
LIMIT 100
|
|
2069
|
+
`;
|
|
2070
|
+
params.push(searchPattern, searchPattern);
|
|
2071
|
+
const rows = this.backend.query(sql, params);
|
|
2072
|
+
// Fetch tags and deserialize
|
|
2073
|
+
const results = [];
|
|
2074
|
+
for (const row of rows) {
|
|
2075
|
+
const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [row.id]);
|
|
2076
|
+
const tags = tagRows.map((r) => r.tag);
|
|
2077
|
+
const ch = deserializeElement(row, tags);
|
|
2078
|
+
if (ch)
|
|
2079
|
+
results.push(ch);
|
|
2080
|
+
}
|
|
2081
|
+
return results;
|
|
2082
|
+
}
|
|
2083
|
+
// --------------------------------------------------------------------------
|
|
2084
|
+
// History Operations
|
|
2085
|
+
// --------------------------------------------------------------------------
|
|
2086
|
+
async getEvents(id, filter) {
|
|
2087
|
+
let sql = 'SELECT * FROM events WHERE element_id = ?';
|
|
2088
|
+
const params = [id];
|
|
2089
|
+
if (filter?.eventType) {
|
|
2090
|
+
const types = Array.isArray(filter.eventType) ? filter.eventType : [filter.eventType];
|
|
2091
|
+
const placeholders = types.map(() => '?').join(', ');
|
|
2092
|
+
sql += ` AND event_type IN (${placeholders})`;
|
|
2093
|
+
params.push(...types);
|
|
2094
|
+
}
|
|
2095
|
+
if (filter?.actor) {
|
|
2096
|
+
sql += ' AND actor = ?';
|
|
2097
|
+
params.push(filter.actor);
|
|
2098
|
+
}
|
|
2099
|
+
if (filter?.after) {
|
|
2100
|
+
sql += ' AND created_at > ?';
|
|
2101
|
+
params.push(filter.after);
|
|
2102
|
+
}
|
|
2103
|
+
if (filter?.before) {
|
|
2104
|
+
sql += ' AND created_at < ?';
|
|
2105
|
+
params.push(filter.before);
|
|
2106
|
+
}
|
|
2107
|
+
sql += ' ORDER BY created_at DESC';
|
|
2108
|
+
if (filter?.limit) {
|
|
2109
|
+
sql += ' LIMIT ?';
|
|
2110
|
+
params.push(filter.limit);
|
|
2111
|
+
}
|
|
2112
|
+
const rows = this.backend.query(sql, params);
|
|
2113
|
+
return rows.map((row) => ({
|
|
2114
|
+
id: row.id,
|
|
2115
|
+
elementId: row.element_id,
|
|
2116
|
+
eventType: row.event_type,
|
|
2117
|
+
actor: row.actor,
|
|
2118
|
+
oldValue: row.old_value ? JSON.parse(row.old_value) : null,
|
|
2119
|
+
newValue: row.new_value ? JSON.parse(row.new_value) : null,
|
|
2120
|
+
createdAt: row.created_at,
|
|
2121
|
+
}));
|
|
2122
|
+
}
|
|
2123
|
+
async listEvents(filter) {
|
|
2124
|
+
let sql = 'SELECT * FROM events WHERE 1=1';
|
|
2125
|
+
const params = [];
|
|
2126
|
+
if (filter?.elementId) {
|
|
2127
|
+
sql += ' AND element_id = ?';
|
|
2128
|
+
params.push(filter.elementId);
|
|
2129
|
+
}
|
|
2130
|
+
if (filter?.eventType) {
|
|
2131
|
+
const types = Array.isArray(filter.eventType) ? filter.eventType : [filter.eventType];
|
|
2132
|
+
const placeholders = types.map(() => '?').join(', ');
|
|
2133
|
+
sql += ` AND event_type IN (${placeholders})`;
|
|
2134
|
+
params.push(...types);
|
|
2135
|
+
}
|
|
2136
|
+
if (filter?.actor) {
|
|
2137
|
+
sql += ' AND actor = ?';
|
|
2138
|
+
params.push(filter.actor);
|
|
2139
|
+
}
|
|
2140
|
+
if (filter?.after) {
|
|
2141
|
+
sql += ' AND created_at > ?';
|
|
2142
|
+
params.push(filter.after);
|
|
2143
|
+
}
|
|
2144
|
+
if (filter?.before) {
|
|
2145
|
+
sql += ' AND created_at < ?';
|
|
2146
|
+
params.push(filter.before);
|
|
2147
|
+
}
|
|
2148
|
+
sql += ' ORDER BY created_at DESC';
|
|
2149
|
+
if (filter?.limit) {
|
|
2150
|
+
sql += ' LIMIT ?';
|
|
2151
|
+
params.push(filter.limit);
|
|
2152
|
+
}
|
|
2153
|
+
if (filter?.offset) {
|
|
2154
|
+
sql += ' OFFSET ?';
|
|
2155
|
+
params.push(filter.offset);
|
|
2156
|
+
}
|
|
2157
|
+
const rows = this.backend.query(sql, params);
|
|
2158
|
+
return rows.map((row) => ({
|
|
2159
|
+
id: row.id,
|
|
2160
|
+
elementId: row.element_id,
|
|
2161
|
+
eventType: row.event_type,
|
|
2162
|
+
actor: row.actor,
|
|
2163
|
+
oldValue: row.old_value ? JSON.parse(row.old_value) : null,
|
|
2164
|
+
newValue: row.new_value ? JSON.parse(row.new_value) : null,
|
|
2165
|
+
createdAt: row.created_at,
|
|
2166
|
+
}));
|
|
2167
|
+
}
|
|
2168
|
+
async countEvents(filter) {
|
|
2169
|
+
let sql = 'SELECT COUNT(*) as count FROM events WHERE 1=1';
|
|
2170
|
+
const params = [];
|
|
2171
|
+
if (filter?.elementId) {
|
|
2172
|
+
sql += ' AND element_id = ?';
|
|
2173
|
+
params.push(filter.elementId);
|
|
2174
|
+
}
|
|
2175
|
+
if (filter?.eventType) {
|
|
2176
|
+
const types = Array.isArray(filter.eventType) ? filter.eventType : [filter.eventType];
|
|
2177
|
+
const placeholders = types.map(() => '?').join(', ');
|
|
2178
|
+
sql += ` AND event_type IN (${placeholders})`;
|
|
2179
|
+
params.push(...types);
|
|
2180
|
+
}
|
|
2181
|
+
if (filter?.actor) {
|
|
2182
|
+
sql += ' AND actor = ?';
|
|
2183
|
+
params.push(filter.actor);
|
|
2184
|
+
}
|
|
2185
|
+
if (filter?.after) {
|
|
2186
|
+
sql += ' AND created_at > ?';
|
|
2187
|
+
params.push(filter.after);
|
|
2188
|
+
}
|
|
2189
|
+
if (filter?.before) {
|
|
2190
|
+
sql += ' AND created_at < ?';
|
|
2191
|
+
params.push(filter.before);
|
|
2192
|
+
}
|
|
2193
|
+
const row = this.backend.queryOne(sql, params);
|
|
2194
|
+
return row?.count ?? 0;
|
|
2195
|
+
}
|
|
2196
|
+
async getDocumentVersion(id, version) {
|
|
2197
|
+
const current = await this.get(id);
|
|
2198
|
+
if (!current) {
|
|
2199
|
+
throw new NotFoundError(`Document not found: ${id}`, ErrorCode.NOT_FOUND, { documentId: id });
|
|
2200
|
+
}
|
|
2201
|
+
if (current.type !== 'document') {
|
|
2202
|
+
throw new ValidationError(`Element ${id} is not a document (type: ${current.type})`, ErrorCode.INVALID_INPUT, { elementId: id, actualType: current.type, expectedType: 'document' });
|
|
2203
|
+
}
|
|
2204
|
+
if (current.deletedAt) {
|
|
2205
|
+
throw new NotFoundError(`Document has been deleted: ${id}`, ErrorCode.NOT_FOUND, { documentId: id, deletedAt: current.deletedAt });
|
|
2206
|
+
}
|
|
2207
|
+
if (current.version === version) {
|
|
2208
|
+
return current;
|
|
2209
|
+
}
|
|
2210
|
+
// Look in version history
|
|
2211
|
+
const row = this.backend.queryOne('SELECT data, created_at FROM document_versions WHERE id = ? AND version = ?', [id, version]);
|
|
2212
|
+
if (!row) {
|
|
2213
|
+
return null;
|
|
2214
|
+
}
|
|
2215
|
+
const data = JSON.parse(row.data);
|
|
2216
|
+
return {
|
|
2217
|
+
id: id,
|
|
2218
|
+
type: 'document',
|
|
2219
|
+
createdAt: row.created_at,
|
|
2220
|
+
updatedAt: row.created_at,
|
|
2221
|
+
createdBy: data.createdBy,
|
|
2222
|
+
tags: data.tags ?? [],
|
|
2223
|
+
metadata: data.metadata ?? {},
|
|
2224
|
+
...data,
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
async getDocumentHistory(id) {
|
|
2228
|
+
// Get current version
|
|
2229
|
+
const current = await this.get(id);
|
|
2230
|
+
const results = [];
|
|
2231
|
+
if (current && current.type === 'document' && !current.deletedAt) {
|
|
2232
|
+
results.push(current);
|
|
2233
|
+
}
|
|
2234
|
+
// Get historical versions (exclude current version to avoid duplicates)
|
|
2235
|
+
const rows = this.backend.query('SELECT version, data, created_at FROM document_versions WHERE id = ? AND version != ? ORDER BY version DESC', [id, current?.version ?? -1]);
|
|
2236
|
+
for (const row of rows) {
|
|
2237
|
+
try {
|
|
2238
|
+
const data = JSON.parse(row.data);
|
|
2239
|
+
results.push({
|
|
2240
|
+
id: id,
|
|
2241
|
+
type: 'document',
|
|
2242
|
+
createdAt: row.created_at,
|
|
2243
|
+
updatedAt: row.created_at,
|
|
2244
|
+
createdBy: data.createdBy,
|
|
2245
|
+
tags: data.tags ?? [],
|
|
2246
|
+
metadata: data.metadata ?? {},
|
|
2247
|
+
...data,
|
|
2248
|
+
version: row.version,
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
catch (error) {
|
|
2252
|
+
console.warn(`[stoneforge] Skipping corrupt version ${row.version} for ${id}:`, error);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
return results;
|
|
2256
|
+
}
|
|
2257
|
+
async reconstructAtTime(id, asOf) {
|
|
2258
|
+
// Get all events for this element (we need them all for reconstruction)
|
|
2259
|
+
const events = await this.getEvents(id, {});
|
|
2260
|
+
if (events.length === 0) {
|
|
2261
|
+
throw new NotFoundError(`No events found for element: ${id}`, ErrorCode.NOT_FOUND, { elementId: id });
|
|
2262
|
+
}
|
|
2263
|
+
// Use the reconstruction utility
|
|
2264
|
+
const { state, eventsApplied, exists } = reconstructStateAtTime(events, asOf);
|
|
2265
|
+
// If the element didn't exist at that time, return null
|
|
2266
|
+
if (!exists || state === null) {
|
|
2267
|
+
return null;
|
|
2268
|
+
}
|
|
2269
|
+
// Return the reconstructed state
|
|
2270
|
+
return {
|
|
2271
|
+
element: state,
|
|
2272
|
+
asOf,
|
|
2273
|
+
eventsApplied,
|
|
2274
|
+
exists,
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
async getElementTimeline(id, filter) {
|
|
2278
|
+
// Get all events for this element
|
|
2279
|
+
const events = await this.getEvents(id, filter);
|
|
2280
|
+
if (events.length === 0) {
|
|
2281
|
+
throw new NotFoundError(`No events found for element: ${id}`, ErrorCode.NOT_FOUND, { elementId: id });
|
|
2282
|
+
}
|
|
2283
|
+
// Get current state
|
|
2284
|
+
const currentState = await this.get(id);
|
|
2285
|
+
// Generate timeline snapshots
|
|
2286
|
+
const snapshotData = generateTimelineSnapshots(events);
|
|
2287
|
+
// Convert to the expected format
|
|
2288
|
+
const snapshots = snapshotData.map(({ event, state, summary }) => ({
|
|
2289
|
+
event,
|
|
2290
|
+
state,
|
|
2291
|
+
summary,
|
|
2292
|
+
}));
|
|
2293
|
+
return {
|
|
2294
|
+
elementId: id,
|
|
2295
|
+
currentState,
|
|
2296
|
+
snapshots,
|
|
2297
|
+
totalEvents: events.length,
|
|
2298
|
+
};
|
|
2299
|
+
}
|
|
2300
|
+
// --------------------------------------------------------------------------
|
|
2301
|
+
// Channel Operations
|
|
2302
|
+
// --------------------------------------------------------------------------
|
|
2303
|
+
async findOrCreateDirectChannel(entityA, entityB, actor) {
|
|
2304
|
+
// Validate actor is one of the entities
|
|
2305
|
+
if (actor !== entityA && actor !== entityB) {
|
|
2306
|
+
throw new ValidationError('Actor must be one of the channel entities', ErrorCode.INVALID_INPUT, { field: 'actor', value: actor, expected: 'entityA or entityB' });
|
|
2307
|
+
}
|
|
2308
|
+
// Search by members for backward compatibility with both ID-named and name-named channels
|
|
2309
|
+
const sortedMembers = [entityA, entityB].sort();
|
|
2310
|
+
const existingRow = this.backend.queryOne(`SELECT * FROM elements
|
|
2311
|
+
WHERE type = 'channel'
|
|
2312
|
+
AND JSON_EXTRACT(data, '$.channelType') = 'direct'
|
|
2313
|
+
AND JSON_EXTRACT(data, '$.members[0]') = ?
|
|
2314
|
+
AND JSON_EXTRACT(data, '$.members[1]') = ?
|
|
2315
|
+
AND deleted_at IS NULL`, [sortedMembers[0], sortedMembers[1]]);
|
|
2316
|
+
if (existingRow) {
|
|
2317
|
+
// Found existing channel, return it
|
|
2318
|
+
const tagRows = this.backend.query('SELECT tag FROM tags WHERE element_id = ?', [existingRow.id]);
|
|
2319
|
+
const tags = tagRows.map((r) => r.tag);
|
|
2320
|
+
const channel = deserializeElement(existingRow, tags);
|
|
2321
|
+
if (!channel) {
|
|
2322
|
+
throw new StorageError(`Corrupt channel data: ${existingRow.id}`);
|
|
2323
|
+
}
|
|
2324
|
+
return { channel, created: false };
|
|
2325
|
+
}
|
|
2326
|
+
// Look up entity names for channel naming
|
|
2327
|
+
const entityAData = await this.get(entityA);
|
|
2328
|
+
const entityBData = await this.get(entityB);
|
|
2329
|
+
const entityAName = entityAData?.name;
|
|
2330
|
+
const entityBName = entityBData?.name;
|
|
2331
|
+
// No existing channel, create a new one with entity names
|
|
2332
|
+
const newChannel = await createDirectChannel({
|
|
2333
|
+
entityA,
|
|
2334
|
+
entityB,
|
|
2335
|
+
createdBy: actor,
|
|
2336
|
+
...(entityAName && { entityAName }),
|
|
2337
|
+
...(entityBName && { entityBName }),
|
|
2338
|
+
});
|
|
2339
|
+
const createdChannel = await this.create(newChannel);
|
|
2340
|
+
return { channel: createdChannel, created: true };
|
|
2341
|
+
}
|
|
2342
|
+
async addChannelMember(channelId, entityId, options) {
|
|
2343
|
+
// Get the channel
|
|
2344
|
+
const channel = await this.get(channelId);
|
|
2345
|
+
if (!channel) {
|
|
2346
|
+
throw new NotFoundError(`Channel not found: ${channelId}`, ErrorCode.NOT_FOUND, { elementId: channelId });
|
|
2347
|
+
}
|
|
2348
|
+
// Verify it's a channel
|
|
2349
|
+
if (channel.type !== 'channel') {
|
|
2350
|
+
throw new ConstraintError(`Element is not a channel: ${channelId}`, ErrorCode.TYPE_MISMATCH, { elementId: channelId, actualType: channel.type, expectedType: 'channel' });
|
|
2351
|
+
}
|
|
2352
|
+
// Cast to Channel type (type guard validated above)
|
|
2353
|
+
const typedChannel = channel;
|
|
2354
|
+
// Direct channels cannot have membership modified
|
|
2355
|
+
if (typedChannel.channelType === ChannelTypeValue.DIRECT) {
|
|
2356
|
+
throw new DirectChannelMembershipError(channelId, 'add');
|
|
2357
|
+
}
|
|
2358
|
+
// Get actor
|
|
2359
|
+
const actor = options?.actor ?? typedChannel.createdBy;
|
|
2360
|
+
// Check actor has permission to modify members
|
|
2361
|
+
if (!canModifyMembers(typedChannel, actor)) {
|
|
2362
|
+
throw new CannotModifyMembersError(channelId, actor);
|
|
2363
|
+
}
|
|
2364
|
+
// Check if entity is already a member
|
|
2365
|
+
if (isMember(typedChannel, entityId)) {
|
|
2366
|
+
// Already a member, return success without change
|
|
2367
|
+
return { success: true, channel: typedChannel, entityId };
|
|
2368
|
+
}
|
|
2369
|
+
// Add member
|
|
2370
|
+
const newMembers = [...typedChannel.members, entityId];
|
|
2371
|
+
const now = createTimestamp();
|
|
2372
|
+
// Update channel and record event in transaction
|
|
2373
|
+
this.backend.transaction((tx) => {
|
|
2374
|
+
// Get current data
|
|
2375
|
+
const row = this.backend.queryOne('SELECT data FROM elements WHERE id = ?', [channelId]);
|
|
2376
|
+
if (!row)
|
|
2377
|
+
return;
|
|
2378
|
+
const data = JSON.parse(row.data);
|
|
2379
|
+
data.members = newMembers;
|
|
2380
|
+
// Recompute content hash
|
|
2381
|
+
const updatedChannel = { ...typedChannel, members: newMembers, updatedAt: now };
|
|
2382
|
+
const { hash: contentHash } = computeContentHashSync(updatedChannel);
|
|
2383
|
+
// Update element
|
|
2384
|
+
tx.run(`UPDATE elements SET data = ?, content_hash = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(data), contentHash, now, channelId]);
|
|
2385
|
+
// Record membership event
|
|
2386
|
+
const event = createEvent({
|
|
2387
|
+
elementId: channelId,
|
|
2388
|
+
eventType: MembershipEventType.MEMBER_ADDED,
|
|
2389
|
+
actor,
|
|
2390
|
+
oldValue: { members: typedChannel.members },
|
|
2391
|
+
newValue: { members: newMembers, addedMember: entityId },
|
|
2392
|
+
});
|
|
2393
|
+
tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
|
|
2394
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
2395
|
+
event.elementId,
|
|
2396
|
+
event.eventType,
|
|
2397
|
+
event.actor,
|
|
2398
|
+
JSON.stringify(event.oldValue),
|
|
2399
|
+
JSON.stringify(event.newValue),
|
|
2400
|
+
event.createdAt,
|
|
2401
|
+
]);
|
|
2402
|
+
});
|
|
2403
|
+
// Mark as dirty
|
|
2404
|
+
this.backend.markDirty(channelId);
|
|
2405
|
+
// Return updated channel
|
|
2406
|
+
const updatedChannel = await this.get(channelId);
|
|
2407
|
+
return {
|
|
2408
|
+
success: true,
|
|
2409
|
+
channel: updatedChannel,
|
|
2410
|
+
entityId,
|
|
2411
|
+
};
|
|
2412
|
+
}
|
|
2413
|
+
async removeChannelMember(channelId, entityId, options) {
|
|
2414
|
+
// Get the channel
|
|
2415
|
+
const channel = await this.get(channelId);
|
|
2416
|
+
if (!channel) {
|
|
2417
|
+
throw new NotFoundError(`Channel not found: ${channelId}`, ErrorCode.NOT_FOUND, { elementId: channelId });
|
|
2418
|
+
}
|
|
2419
|
+
// Verify it's a channel
|
|
2420
|
+
if (channel.type !== 'channel') {
|
|
2421
|
+
throw new ConstraintError(`Element is not a channel: ${channelId}`, ErrorCode.TYPE_MISMATCH, { elementId: channelId, actualType: channel.type, expectedType: 'channel' });
|
|
2422
|
+
}
|
|
2423
|
+
// Cast to Channel type (type guard validated above)
|
|
2424
|
+
const typedChannel = channel;
|
|
2425
|
+
// Direct channels cannot have membership modified
|
|
2426
|
+
if (typedChannel.channelType === ChannelTypeValue.DIRECT) {
|
|
2427
|
+
throw new DirectChannelMembershipError(channelId, 'remove');
|
|
2428
|
+
}
|
|
2429
|
+
// Get actor
|
|
2430
|
+
const actor = options?.actor ?? typedChannel.createdBy;
|
|
2431
|
+
// Check actor has permission to modify members
|
|
2432
|
+
if (!canModifyMembers(typedChannel, actor)) {
|
|
2433
|
+
throw new CannotModifyMembersError(channelId, actor);
|
|
2434
|
+
}
|
|
2435
|
+
// Check if entity is a member
|
|
2436
|
+
if (!isMember(typedChannel, entityId)) {
|
|
2437
|
+
throw new NotAMemberError(channelId, entityId);
|
|
2438
|
+
}
|
|
2439
|
+
// Remove member
|
|
2440
|
+
const newMembers = typedChannel.members.filter((m) => m !== entityId);
|
|
2441
|
+
// Also remove from modifyMembers if present
|
|
2442
|
+
const newModifyMembers = typedChannel.permissions.modifyMembers.filter((m) => m !== entityId);
|
|
2443
|
+
const now = createTimestamp();
|
|
2444
|
+
// Update channel and record event in transaction
|
|
2445
|
+
this.backend.transaction((tx) => {
|
|
2446
|
+
// Get current data
|
|
2447
|
+
const row = this.backend.queryOne('SELECT data FROM elements WHERE id = ?', [channelId]);
|
|
2448
|
+
if (!row)
|
|
2449
|
+
return;
|
|
2450
|
+
const data = JSON.parse(row.data);
|
|
2451
|
+
data.members = newMembers;
|
|
2452
|
+
data.permissions = {
|
|
2453
|
+
...data.permissions,
|
|
2454
|
+
modifyMembers: newModifyMembers,
|
|
2455
|
+
};
|
|
2456
|
+
// Recompute content hash
|
|
2457
|
+
const updatedChannel = {
|
|
2458
|
+
...typedChannel,
|
|
2459
|
+
members: newMembers,
|
|
2460
|
+
permissions: { ...typedChannel.permissions, modifyMembers: newModifyMembers },
|
|
2461
|
+
updatedAt: now,
|
|
2462
|
+
};
|
|
2463
|
+
const { hash: contentHash } = computeContentHashSync(updatedChannel);
|
|
2464
|
+
// Update element
|
|
2465
|
+
tx.run(`UPDATE elements SET data = ?, content_hash = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(data), contentHash, now, channelId]);
|
|
2466
|
+
// Record membership event
|
|
2467
|
+
const event = createEvent({
|
|
2468
|
+
elementId: channelId,
|
|
2469
|
+
eventType: MembershipEventType.MEMBER_REMOVED,
|
|
2470
|
+
actor,
|
|
2471
|
+
oldValue: { members: typedChannel.members },
|
|
2472
|
+
newValue: {
|
|
2473
|
+
members: newMembers,
|
|
2474
|
+
removedMember: entityId,
|
|
2475
|
+
...(options?.reason && { reason: options.reason }),
|
|
2476
|
+
},
|
|
2477
|
+
});
|
|
2478
|
+
tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
|
|
2479
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
2480
|
+
event.elementId,
|
|
2481
|
+
event.eventType,
|
|
2482
|
+
event.actor,
|
|
2483
|
+
JSON.stringify(event.oldValue),
|
|
2484
|
+
JSON.stringify(event.newValue),
|
|
2485
|
+
event.createdAt,
|
|
2486
|
+
]);
|
|
2487
|
+
});
|
|
2488
|
+
// Mark as dirty
|
|
2489
|
+
this.backend.markDirty(channelId);
|
|
2490
|
+
// Return updated channel
|
|
2491
|
+
const updatedChannel = await this.get(channelId);
|
|
2492
|
+
return {
|
|
2493
|
+
success: true,
|
|
2494
|
+
channel: updatedChannel,
|
|
2495
|
+
entityId,
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
async leaveChannel(channelId, actor) {
|
|
2499
|
+
// Get the channel
|
|
2500
|
+
const channel = await this.get(channelId);
|
|
2501
|
+
if (!channel) {
|
|
2502
|
+
throw new NotFoundError(`Channel not found: ${channelId}`, ErrorCode.NOT_FOUND, { elementId: channelId });
|
|
2503
|
+
}
|
|
2504
|
+
// Verify it's a channel
|
|
2505
|
+
if (channel.type !== 'channel') {
|
|
2506
|
+
throw new ConstraintError(`Element is not a channel: ${channelId}`, ErrorCode.TYPE_MISMATCH, { elementId: channelId, actualType: channel.type, expectedType: 'channel' });
|
|
2507
|
+
}
|
|
2508
|
+
// Cast to Channel type (type guard validated above)
|
|
2509
|
+
const typedChannel = channel;
|
|
2510
|
+
// Direct channels cannot be left
|
|
2511
|
+
if (typedChannel.channelType === ChannelTypeValue.DIRECT) {
|
|
2512
|
+
throw new ConstraintError('Cannot leave a direct channel', ErrorCode.IMMUTABLE, { channelId, channelType: 'direct' });
|
|
2513
|
+
}
|
|
2514
|
+
// Check if actor is a member
|
|
2515
|
+
if (!isMember(typedChannel, actor)) {
|
|
2516
|
+
throw new NotAMemberError(channelId, actor);
|
|
2517
|
+
}
|
|
2518
|
+
// Remove actor from members
|
|
2519
|
+
const newMembers = typedChannel.members.filter((m) => m !== actor);
|
|
2520
|
+
// Also remove from modifyMembers if present
|
|
2521
|
+
const newModifyMembers = typedChannel.permissions.modifyMembers.filter((m) => m !== actor);
|
|
2522
|
+
const now = createTimestamp();
|
|
2523
|
+
// Update channel and record event in transaction
|
|
2524
|
+
this.backend.transaction((tx) => {
|
|
2525
|
+
// Get current data
|
|
2526
|
+
const row = this.backend.queryOne('SELECT data FROM elements WHERE id = ?', [channelId]);
|
|
2527
|
+
if (!row)
|
|
2528
|
+
return;
|
|
2529
|
+
const data = JSON.parse(row.data);
|
|
2530
|
+
data.members = newMembers;
|
|
2531
|
+
data.permissions = {
|
|
2532
|
+
...data.permissions,
|
|
2533
|
+
modifyMembers: newModifyMembers,
|
|
2534
|
+
};
|
|
2535
|
+
// Recompute content hash
|
|
2536
|
+
const updatedChannelData = {
|
|
2537
|
+
...typedChannel,
|
|
2538
|
+
members: newMembers,
|
|
2539
|
+
permissions: { ...typedChannel.permissions, modifyMembers: newModifyMembers },
|
|
2540
|
+
updatedAt: now,
|
|
2541
|
+
};
|
|
2542
|
+
const { hash: contentHash } = computeContentHashSync(updatedChannelData);
|
|
2543
|
+
// Update element
|
|
2544
|
+
tx.run(`UPDATE elements SET data = ?, content_hash = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(data), contentHash, now, channelId]);
|
|
2545
|
+
// Record membership event (leaving is a special form of member_removed)
|
|
2546
|
+
const event = createEvent({
|
|
2547
|
+
elementId: channelId,
|
|
2548
|
+
eventType: MembershipEventType.MEMBER_REMOVED,
|
|
2549
|
+
actor,
|
|
2550
|
+
oldValue: { members: typedChannel.members },
|
|
2551
|
+
newValue: { members: newMembers, removedMember: actor, selfRemoval: true },
|
|
2552
|
+
});
|
|
2553
|
+
tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
|
|
2554
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
2555
|
+
event.elementId,
|
|
2556
|
+
event.eventType,
|
|
2557
|
+
event.actor,
|
|
2558
|
+
JSON.stringify(event.oldValue),
|
|
2559
|
+
JSON.stringify(event.newValue),
|
|
2560
|
+
event.createdAt,
|
|
2561
|
+
]);
|
|
2562
|
+
});
|
|
2563
|
+
// Mark as dirty
|
|
2564
|
+
this.backend.markDirty(channelId);
|
|
2565
|
+
// Return updated channel
|
|
2566
|
+
const updatedChannel = await this.get(channelId);
|
|
2567
|
+
return {
|
|
2568
|
+
success: true,
|
|
2569
|
+
channel: updatedChannel,
|
|
2570
|
+
entityId: actor,
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
/**
|
|
2574
|
+
* Merge two group channels: move all messages from source to target,
|
|
2575
|
+
* merge member lists, and archive the source channel.
|
|
2576
|
+
*
|
|
2577
|
+
* Only group channels can be merged. Direct channels are rejected.
|
|
2578
|
+
*/
|
|
2579
|
+
async mergeChannels(sourceId, targetId, options) {
|
|
2580
|
+
// Fetch both channels
|
|
2581
|
+
const source = await this.get(sourceId);
|
|
2582
|
+
if (!source || source.type !== 'channel') {
|
|
2583
|
+
throw new NotFoundError(`Source channel not found: ${sourceId}`, ErrorCode.NOT_FOUND, { elementId: sourceId });
|
|
2584
|
+
}
|
|
2585
|
+
const target = await this.get(targetId);
|
|
2586
|
+
if (!target || target.type !== 'channel') {
|
|
2587
|
+
throw new NotFoundError(`Target channel not found: ${targetId}`, ErrorCode.NOT_FOUND, { elementId: targetId });
|
|
2588
|
+
}
|
|
2589
|
+
const typedSource = source;
|
|
2590
|
+
const typedTarget = target;
|
|
2591
|
+
// Only group channels can be merged
|
|
2592
|
+
if (typedSource.channelType !== ChannelTypeValue.GROUP) {
|
|
2593
|
+
throw new ConstraintError('Cannot merge: source is not a group channel', ErrorCode.IMMUTABLE, { channelId: sourceId, channelType: typedSource.channelType });
|
|
2594
|
+
}
|
|
2595
|
+
if (typedTarget.channelType !== ChannelTypeValue.GROUP) {
|
|
2596
|
+
throw new ConstraintError('Cannot merge: target is not a group channel', ErrorCode.IMMUTABLE, { channelId: targetId, channelType: typedTarget.channelType });
|
|
2597
|
+
}
|
|
2598
|
+
const actor = options?.actor ?? typedTarget.createdBy;
|
|
2599
|
+
const now = createTimestamp();
|
|
2600
|
+
// Get all messages from source channel
|
|
2601
|
+
const sourceMessages = this.backend.query(`SELECT * FROM elements
|
|
2602
|
+
WHERE type = 'message'
|
|
2603
|
+
AND JSON_EXTRACT(data, '$.channelId') = ?
|
|
2604
|
+
AND deleted_at IS NULL`, [sourceId]);
|
|
2605
|
+
// Merge members: add source members not already in target
|
|
2606
|
+
const targetMemberSet = new Set(typedTarget.members);
|
|
2607
|
+
const newMembers = [...typedTarget.members];
|
|
2608
|
+
for (const member of typedSource.members) {
|
|
2609
|
+
if (!targetMemberSet.has(member)) {
|
|
2610
|
+
newMembers.push(member);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
// Merge modifyMembers similarly
|
|
2614
|
+
const targetModSet = new Set(typedTarget.permissions.modifyMembers);
|
|
2615
|
+
const newModifyMembers = [...typedTarget.permissions.modifyMembers];
|
|
2616
|
+
for (const mod of typedSource.permissions.modifyMembers) {
|
|
2617
|
+
if (!targetModSet.has(mod)) {
|
|
2618
|
+
newModifyMembers.push(mod);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
const newName = options?.newName ?? typedTarget.name;
|
|
2622
|
+
// Execute everything in a transaction
|
|
2623
|
+
this.backend.transaction((tx) => {
|
|
2624
|
+
// 1. Move messages: update channelId in each message's data
|
|
2625
|
+
for (const msgRow of sourceMessages) {
|
|
2626
|
+
const msgData = JSON.parse(msgRow.data);
|
|
2627
|
+
msgData.channelId = targetId;
|
|
2628
|
+
tx.run(`UPDATE elements SET data = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(msgData), now, msgRow.id]);
|
|
2629
|
+
}
|
|
2630
|
+
// 2. Update inbox_items channel_id for moved messages
|
|
2631
|
+
const messageIds = sourceMessages.map((m) => m.id);
|
|
2632
|
+
if (messageIds.length > 0) {
|
|
2633
|
+
const placeholders = messageIds.map(() => '?').join(',');
|
|
2634
|
+
tx.run(`UPDATE inbox_items SET channel_id = ? WHERE message_id IN (${placeholders})`, [targetId, ...messageIds]);
|
|
2635
|
+
}
|
|
2636
|
+
// 3. Update target channel: merged members, optional rename
|
|
2637
|
+
const targetRow = this.backend.queryOne('SELECT data FROM elements WHERE id = ?', [targetId]);
|
|
2638
|
+
if (targetRow) {
|
|
2639
|
+
const targetData = JSON.parse(targetRow.data);
|
|
2640
|
+
targetData.members = newMembers;
|
|
2641
|
+
targetData.permissions = {
|
|
2642
|
+
...targetData.permissions,
|
|
2643
|
+
modifyMembers: newModifyMembers,
|
|
2644
|
+
};
|
|
2645
|
+
if (options?.newName) {
|
|
2646
|
+
targetData.name = newName;
|
|
2647
|
+
}
|
|
2648
|
+
const updatedTarget = {
|
|
2649
|
+
...typedTarget,
|
|
2650
|
+
members: newMembers,
|
|
2651
|
+
permissions: { ...typedTarget.permissions, modifyMembers: newModifyMembers },
|
|
2652
|
+
name: newName,
|
|
2653
|
+
updatedAt: now,
|
|
2654
|
+
};
|
|
2655
|
+
const { hash: contentHash } = computeContentHashSync(updatedTarget);
|
|
2656
|
+
tx.run(`UPDATE elements SET data = ?, content_hash = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(targetData), contentHash, now, targetId]);
|
|
2657
|
+
}
|
|
2658
|
+
// 4. Archive source channel (soft delete)
|
|
2659
|
+
tx.run(`UPDATE elements SET deleted_at = ? WHERE id = ?`, [now, sourceId]);
|
|
2660
|
+
// 5. Record merge event on target
|
|
2661
|
+
const event = createEvent({
|
|
2662
|
+
elementId: targetId,
|
|
2663
|
+
eventType: LifecycleEventType.UPDATED,
|
|
2664
|
+
actor,
|
|
2665
|
+
oldValue: { members: typedTarget.members, name: typedTarget.name },
|
|
2666
|
+
newValue: {
|
|
2667
|
+
members: newMembers,
|
|
2668
|
+
name: newName,
|
|
2669
|
+
mergedFrom: sourceId,
|
|
2670
|
+
messagesMoved: sourceMessages.length,
|
|
2671
|
+
},
|
|
2672
|
+
});
|
|
2673
|
+
tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
|
|
2674
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [event.elementId, event.eventType, event.actor, JSON.stringify(event.oldValue), JSON.stringify(event.newValue), event.createdAt]);
|
|
2675
|
+
});
|
|
2676
|
+
// Mark both as dirty
|
|
2677
|
+
this.backend.markDirty(sourceId);
|
|
2678
|
+
this.backend.markDirty(targetId);
|
|
2679
|
+
// Return updated target
|
|
2680
|
+
const updatedTarget = await this.get(targetId);
|
|
2681
|
+
return {
|
|
2682
|
+
target: updatedTarget,
|
|
2683
|
+
sourceArchived: true,
|
|
2684
|
+
messagesMoved: sourceMessages.length,
|
|
2685
|
+
};
|
|
2686
|
+
}
|
|
2687
|
+
/**
|
|
2688
|
+
* Send a direct message to another entity
|
|
2689
|
+
*
|
|
2690
|
+
* This is a convenience method that:
|
|
2691
|
+
* 1. Finds or creates the direct channel between sender and recipient
|
|
2692
|
+
* 2. Creates and sends the message in that channel
|
|
2693
|
+
*
|
|
2694
|
+
* @param sender - The entity sending the message
|
|
2695
|
+
* @param input - The message input including recipient, contentRef, etc.
|
|
2696
|
+
* @returns The created message and channel information
|
|
2697
|
+
*/
|
|
2698
|
+
async sendDirectMessage(sender, input) {
|
|
2699
|
+
// Find or create the direct channel
|
|
2700
|
+
const { channel, created: channelCreated } = await this.findOrCreateDirectChannel(sender, input.recipient, sender);
|
|
2701
|
+
// Create the message
|
|
2702
|
+
const message = await createMessage({
|
|
2703
|
+
channelId: channel.id,
|
|
2704
|
+
sender,
|
|
2705
|
+
contentRef: input.contentRef,
|
|
2706
|
+
attachments: input.attachments,
|
|
2707
|
+
tags: input.tags,
|
|
2708
|
+
metadata: input.metadata,
|
|
2709
|
+
});
|
|
2710
|
+
// Persist the message (membership validation happens in create)
|
|
2711
|
+
const createdMessage = await this.create(message);
|
|
2712
|
+
return {
|
|
2713
|
+
message: createdMessage,
|
|
2714
|
+
channel,
|
|
2715
|
+
channelCreated,
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2718
|
+
// --------------------------------------------------------------------------
|
|
2719
|
+
// Workflow Operations
|
|
2720
|
+
// --------------------------------------------------------------------------
|
|
2721
|
+
async deleteWorkflow(workflowId, options) {
|
|
2722
|
+
// Get the workflow
|
|
2723
|
+
const workflow = await this.get(workflowId);
|
|
2724
|
+
if (!workflow) {
|
|
2725
|
+
throw new NotFoundError(`Workflow not found: ${workflowId}`, ErrorCode.NOT_FOUND, { id: workflowId });
|
|
2726
|
+
}
|
|
2727
|
+
if (workflow.type !== 'workflow') {
|
|
2728
|
+
throw new ValidationError(`Element ${workflowId} is not a workflow (type: ${workflow.type})`, ErrorCode.INVALID_INPUT, { field: 'workflowId', value: workflowId });
|
|
2729
|
+
}
|
|
2730
|
+
const wasEphemeral = workflow.ephemeral ?? false;
|
|
2731
|
+
// Get all dependencies to find tasks
|
|
2732
|
+
const allDependencies = await this.getAllDependencies();
|
|
2733
|
+
// Find task IDs that are children of this workflow
|
|
2734
|
+
const taskIds = [];
|
|
2735
|
+
for (const dep of allDependencies) {
|
|
2736
|
+
if (dep.type === 'parent-child' && dep.blockerId === workflowId) {
|
|
2737
|
+
taskIds.push(dep.blockedId);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
// Find all dependencies involving the workflow or its tasks
|
|
2741
|
+
const elementIds = new Set([workflowId, ...taskIds]);
|
|
2742
|
+
const depsToDelete = allDependencies.filter((dep) => elementIds.has(dep.blockedId) || elementIds.has(dep.blockerId));
|
|
2743
|
+
// Delete dependencies first
|
|
2744
|
+
for (const dep of depsToDelete) {
|
|
2745
|
+
try {
|
|
2746
|
+
await this.removeDependency(dep.blockedId, dep.blockerId, dep.type, options?.actor);
|
|
2747
|
+
}
|
|
2748
|
+
catch {
|
|
2749
|
+
// Ignore errors for dependencies that don't exist
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
// Delete tasks
|
|
2753
|
+
for (const taskId of taskIds) {
|
|
2754
|
+
try {
|
|
2755
|
+
// Hard delete via SQL since this is a destructive delete
|
|
2756
|
+
this.backend.run('DELETE FROM elements WHERE id = ?', [taskId]);
|
|
2757
|
+
this.backend.run('DELETE FROM tags WHERE element_id = ?', [taskId]);
|
|
2758
|
+
this.backend.run('DELETE FROM events WHERE element_id = ?', [taskId]);
|
|
2759
|
+
}
|
|
2760
|
+
catch {
|
|
2761
|
+
// Ignore errors for tasks that don't exist
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
// Delete the workflow itself
|
|
2765
|
+
this.backend.run('DELETE FROM elements WHERE id = ?', [workflowId]);
|
|
2766
|
+
this.backend.run('DELETE FROM tags WHERE element_id = ?', [workflowId]);
|
|
2767
|
+
this.backend.run('DELETE FROM events WHERE element_id = ?', [workflowId]);
|
|
2768
|
+
return {
|
|
2769
|
+
workflowId,
|
|
2770
|
+
tasksDeleted: taskIds.length,
|
|
2771
|
+
dependenciesDeleted: depsToDelete.length,
|
|
2772
|
+
wasEphemeral,
|
|
2773
|
+
};
|
|
2774
|
+
}
|
|
2775
|
+
async garbageCollectWorkflows(options) {
|
|
2776
|
+
const now = Date.now();
|
|
2777
|
+
const result = {
|
|
2778
|
+
workflowsDeleted: 0,
|
|
2779
|
+
tasksDeleted: 0,
|
|
2780
|
+
dependenciesDeleted: 0,
|
|
2781
|
+
deletedWorkflowIds: [],
|
|
2782
|
+
};
|
|
2783
|
+
// Find all ephemeral workflows in terminal state
|
|
2784
|
+
const workflows = await this.list({ type: 'workflow' });
|
|
2785
|
+
const candidates = [];
|
|
2786
|
+
for (const workflow of workflows) {
|
|
2787
|
+
// Must be ephemeral
|
|
2788
|
+
if (!workflow.ephemeral)
|
|
2789
|
+
continue;
|
|
2790
|
+
// Must be in terminal state
|
|
2791
|
+
const terminalStatuses = ['completed', 'failed', 'cancelled'];
|
|
2792
|
+
if (!terminalStatuses.includes(workflow.status))
|
|
2793
|
+
continue;
|
|
2794
|
+
// Must have finished
|
|
2795
|
+
if (!workflow.finishedAt)
|
|
2796
|
+
continue;
|
|
2797
|
+
// Must be old enough
|
|
2798
|
+
const finishedTime = new Date(workflow.finishedAt).getTime();
|
|
2799
|
+
const age = now - finishedTime;
|
|
2800
|
+
if (age < options.maxAgeMs)
|
|
2801
|
+
continue;
|
|
2802
|
+
candidates.push(workflow);
|
|
2803
|
+
}
|
|
2804
|
+
// Apply limit if specified
|
|
2805
|
+
const toDelete = options.limit ? candidates.slice(0, options.limit) : candidates;
|
|
2806
|
+
// If dry run, just return what would be deleted
|
|
2807
|
+
if (options.dryRun) {
|
|
2808
|
+
// Count what would be deleted
|
|
2809
|
+
const allDeps = await this.getAllDependencies();
|
|
2810
|
+
for (const workflow of toDelete) {
|
|
2811
|
+
result.deletedWorkflowIds.push(workflow.id);
|
|
2812
|
+
result.workflowsDeleted++;
|
|
2813
|
+
// Count tasks
|
|
2814
|
+
for (const dep of allDeps) {
|
|
2815
|
+
if (dep.type === 'parent-child' && dep.blockerId === workflow.id) {
|
|
2816
|
+
result.tasksDeleted++;
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
return result;
|
|
2821
|
+
}
|
|
2822
|
+
// Actually delete
|
|
2823
|
+
for (const workflow of toDelete) {
|
|
2824
|
+
const deleteResult = await this.deleteWorkflow(workflow.id);
|
|
2825
|
+
result.workflowsDeleted++;
|
|
2826
|
+
result.tasksDeleted += deleteResult.tasksDeleted;
|
|
2827
|
+
result.dependenciesDeleted += deleteResult.dependenciesDeleted;
|
|
2828
|
+
result.deletedWorkflowIds.push(workflow.id);
|
|
2829
|
+
}
|
|
2830
|
+
return result;
|
|
2831
|
+
}
|
|
2832
|
+
async garbageCollectTasks(_options) {
|
|
2833
|
+
// Tasks no longer have an ephemeral property - only workflows can be ephemeral.
|
|
2834
|
+
// Tasks belonging to ephemeral workflows are garbage collected via garbageCollectWorkflows().
|
|
2835
|
+
// This method is now a no-op for backwards compatibility.
|
|
2836
|
+
return {
|
|
2837
|
+
tasksDeleted: 0,
|
|
2838
|
+
dependenciesDeleted: 0,
|
|
2839
|
+
deletedTaskIds: [],
|
|
2840
|
+
};
|
|
2841
|
+
}
|
|
2842
|
+
async getTasksInWorkflow(workflowId, filter) {
|
|
2843
|
+
// Verify workflow exists
|
|
2844
|
+
const workflow = await this.get(workflowId);
|
|
2845
|
+
if (!workflow) {
|
|
2846
|
+
throw new NotFoundError(`Workflow not found: ${workflowId}`, ErrorCode.NOT_FOUND, { elementId: workflowId });
|
|
2847
|
+
}
|
|
2848
|
+
if (workflow.type !== 'workflow') {
|
|
2849
|
+
throw new ConstraintError(`Element is not a workflow: ${workflowId}`, ErrorCode.TYPE_MISMATCH, { elementId: workflowId, actualType: workflow.type, expectedType: 'workflow' });
|
|
2850
|
+
}
|
|
2851
|
+
// Get all elements that have parent-child dependency to this workflow
|
|
2852
|
+
const dependents = await this.getDependents(workflowId, ['parent-child']);
|
|
2853
|
+
// If no dependents, return empty array
|
|
2854
|
+
if (dependents.length === 0) {
|
|
2855
|
+
return [];
|
|
2856
|
+
}
|
|
2857
|
+
// Fetch tasks by their IDs
|
|
2858
|
+
const taskIds = dependents.map((d) => d.blockedId);
|
|
2859
|
+
const tasks = [];
|
|
2860
|
+
for (const taskId of taskIds) {
|
|
2861
|
+
const task = await this.get(taskId);
|
|
2862
|
+
if (task && task.type === 'task') {
|
|
2863
|
+
tasks.push(task);
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
// Apply filters if provided
|
|
2867
|
+
let filteredTasks = tasks;
|
|
2868
|
+
if (filter?.status) {
|
|
2869
|
+
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
|
|
2870
|
+
filteredTasks = filteredTasks.filter((t) => statuses.includes(t.status));
|
|
2871
|
+
}
|
|
2872
|
+
if (filter?.priority) {
|
|
2873
|
+
const priorities = Array.isArray(filter.priority) ? filter.priority : [filter.priority];
|
|
2874
|
+
filteredTasks = filteredTasks.filter((t) => priorities.includes(t.priority));
|
|
2875
|
+
}
|
|
2876
|
+
if (filter?.assignee) {
|
|
2877
|
+
filteredTasks = filteredTasks.filter((t) => t.assignee === filter.assignee);
|
|
2878
|
+
}
|
|
2879
|
+
if (filter?.owner) {
|
|
2880
|
+
filteredTasks = filteredTasks.filter((t) => t.owner === filter.owner);
|
|
2881
|
+
}
|
|
2882
|
+
if (filter?.tags && filter.tags.length > 0) {
|
|
2883
|
+
filteredTasks = filteredTasks.filter((t) => filter.tags.every((tag) => t.tags.includes(tag)));
|
|
2884
|
+
}
|
|
2885
|
+
if (filter?.includeDeleted !== true) {
|
|
2886
|
+
filteredTasks = filteredTasks.filter((t) => t.status !== 'tombstone');
|
|
2887
|
+
}
|
|
2888
|
+
return filteredTasks;
|
|
2889
|
+
}
|
|
2890
|
+
async getReadyTasksInWorkflow(workflowId, filter) {
|
|
2891
|
+
// Get all tasks in the workflow
|
|
2892
|
+
const tasks = await this.getTasksInWorkflow(workflowId, {
|
|
2893
|
+
...filter,
|
|
2894
|
+
status: [TaskStatusEnum.OPEN, TaskStatusEnum.IN_PROGRESS],
|
|
2895
|
+
});
|
|
2896
|
+
// Filter out blocked tasks
|
|
2897
|
+
const blockedIds = new Set(this.backend.query('SELECT element_id FROM blocked_cache').map((r) => r.element_id));
|
|
2898
|
+
// Filter out scheduled-for-future tasks
|
|
2899
|
+
const now = new Date();
|
|
2900
|
+
const readyTasks = tasks.filter((task) => {
|
|
2901
|
+
// Not blocked
|
|
2902
|
+
if (blockedIds.has(task.id)) {
|
|
2903
|
+
return false;
|
|
2904
|
+
}
|
|
2905
|
+
// Not scheduled for future
|
|
2906
|
+
if (task.scheduledFor && new Date(task.scheduledFor) > now) {
|
|
2907
|
+
return false;
|
|
2908
|
+
}
|
|
2909
|
+
return true;
|
|
2910
|
+
});
|
|
2911
|
+
// Calculate effective priorities and sort
|
|
2912
|
+
const tasksWithPriority = this.priorityService.enhanceTasksWithEffectivePriority(readyTasks);
|
|
2913
|
+
this.priorityService.sortByEffectivePriority(tasksWithPriority);
|
|
2914
|
+
// Apply limit after sorting
|
|
2915
|
+
if (filter?.limit !== undefined) {
|
|
2916
|
+
return tasksWithPriority.slice(0, filter.limit);
|
|
2917
|
+
}
|
|
2918
|
+
return tasksWithPriority;
|
|
2919
|
+
}
|
|
2920
|
+
async getWorkflowProgress(workflowId) {
|
|
2921
|
+
// Verify workflow exists
|
|
2922
|
+
const workflow = await this.get(workflowId);
|
|
2923
|
+
if (!workflow) {
|
|
2924
|
+
throw new NotFoundError(`Workflow not found: ${workflowId}`, ErrorCode.NOT_FOUND, { elementId: workflowId });
|
|
2925
|
+
}
|
|
2926
|
+
if (workflow.type !== 'workflow') {
|
|
2927
|
+
throw new ConstraintError(`Element is not a workflow: ${workflowId}`, ErrorCode.TYPE_MISMATCH, { elementId: workflowId, actualType: workflow.type, expectedType: 'workflow' });
|
|
2928
|
+
}
|
|
2929
|
+
// Get all tasks in the workflow (excluding tombstones)
|
|
2930
|
+
const tasks = await this.getTasksInWorkflow(workflowId, { includeDeleted: false });
|
|
2931
|
+
// Count tasks by status
|
|
2932
|
+
const statusCounts = {
|
|
2933
|
+
open: 0,
|
|
2934
|
+
in_progress: 0,
|
|
2935
|
+
blocked: 0,
|
|
2936
|
+
closed: 0,
|
|
2937
|
+
deferred: 0,
|
|
2938
|
+
tombstone: 0,
|
|
2939
|
+
};
|
|
2940
|
+
for (const task of tasks) {
|
|
2941
|
+
if (task.status in statusCounts) {
|
|
2942
|
+
statusCounts[task.status]++;
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
// Get blocked and ready counts
|
|
2946
|
+
const blockedIds = new Set(this.backend.query('SELECT element_id FROM blocked_cache').map((r) => r.element_id));
|
|
2947
|
+
const taskIds = new Set(tasks.map((t) => t.id));
|
|
2948
|
+
const blockedCount = [...blockedIds].filter((id) => taskIds.has(id)).length;
|
|
2949
|
+
// Ready = open/in_progress, not blocked, not scheduled for future
|
|
2950
|
+
const now = new Date();
|
|
2951
|
+
const readyCount = tasks.filter((task) => {
|
|
2952
|
+
if (task.status !== TaskStatusEnum.OPEN && task.status !== TaskStatusEnum.IN_PROGRESS) {
|
|
2953
|
+
return false;
|
|
2954
|
+
}
|
|
2955
|
+
if (blockedIds.has(task.id)) {
|
|
2956
|
+
return false;
|
|
2957
|
+
}
|
|
2958
|
+
if (task.scheduledFor && new Date(task.scheduledFor) > now) {
|
|
2959
|
+
return false;
|
|
2960
|
+
}
|
|
2961
|
+
return true;
|
|
2962
|
+
}).length;
|
|
2963
|
+
// Calculate completion percentage
|
|
2964
|
+
const total = tasks.length;
|
|
2965
|
+
const completed = statusCounts.closed;
|
|
2966
|
+
const completionPercentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
2967
|
+
return {
|
|
2968
|
+
workflowId,
|
|
2969
|
+
totalTasks: total,
|
|
2970
|
+
statusCounts,
|
|
2971
|
+
completionPercentage,
|
|
2972
|
+
readyTasks: readyCount,
|
|
2973
|
+
blockedTasks: blockedCount,
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
/**
|
|
2977
|
+
* Get tasks in a workflow ordered by execution order (topological sort).
|
|
2978
|
+
*
|
|
2979
|
+
* Tasks are ordered such that blockers come before the tasks they block.
|
|
2980
|
+
* This represents the order in which tasks should be executed.
|
|
2981
|
+
*
|
|
2982
|
+
* @param workflowId - The workflow ID
|
|
2983
|
+
* @param filter - Optional filter to apply to tasks
|
|
2984
|
+
* @returns Tasks in execution order (topological sort based on blocks dependencies)
|
|
2985
|
+
*/
|
|
2986
|
+
async getOrderedTasksInWorkflow(workflowId, filter) {
|
|
2987
|
+
// Get all tasks in the workflow
|
|
2988
|
+
const tasks = await this.getTasksInWorkflow(workflowId, filter);
|
|
2989
|
+
if (tasks.length === 0) {
|
|
2990
|
+
return [];
|
|
2991
|
+
}
|
|
2992
|
+
// Build task lookup
|
|
2993
|
+
const taskById = new Map();
|
|
2994
|
+
for (const task of tasks) {
|
|
2995
|
+
taskById.set(task.id, task);
|
|
2996
|
+
}
|
|
2997
|
+
// Get blocks dependencies between tasks in this workflow
|
|
2998
|
+
const taskIds = tasks.map((t) => t.id);
|
|
2999
|
+
const taskIdSet = new Set(taskIds);
|
|
3000
|
+
// Query blocks dependencies where both source and target are in this workflow
|
|
3001
|
+
const placeholders = taskIds.map(() => '?').join(', ');
|
|
3002
|
+
const deps = this.backend.query(`SELECT blocker_id, blocked_id FROM dependencies
|
|
3003
|
+
WHERE type = 'blocks'
|
|
3004
|
+
AND blocker_id IN (${placeholders})
|
|
3005
|
+
AND blocked_id IN (${placeholders})`, [...taskIds, ...taskIds]);
|
|
3006
|
+
// Build adjacency list: blockedBy[taskId] = list of tasks that block it
|
|
3007
|
+
const blockedBy = new Map();
|
|
3008
|
+
for (const task of tasks) {
|
|
3009
|
+
blockedBy.set(task.id, []);
|
|
3010
|
+
}
|
|
3011
|
+
for (const dep of deps) {
|
|
3012
|
+
// In blocks dependency: blocked_id = blocked, blocker_id = blocker (blocked waits for blocker)
|
|
3013
|
+
// So blocked_id is blocked by blocker_id
|
|
3014
|
+
if (taskIdSet.has(dep.blocker_id) && taskIdSet.has(dep.blocked_id)) {
|
|
3015
|
+
const current = blockedBy.get(dep.blocked_id) ?? [];
|
|
3016
|
+
current.push(dep.blocker_id);
|
|
3017
|
+
blockedBy.set(dep.blocked_id, current);
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
// Kahn's algorithm for topological sort
|
|
3021
|
+
const inDegree = new Map();
|
|
3022
|
+
for (const task of tasks) {
|
|
3023
|
+
inDegree.set(task.id, (blockedBy.get(task.id) ?? []).length);
|
|
3024
|
+
}
|
|
3025
|
+
// Start with tasks that have no blockers
|
|
3026
|
+
const queue = [];
|
|
3027
|
+
for (const task of tasks) {
|
|
3028
|
+
if (inDegree.get(task.id) === 0) {
|
|
3029
|
+
queue.push(task.id);
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
// Sort queue by priority for consistent ordering of tasks at same level
|
|
3033
|
+
queue.sort((a, b) => {
|
|
3034
|
+
const taskA = taskById.get(a);
|
|
3035
|
+
const taskB = taskById.get(b);
|
|
3036
|
+
return taskA.priority - taskB.priority;
|
|
3037
|
+
});
|
|
3038
|
+
const result = [];
|
|
3039
|
+
const processed = new Set();
|
|
3040
|
+
while (queue.length > 0) {
|
|
3041
|
+
const taskId = queue.shift();
|
|
3042
|
+
if (processed.has(taskId)) {
|
|
3043
|
+
continue;
|
|
3044
|
+
}
|
|
3045
|
+
processed.add(taskId);
|
|
3046
|
+
const task = taskById.get(taskId);
|
|
3047
|
+
if (task) {
|
|
3048
|
+
result.push(task);
|
|
3049
|
+
}
|
|
3050
|
+
// Find tasks that were blocked by this one (this task is blocker_id = blocker)
|
|
3051
|
+
// and reduce their in-degree
|
|
3052
|
+
for (const dep of deps) {
|
|
3053
|
+
// dep.blocker_id = blocker, dep.blocked_id = blocked (blocked waits for blocker)
|
|
3054
|
+
// If this task is the blocker (blocker_id), the blocked task (blocked_id) can progress
|
|
3055
|
+
if (dep.blocker_id === taskId && !processed.has(dep.blocked_id)) {
|
|
3056
|
+
const newDegree = (inDegree.get(dep.blocked_id) ?? 1) - 1;
|
|
3057
|
+
inDegree.set(dep.blocked_id, newDegree);
|
|
3058
|
+
if (newDegree === 0) {
|
|
3059
|
+
queue.push(dep.blocked_id);
|
|
3060
|
+
// Re-sort queue by priority
|
|
3061
|
+
queue.sort((a, b) => {
|
|
3062
|
+
const taskA = taskById.get(a);
|
|
3063
|
+
const taskB = taskById.get(b);
|
|
3064
|
+
return taskA.priority - taskB.priority;
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
// If there are remaining tasks (cycle detected or isolated), append them by priority
|
|
3071
|
+
for (const task of tasks) {
|
|
3072
|
+
if (!processed.has(task.id)) {
|
|
3073
|
+
result.push(task);
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
return result;
|
|
3077
|
+
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Get all dependencies from storage
|
|
3080
|
+
*/
|
|
3081
|
+
async getAllDependencies() {
|
|
3082
|
+
const rows = this.backend.query('SELECT * FROM dependencies');
|
|
3083
|
+
return rows.map((row) => ({
|
|
3084
|
+
blockedId: row.blocked_id,
|
|
3085
|
+
blockerId: row.blocker_id,
|
|
3086
|
+
type: row.type,
|
|
3087
|
+
createdAt: row.created_at,
|
|
3088
|
+
createdBy: row.created_by,
|
|
3089
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : {},
|
|
3090
|
+
}));
|
|
3091
|
+
}
|
|
3092
|
+
// --------------------------------------------------------------------------
|
|
3093
|
+
// Document Convenience Methods
|
|
3094
|
+
// --------------------------------------------------------------------------
|
|
3095
|
+
async archiveDocument(id) {
|
|
3096
|
+
const doc = await this.get(id);
|
|
3097
|
+
if (!doc || doc.type !== 'document') {
|
|
3098
|
+
throw new NotFoundError(`Document not found: ${id}`, ErrorCode.NOT_FOUND, { elementId: id });
|
|
3099
|
+
}
|
|
3100
|
+
return this.update(id, { status: 'archived' });
|
|
3101
|
+
}
|
|
3102
|
+
async unarchiveDocument(id) {
|
|
3103
|
+
const doc = await this.get(id);
|
|
3104
|
+
if (!doc || doc.type !== 'document') {
|
|
3105
|
+
throw new NotFoundError(`Document not found: ${id}`, ErrorCode.NOT_FOUND, { elementId: id });
|
|
3106
|
+
}
|
|
3107
|
+
return this.update(id, { status: 'active' });
|
|
3108
|
+
}
|
|
3109
|
+
// --------------------------------------------------------------------------
|
|
3110
|
+
// Embedding Service Registration
|
|
3111
|
+
// --------------------------------------------------------------------------
|
|
3112
|
+
registerEmbeddingService(service) {
|
|
3113
|
+
this.embeddingService = service;
|
|
3114
|
+
}
|
|
3115
|
+
// --------------------------------------------------------------------------
|
|
3116
|
+
// FTS Availability Check
|
|
3117
|
+
// --------------------------------------------------------------------------
|
|
3118
|
+
checkFTSAvailable() {
|
|
3119
|
+
try {
|
|
3120
|
+
const row = this.backend.queryOne(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'documents_fts'`);
|
|
3121
|
+
return !!row;
|
|
3122
|
+
}
|
|
3123
|
+
catch {
|
|
3124
|
+
return false;
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
// --------------------------------------------------------------------------
|
|
3128
|
+
// FTS Index Maintenance
|
|
3129
|
+
// --------------------------------------------------------------------------
|
|
3130
|
+
/**
|
|
3131
|
+
* Index a document in the FTS5 virtual table for full-text search.
|
|
3132
|
+
* Called after document creation and update.
|
|
3133
|
+
*/
|
|
3134
|
+
indexDocumentForFTS(doc) {
|
|
3135
|
+
if (!this.checkFTSAvailable())
|
|
3136
|
+
return;
|
|
3137
|
+
try {
|
|
3138
|
+
const title = doc.title ?? '';
|
|
3139
|
+
// Remove existing entry first (idempotent)
|
|
3140
|
+
this.backend.run(`DELETE FROM documents_fts WHERE document_id = ?`, [doc.id]);
|
|
3141
|
+
// Insert new entry
|
|
3142
|
+
this.backend.run(`INSERT INTO documents_fts (document_id, title, content, tags, category)
|
|
3143
|
+
VALUES (?, ?, ?, ?, ?)`, [
|
|
3144
|
+
doc.id,
|
|
3145
|
+
title,
|
|
3146
|
+
doc.content,
|
|
3147
|
+
doc.tags.join(' '),
|
|
3148
|
+
doc.category,
|
|
3149
|
+
]);
|
|
3150
|
+
}
|
|
3151
|
+
catch (error) {
|
|
3152
|
+
console.warn(`[stoneforge] FTS index failed for ${doc.id}:`, error);
|
|
3153
|
+
}
|
|
3154
|
+
// Auto-embed if embedding service is registered
|
|
3155
|
+
if (this.embeddingService) {
|
|
3156
|
+
const text = `${doc.title ?? ''} ${doc.content}`.trim();
|
|
3157
|
+
this.embeddingService.indexDocument(doc.id, text).catch((error) => {
|
|
3158
|
+
console.warn(`[stoneforge] Embedding index failed for ${doc.id}:`, error);
|
|
3159
|
+
});
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
// --------------------------------------------------------------------------
|
|
3163
|
+
// FTS Reindex
|
|
3164
|
+
// --------------------------------------------------------------------------
|
|
3165
|
+
/**
|
|
3166
|
+
* Reindex all documents in the FTS5 virtual table.
|
|
3167
|
+
* Does NOT create version history entries — safe for bulk reindex.
|
|
3168
|
+
*/
|
|
3169
|
+
reindexAllDocumentsFTS() {
|
|
3170
|
+
const docs = this.backend.query(`SELECT * FROM elements WHERE type = 'document' AND deleted_at IS NULL`);
|
|
3171
|
+
let indexed = 0;
|
|
3172
|
+
let errors = 0;
|
|
3173
|
+
for (const row of docs) {
|
|
3174
|
+
try {
|
|
3175
|
+
const data = JSON.parse(row.data);
|
|
3176
|
+
const doc = {
|
|
3177
|
+
id: row.id,
|
|
3178
|
+
type: 'document',
|
|
3179
|
+
createdAt: row.created_at,
|
|
3180
|
+
updatedAt: row.updated_at,
|
|
3181
|
+
createdBy: row.created_by,
|
|
3182
|
+
tags: data.tags ?? [],
|
|
3183
|
+
metadata: data.metadata ?? {},
|
|
3184
|
+
content: data.content ?? '',
|
|
3185
|
+
contentType: data.contentType ?? 'text',
|
|
3186
|
+
version: data.version ?? 1,
|
|
3187
|
+
previousVersionId: data.previousVersionId ?? null,
|
|
3188
|
+
category: data.category ?? 'other',
|
|
3189
|
+
status: data.status ?? 'active',
|
|
3190
|
+
title: data.title,
|
|
3191
|
+
immutable: data.immutable ?? false,
|
|
3192
|
+
};
|
|
3193
|
+
this.indexDocumentForFTS(doc);
|
|
3194
|
+
indexed++;
|
|
3195
|
+
}
|
|
3196
|
+
catch {
|
|
3197
|
+
errors++;
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
return { indexed, errors };
|
|
3201
|
+
}
|
|
3202
|
+
/**
|
|
3203
|
+
* Reindex imported documents after sync completes.
|
|
3204
|
+
* Called internally after import operations.
|
|
3205
|
+
*/
|
|
3206
|
+
reindexDocumentsAfterImport() {
|
|
3207
|
+
this.reindexAllDocumentsFTS();
|
|
3208
|
+
}
|
|
3209
|
+
// --------------------------------------------------------------------------
|
|
3210
|
+
// FTS5 Full-Text Search
|
|
3211
|
+
// --------------------------------------------------------------------------
|
|
3212
|
+
async searchDocumentsFTS(query, options = {}) {
|
|
3213
|
+
const { category, status, hardCap = 50, elbowSensitivity = 1.5, minResults = 1, } = options;
|
|
3214
|
+
// Check FTS table availability
|
|
3215
|
+
if (!this.checkFTSAvailable()) {
|
|
3216
|
+
throw new StorageError('FTS5 search is unavailable: the documents_fts table does not exist. Run schema migrations to enable full-text search.', ErrorCode.DATABASE_ERROR);
|
|
3217
|
+
}
|
|
3218
|
+
const escaped = escapeFts5Query(query);
|
|
3219
|
+
if (!escaped)
|
|
3220
|
+
return [];
|
|
3221
|
+
try {
|
|
3222
|
+
// Build FTS5 query with BM25 ranking and snippet generation
|
|
3223
|
+
// BM25 returns negative scores (more negative = more relevant)
|
|
3224
|
+
let sql = `
|
|
3225
|
+
SELECT
|
|
3226
|
+
f.document_id,
|
|
3227
|
+
bm25(documents_fts) AS score,
|
|
3228
|
+
snippet(documents_fts, 2, '<mark>', '</mark>', '...', 40) AS snippet
|
|
3229
|
+
FROM documents_fts f
|
|
3230
|
+
JOIN elements e ON f.document_id = e.id
|
|
3231
|
+
WHERE documents_fts MATCH ?
|
|
3232
|
+
AND e.deleted_at IS NULL
|
|
3233
|
+
`;
|
|
3234
|
+
const params = [escaped];
|
|
3235
|
+
// Category filter
|
|
3236
|
+
if (category !== undefined) {
|
|
3237
|
+
const categories = Array.isArray(category) ? category : [category];
|
|
3238
|
+
sql += ` AND f.category IN (${categories.map(() => '?').join(',')})`;
|
|
3239
|
+
params.push(...categories);
|
|
3240
|
+
}
|
|
3241
|
+
// Status filter (default: active only)
|
|
3242
|
+
if (status !== undefined) {
|
|
3243
|
+
const statuses = Array.isArray(status) ? status : [status];
|
|
3244
|
+
sql += ` AND JSON_EXTRACT(e.data, '$.status') IN (${statuses.map(() => '?').join(',')})`;
|
|
3245
|
+
params.push(...statuses);
|
|
3246
|
+
}
|
|
3247
|
+
else {
|
|
3248
|
+
sql += ` AND JSON_EXTRACT(e.data, '$.status') = ?`;
|
|
3249
|
+
params.push('active');
|
|
3250
|
+
}
|
|
3251
|
+
sql += ` ORDER BY score LIMIT ?`;
|
|
3252
|
+
params.push(hardCap);
|
|
3253
|
+
const rows = this.backend.query(sql, params);
|
|
3254
|
+
if (rows.length === 0)
|
|
3255
|
+
return [];
|
|
3256
|
+
// Hydrate documents
|
|
3257
|
+
const results = [];
|
|
3258
|
+
for (const row of rows) {
|
|
3259
|
+
const doc = await this.get(row.document_id);
|
|
3260
|
+
if (doc) {
|
|
3261
|
+
results.push({
|
|
3262
|
+
document: doc,
|
|
3263
|
+
// Negate score so higher = more relevant for adaptive top-K
|
|
3264
|
+
score: -row.score,
|
|
3265
|
+
snippet: row.snippet,
|
|
3266
|
+
});
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
// Apply adaptive top-K elbow detection
|
|
3270
|
+
const scored = results.map((r) => ({ item: r, score: r.score }));
|
|
3271
|
+
const filtered = applyAdaptiveTopK(scored, {
|
|
3272
|
+
sensitivity: elbowSensitivity,
|
|
3273
|
+
minResults,
|
|
3274
|
+
maxResults: hardCap,
|
|
3275
|
+
});
|
|
3276
|
+
return filtered.map((f) => f.item);
|
|
3277
|
+
}
|
|
3278
|
+
catch (error) {
|
|
3279
|
+
// Re-throw typed errors (e.g., StorageError from FTS check)
|
|
3280
|
+
if (error instanceof StorageError || error instanceof NotFoundError) {
|
|
3281
|
+
throw error;
|
|
3282
|
+
}
|
|
3283
|
+
// Other errors (e.g., malformed query syntax) — log and return empty
|
|
3284
|
+
console.warn('[stoneforge] FTS search error:', error);
|
|
3285
|
+
return [];
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
// --------------------------------------------------------------------------
|
|
3289
|
+
// Sync Operations
|
|
3290
|
+
// --------------------------------------------------------------------------
|
|
3291
|
+
async export(options) {
|
|
3292
|
+
// Use SyncService for export functionality
|
|
3293
|
+
const { elements, dependencies } = this.syncService.exportToString({
|
|
3294
|
+
includeEphemeral: false, // API export excludes ephemeral by default
|
|
3295
|
+
includeDependencies: options?.includeDependencies ?? true,
|
|
3296
|
+
});
|
|
3297
|
+
// Build combined JSONL string
|
|
3298
|
+
let jsonl = elements;
|
|
3299
|
+
if (options?.includeDependencies !== false && dependencies) {
|
|
3300
|
+
jsonl = jsonl + (jsonl && dependencies ? '\n' : '') + dependencies;
|
|
3301
|
+
}
|
|
3302
|
+
if (options?.outputPath) {
|
|
3303
|
+
// Write to file using SyncService's file-based export
|
|
3304
|
+
const result = await this.syncService.export({
|
|
3305
|
+
outputDir: options.outputPath,
|
|
3306
|
+
full: true,
|
|
3307
|
+
includeEphemeral: false,
|
|
3308
|
+
});
|
|
3309
|
+
// Return void for file-based export
|
|
3310
|
+
return;
|
|
3311
|
+
}
|
|
3312
|
+
return jsonl;
|
|
3313
|
+
}
|
|
3314
|
+
async import(options) {
|
|
3315
|
+
// Use SyncService for import functionality
|
|
3316
|
+
let elementsContent = '';
|
|
3317
|
+
let dependenciesContent = '';
|
|
3318
|
+
// Handle input data - either from file path or raw data string
|
|
3319
|
+
if (options.data) {
|
|
3320
|
+
// Parse raw JSONL data - separate elements from dependencies
|
|
3321
|
+
// Elements have `id` and `type`, dependencies have `blockedId` and `blockerId`
|
|
3322
|
+
const lines = options.data.split('\n').filter((line) => line.trim());
|
|
3323
|
+
const elementLines = [];
|
|
3324
|
+
const dependencyLines = [];
|
|
3325
|
+
for (const line of lines) {
|
|
3326
|
+
try {
|
|
3327
|
+
const parsed = JSON.parse(line);
|
|
3328
|
+
if (parsed.blockedId && parsed.blockerId) {
|
|
3329
|
+
// This is a dependency
|
|
3330
|
+
dependencyLines.push(line);
|
|
3331
|
+
}
|
|
3332
|
+
else if (parsed.id) {
|
|
3333
|
+
// This is an element
|
|
3334
|
+
elementLines.push(line);
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
catch {
|
|
3338
|
+
// Invalid JSON - add to elements to let SyncService report the error
|
|
3339
|
+
elementLines.push(line);
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
elementsContent = elementLines.join('\n');
|
|
3343
|
+
dependenciesContent = dependencyLines.join('\n');
|
|
3344
|
+
}
|
|
3345
|
+
else if (options.inputPath) {
|
|
3346
|
+
// Use file-based import via SyncService
|
|
3347
|
+
const syncResult = await this.syncService.import({
|
|
3348
|
+
inputDir: options.inputPath,
|
|
3349
|
+
dryRun: options.dryRun ?? false,
|
|
3350
|
+
force: options.conflictStrategy === 'overwrite',
|
|
3351
|
+
});
|
|
3352
|
+
// Convert SyncService result to API ImportResult format
|
|
3353
|
+
const apiResult = this.convertSyncImportResult(syncResult, options.dryRun ?? false);
|
|
3354
|
+
if (!options.dryRun) {
|
|
3355
|
+
this.reindexDocumentsAfterImport();
|
|
3356
|
+
}
|
|
3357
|
+
return apiResult;
|
|
3358
|
+
}
|
|
3359
|
+
// For raw data import, use SyncService's string-based import
|
|
3360
|
+
const syncResult = this.syncService.importFromStrings(elementsContent, dependenciesContent, {
|
|
3361
|
+
dryRun: options.dryRun ?? false,
|
|
3362
|
+
force: options.conflictStrategy === 'overwrite',
|
|
3363
|
+
});
|
|
3364
|
+
const apiResult = this.convertSyncImportResult(syncResult, options.dryRun ?? false);
|
|
3365
|
+
if (!options.dryRun) {
|
|
3366
|
+
this.reindexDocumentsAfterImport();
|
|
3367
|
+
}
|
|
3368
|
+
return apiResult;
|
|
3369
|
+
}
|
|
3370
|
+
/**
|
|
3371
|
+
* Convert SyncService ImportResult to API ImportResult format
|
|
3372
|
+
*/
|
|
3373
|
+
convertSyncImportResult(syncResult, dryRun) {
|
|
3374
|
+
// Convert conflicts to API format
|
|
3375
|
+
const conflicts = syncResult.conflicts.map((c) => ({
|
|
3376
|
+
elementId: c.elementId,
|
|
3377
|
+
conflictType: 'exists',
|
|
3378
|
+
details: `Resolved via ${c.resolution}`,
|
|
3379
|
+
}));
|
|
3380
|
+
// Convert errors to string format
|
|
3381
|
+
const errors = syncResult.errors.map((e) => `${e.file}:${e.line}: ${e.message}${e.content ? ` (${e.content.substring(0, 50)}...)` : ''}`);
|
|
3382
|
+
return {
|
|
3383
|
+
success: syncResult.errors.length === 0,
|
|
3384
|
+
elementsImported: syncResult.elementsImported,
|
|
3385
|
+
dependenciesImported: syncResult.dependenciesImported,
|
|
3386
|
+
eventsImported: 0, // Events are not imported via sync
|
|
3387
|
+
conflicts,
|
|
3388
|
+
errors,
|
|
3389
|
+
dryRun,
|
|
3390
|
+
};
|
|
3391
|
+
}
|
|
3392
|
+
// --------------------------------------------------------------------------
|
|
3393
|
+
// Team Operations
|
|
3394
|
+
// --------------------------------------------------------------------------
|
|
3395
|
+
async addTeamMember(teamId, entityId, options) {
|
|
3396
|
+
// Get the team
|
|
3397
|
+
const team = await this.get(teamId);
|
|
3398
|
+
if (!team) {
|
|
3399
|
+
throw new NotFoundError(`Team not found: ${teamId}`, ErrorCode.NOT_FOUND, { elementId: teamId });
|
|
3400
|
+
}
|
|
3401
|
+
// Verify it's a team
|
|
3402
|
+
if (team.type !== 'team') {
|
|
3403
|
+
throw new ConstraintError(`Element is not a team: ${teamId}`, ErrorCode.TYPE_MISMATCH, { elementId: teamId, actualType: team.type, expectedType: 'team' });
|
|
3404
|
+
}
|
|
3405
|
+
// Check if team is deleted
|
|
3406
|
+
if (isTeamDeleted(team)) {
|
|
3407
|
+
throw new ConstraintError('Cannot add member to a deleted team', ErrorCode.IMMUTABLE, { teamId, status: team.status });
|
|
3408
|
+
}
|
|
3409
|
+
// Check if entity is already a member
|
|
3410
|
+
if (isTeamMember(team, entityId)) {
|
|
3411
|
+
// Already a member, return success without change
|
|
3412
|
+
return { success: true, team, entityId };
|
|
3413
|
+
}
|
|
3414
|
+
// Add member
|
|
3415
|
+
const newMembers = [...team.members, entityId];
|
|
3416
|
+
const actor = options?.actor ?? team.createdBy;
|
|
3417
|
+
const now = createTimestamp();
|
|
3418
|
+
// Update team and record event in transaction
|
|
3419
|
+
this.backend.transaction((tx) => {
|
|
3420
|
+
// Get current data
|
|
3421
|
+
const row = this.backend.queryOne('SELECT data FROM elements WHERE id = ?', [teamId]);
|
|
3422
|
+
if (!row)
|
|
3423
|
+
return;
|
|
3424
|
+
const data = JSON.parse(row.data);
|
|
3425
|
+
data.members = newMembers;
|
|
3426
|
+
// Recompute content hash
|
|
3427
|
+
const updatedTeam = { ...team, members: newMembers, updatedAt: now };
|
|
3428
|
+
const { hash: contentHash } = computeContentHashSync(updatedTeam);
|
|
3429
|
+
// Update element
|
|
3430
|
+
tx.run(`UPDATE elements SET data = ?, content_hash = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(data), contentHash, now, teamId]);
|
|
3431
|
+
// Record membership event
|
|
3432
|
+
const event = createEvent({
|
|
3433
|
+
elementId: teamId,
|
|
3434
|
+
eventType: MembershipEventType.MEMBER_ADDED,
|
|
3435
|
+
actor,
|
|
3436
|
+
oldValue: { members: team.members },
|
|
3437
|
+
newValue: { members: newMembers, addedMember: entityId },
|
|
3438
|
+
});
|
|
3439
|
+
tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
|
|
3440
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
3441
|
+
event.elementId,
|
|
3442
|
+
event.eventType,
|
|
3443
|
+
event.actor,
|
|
3444
|
+
JSON.stringify(event.oldValue),
|
|
3445
|
+
JSON.stringify(event.newValue),
|
|
3446
|
+
event.createdAt,
|
|
3447
|
+
]);
|
|
3448
|
+
});
|
|
3449
|
+
// Mark as dirty
|
|
3450
|
+
this.backend.markDirty(teamId);
|
|
3451
|
+
// Return updated team
|
|
3452
|
+
const updatedTeam = await this.get(teamId);
|
|
3453
|
+
return {
|
|
3454
|
+
success: true,
|
|
3455
|
+
team: updatedTeam,
|
|
3456
|
+
entityId,
|
|
3457
|
+
};
|
|
3458
|
+
}
|
|
3459
|
+
async removeTeamMember(teamId, entityId, options) {
|
|
3460
|
+
// Get the team
|
|
3461
|
+
const team = await this.get(teamId);
|
|
3462
|
+
if (!team) {
|
|
3463
|
+
throw new NotFoundError(`Team not found: ${teamId}`, ErrorCode.NOT_FOUND, { elementId: teamId });
|
|
3464
|
+
}
|
|
3465
|
+
// Verify it's a team
|
|
3466
|
+
if (team.type !== 'team') {
|
|
3467
|
+
throw new ConstraintError(`Element is not a team: ${teamId}`, ErrorCode.TYPE_MISMATCH, { elementId: teamId, actualType: team.type, expectedType: 'team' });
|
|
3468
|
+
}
|
|
3469
|
+
// Check if team is deleted
|
|
3470
|
+
if (isTeamDeleted(team)) {
|
|
3471
|
+
throw new ConstraintError('Cannot remove member from a deleted team', ErrorCode.IMMUTABLE, { teamId, status: team.status });
|
|
3472
|
+
}
|
|
3473
|
+
// Check if entity is a member
|
|
3474
|
+
if (!isTeamMember(team, entityId)) {
|
|
3475
|
+
throw new ConstraintError(`Entity is not a member of this team`, ErrorCode.MEMBER_REQUIRED, { teamId, entityId });
|
|
3476
|
+
}
|
|
3477
|
+
// Remove member
|
|
3478
|
+
const newMembers = team.members.filter((m) => m !== entityId);
|
|
3479
|
+
const actor = options?.actor ?? team.createdBy;
|
|
3480
|
+
const now = createTimestamp();
|
|
3481
|
+
// Update team and record event in transaction
|
|
3482
|
+
this.backend.transaction((tx) => {
|
|
3483
|
+
// Get current data
|
|
3484
|
+
const row = this.backend.queryOne('SELECT data FROM elements WHERE id = ?', [teamId]);
|
|
3485
|
+
if (!row)
|
|
3486
|
+
return;
|
|
3487
|
+
const data = JSON.parse(row.data);
|
|
3488
|
+
data.members = newMembers;
|
|
3489
|
+
// Recompute content hash
|
|
3490
|
+
const updatedTeam = { ...team, members: newMembers, updatedAt: now };
|
|
3491
|
+
const { hash: contentHash } = computeContentHashSync(updatedTeam);
|
|
3492
|
+
// Update element
|
|
3493
|
+
tx.run(`UPDATE elements SET data = ?, content_hash = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(data), contentHash, now, teamId]);
|
|
3494
|
+
// Record membership event
|
|
3495
|
+
const event = createEvent({
|
|
3496
|
+
elementId: teamId,
|
|
3497
|
+
eventType: MembershipEventType.MEMBER_REMOVED,
|
|
3498
|
+
actor,
|
|
3499
|
+
oldValue: { members: team.members },
|
|
3500
|
+
newValue: {
|
|
3501
|
+
members: newMembers,
|
|
3502
|
+
removedMember: entityId,
|
|
3503
|
+
...(options?.reason && { reason: options.reason }),
|
|
3504
|
+
},
|
|
3505
|
+
});
|
|
3506
|
+
tx.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at)
|
|
3507
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
3508
|
+
event.elementId,
|
|
3509
|
+
event.eventType,
|
|
3510
|
+
event.actor,
|
|
3511
|
+
JSON.stringify(event.oldValue),
|
|
3512
|
+
JSON.stringify(event.newValue),
|
|
3513
|
+
event.createdAt,
|
|
3514
|
+
]);
|
|
3515
|
+
});
|
|
3516
|
+
// Mark as dirty
|
|
3517
|
+
this.backend.markDirty(teamId);
|
|
3518
|
+
// Return updated team
|
|
3519
|
+
const updatedTeam = await this.get(teamId);
|
|
3520
|
+
return {
|
|
3521
|
+
success: true,
|
|
3522
|
+
team: updatedTeam,
|
|
3523
|
+
entityId,
|
|
3524
|
+
};
|
|
3525
|
+
}
|
|
3526
|
+
async getTasksForTeam(teamId, options) {
|
|
3527
|
+
// Get the team
|
|
3528
|
+
const team = await this.get(teamId);
|
|
3529
|
+
if (!team) {
|
|
3530
|
+
throw new NotFoundError(`Team not found: ${teamId}`, ErrorCode.NOT_FOUND, { elementId: teamId });
|
|
3531
|
+
}
|
|
3532
|
+
// Verify it's a team
|
|
3533
|
+
if (team.type !== 'team') {
|
|
3534
|
+
throw new ConstraintError(`Element is not a team: ${teamId}`, ErrorCode.TYPE_MISMATCH, { elementId: teamId, actualType: team.type, expectedType: 'team' });
|
|
3535
|
+
}
|
|
3536
|
+
// Build assignee list: team ID + all member IDs
|
|
3537
|
+
const assignees = [teamId, ...team.members];
|
|
3538
|
+
// Get all tasks and filter by assignee
|
|
3539
|
+
const tasks = await this.list({ type: 'task', ...options });
|
|
3540
|
+
// Filter to tasks assigned to team or any member
|
|
3541
|
+
return tasks.filter((task) => task.assignee && assignees.includes(task.assignee));
|
|
3542
|
+
}
|
|
3543
|
+
async claimTaskFromTeam(taskId, entityId, options) {
|
|
3544
|
+
// Get the task
|
|
3545
|
+
const task = await this.get(taskId);
|
|
3546
|
+
if (!task) {
|
|
3547
|
+
throw new NotFoundError(`Task not found: ${taskId}`, ErrorCode.NOT_FOUND, { elementId: taskId });
|
|
3548
|
+
}
|
|
3549
|
+
// Verify it's a task
|
|
3550
|
+
if (task.type !== 'task') {
|
|
3551
|
+
throw new ConstraintError(`Element is not a task: ${taskId}`, ErrorCode.TYPE_MISMATCH, { elementId: taskId, actualType: task.type, expectedType: 'task' });
|
|
3552
|
+
}
|
|
3553
|
+
// Check if task is assigned to a team
|
|
3554
|
+
if (!task.assignee) {
|
|
3555
|
+
throw new ValidationError('Task has no assignee to claim from', ErrorCode.MISSING_REQUIRED_FIELD, { taskId, field: 'assignee' });
|
|
3556
|
+
}
|
|
3557
|
+
// Get the team to verify the task is team-assigned
|
|
3558
|
+
const team = await this.get(task.assignee);
|
|
3559
|
+
if (!team || team.type !== 'team') {
|
|
3560
|
+
throw new ConstraintError('Task is not assigned to a team', ErrorCode.TYPE_MISMATCH, { taskId, currentAssignee: task.assignee, expectedType: 'team' });
|
|
3561
|
+
}
|
|
3562
|
+
// Check if entity is a member of the team
|
|
3563
|
+
if (!isTeamMember(team, entityId)) {
|
|
3564
|
+
throw new ConstraintError('Entity is not a member of the assigned team', ErrorCode.MEMBER_REQUIRED, { taskId, teamId: team.id, entityId });
|
|
3565
|
+
}
|
|
3566
|
+
// Update task assignee to the claiming entity
|
|
3567
|
+
const actor = options?.actor ?? entityId;
|
|
3568
|
+
const updated = await this.update(taskId, {
|
|
3569
|
+
assignee: entityId,
|
|
3570
|
+
// Optionally preserve the team reference in metadata
|
|
3571
|
+
metadata: {
|
|
3572
|
+
...task.metadata,
|
|
3573
|
+
claimedFromTeam: team.id,
|
|
3574
|
+
claimedAt: createTimestamp(),
|
|
3575
|
+
},
|
|
3576
|
+
}, { actor });
|
|
3577
|
+
return updated;
|
|
3578
|
+
}
|
|
3579
|
+
async getTeamMetrics(teamId) {
|
|
3580
|
+
// Get the team
|
|
3581
|
+
const team = await this.get(teamId);
|
|
3582
|
+
if (!team) {
|
|
3583
|
+
throw new NotFoundError(`Team not found: ${teamId}`, ErrorCode.NOT_FOUND, { elementId: teamId });
|
|
3584
|
+
}
|
|
3585
|
+
// Verify it's a team
|
|
3586
|
+
if (team.type !== 'team') {
|
|
3587
|
+
throw new ConstraintError(`Element is not a team: ${teamId}`, ErrorCode.TYPE_MISMATCH, { elementId: teamId, actualType: team.type, expectedType: 'team' });
|
|
3588
|
+
}
|
|
3589
|
+
// Get tasks for team
|
|
3590
|
+
const tasks = await this.getTasksForTeam(teamId);
|
|
3591
|
+
// Calculate metrics
|
|
3592
|
+
let tasksCompleted = 0;
|
|
3593
|
+
let tasksInProgress = 0;
|
|
3594
|
+
let tasksAssignedToTeam = 0;
|
|
3595
|
+
let totalCycleTimeMs = 0;
|
|
3596
|
+
let completedWithCycleTime = 0;
|
|
3597
|
+
for (const task of tasks) {
|
|
3598
|
+
if (task.assignee === teamId) {
|
|
3599
|
+
tasksAssignedToTeam++;
|
|
3600
|
+
}
|
|
3601
|
+
if (task.status === TaskStatusEnum.CLOSED) {
|
|
3602
|
+
tasksCompleted++;
|
|
3603
|
+
// Calculate cycle time if closedAt exists
|
|
3604
|
+
if (task.closedAt) {
|
|
3605
|
+
const createdAt = new Date(task.createdAt).getTime();
|
|
3606
|
+
const closedAt = new Date(task.closedAt).getTime();
|
|
3607
|
+
totalCycleTimeMs += closedAt - createdAt;
|
|
3608
|
+
completedWithCycleTime++;
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
else if (task.status === TaskStatusEnum.IN_PROGRESS) {
|
|
3612
|
+
tasksInProgress++;
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
const averageCycleTimeMs = completedWithCycleTime > 0 ? Math.round(totalCycleTimeMs / completedWithCycleTime) : null;
|
|
3616
|
+
return {
|
|
3617
|
+
teamId,
|
|
3618
|
+
tasksCompleted,
|
|
3619
|
+
tasksInProgress,
|
|
3620
|
+
totalTasks: tasks.length,
|
|
3621
|
+
tasksAssignedToTeam,
|
|
3622
|
+
averageCycleTimeMs,
|
|
3623
|
+
};
|
|
3624
|
+
}
|
|
3625
|
+
// --------------------------------------------------------------------------
|
|
3626
|
+
// Statistics
|
|
3627
|
+
// --------------------------------------------------------------------------
|
|
3628
|
+
async stats() {
|
|
3629
|
+
const now = createTimestamp();
|
|
3630
|
+
// Count elements by type
|
|
3631
|
+
const typeCounts = this.backend.query("SELECT type, COUNT(*) as count FROM elements WHERE deleted_at IS NULL GROUP BY type");
|
|
3632
|
+
const elementsByType = {};
|
|
3633
|
+
let totalElements = 0;
|
|
3634
|
+
for (const row of typeCounts) {
|
|
3635
|
+
elementsByType[row.type] = row.count;
|
|
3636
|
+
totalElements += row.count;
|
|
3637
|
+
}
|
|
3638
|
+
// Count dependencies
|
|
3639
|
+
const depCount = this.backend.queryOne('SELECT COUNT(*) as count FROM dependencies');
|
|
3640
|
+
const totalDependencies = depCount?.count ?? 0;
|
|
3641
|
+
// Count events
|
|
3642
|
+
const eventCount = this.backend.queryOne('SELECT COUNT(*) as count FROM events');
|
|
3643
|
+
const totalEvents = eventCount?.count ?? 0;
|
|
3644
|
+
// Count ready tasks
|
|
3645
|
+
const readyTasks = await this.ready();
|
|
3646
|
+
// Count blocked tasks
|
|
3647
|
+
const blockedCount = this.backend.queryOne('SELECT COUNT(*) as count FROM blocked_cache');
|
|
3648
|
+
const blockedTasks = blockedCount?.count ?? 0;
|
|
3649
|
+
// Get database size
|
|
3650
|
+
const stats = this.backend.getStats();
|
|
3651
|
+
return {
|
|
3652
|
+
totalElements,
|
|
3653
|
+
elementsByType,
|
|
3654
|
+
totalDependencies,
|
|
3655
|
+
totalEvents,
|
|
3656
|
+
readyTasks: readyTasks.length,
|
|
3657
|
+
blockedTasks,
|
|
3658
|
+
databaseSize: stats.fileSize,
|
|
3659
|
+
computedAt: now,
|
|
3660
|
+
};
|
|
3661
|
+
}
|
|
3662
|
+
// --------------------------------------------------------------------------
|
|
3663
|
+
// Batch Fetch Helpers
|
|
3664
|
+
// --------------------------------------------------------------------------
|
|
3665
|
+
/**
|
|
3666
|
+
* Batch fetch tags for multiple elements by their IDs.
|
|
3667
|
+
* Returns a map of element ID to array of tags for efficient lookup.
|
|
3668
|
+
* This eliminates N+1 query issues when fetching tags for multiple elements.
|
|
3669
|
+
*/
|
|
3670
|
+
batchFetchTags(elementIds) {
|
|
3671
|
+
if (elementIds.length === 0) {
|
|
3672
|
+
return new Map();
|
|
3673
|
+
}
|
|
3674
|
+
// Deduplicate IDs
|
|
3675
|
+
const uniqueIds = [...new Set(elementIds)];
|
|
3676
|
+
// Build query with placeholders
|
|
3677
|
+
const placeholders = uniqueIds.map(() => '?').join(', ');
|
|
3678
|
+
const sql = `SELECT element_id, tag FROM tags WHERE element_id IN (${placeholders})`;
|
|
3679
|
+
const rows = this.backend.query(sql, uniqueIds);
|
|
3680
|
+
// Group tags by element ID
|
|
3681
|
+
const tagsMap = new Map();
|
|
3682
|
+
for (const id of uniqueIds) {
|
|
3683
|
+
tagsMap.set(id, []);
|
|
3684
|
+
}
|
|
3685
|
+
for (const row of rows) {
|
|
3686
|
+
const tags = tagsMap.get(row.element_id);
|
|
3687
|
+
if (tags) {
|
|
3688
|
+
tags.push(row.tag);
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
return tagsMap;
|
|
3692
|
+
}
|
|
3693
|
+
// --------------------------------------------------------------------------
|
|
3694
|
+
// Hydration Helpers
|
|
3695
|
+
// --------------------------------------------------------------------------
|
|
3696
|
+
async hydrateTask(task, options) {
|
|
3697
|
+
const hydrated = { ...task };
|
|
3698
|
+
if (options.description && task.descriptionRef) {
|
|
3699
|
+
const doc = await this.get(task.descriptionRef);
|
|
3700
|
+
if (doc) {
|
|
3701
|
+
hydrated.description = doc.content;
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3704
|
+
return hydrated;
|
|
3705
|
+
}
|
|
3706
|
+
/**
|
|
3707
|
+
* Batch fetch documents by their IDs.
|
|
3708
|
+
* Returns a map of document ID to document for efficient lookup.
|
|
3709
|
+
*/
|
|
3710
|
+
batchFetchDocuments(documentIds) {
|
|
3711
|
+
if (documentIds.length === 0) {
|
|
3712
|
+
return new Map();
|
|
3713
|
+
}
|
|
3714
|
+
// Deduplicate IDs
|
|
3715
|
+
const uniqueIds = [...new Set(documentIds)];
|
|
3716
|
+
// Build query with placeholders
|
|
3717
|
+
const placeholders = uniqueIds.map(() => '?').join(', ');
|
|
3718
|
+
const sql = `SELECT * FROM elements WHERE id IN (${placeholders}) AND type = 'document'`;
|
|
3719
|
+
const rows = this.backend.query(sql, uniqueIds);
|
|
3720
|
+
// Batch fetch tags for all documents (eliminates N+1 query issue)
|
|
3721
|
+
const elementIds = rows.map((row) => row.id);
|
|
3722
|
+
const tagsMap = this.batchFetchTags(elementIds);
|
|
3723
|
+
// Convert to map
|
|
3724
|
+
const documentMap = new Map();
|
|
3725
|
+
for (const row of rows) {
|
|
3726
|
+
const tags = tagsMap.get(row.id) ?? [];
|
|
3727
|
+
const doc = deserializeElement(row, tags);
|
|
3728
|
+
if (doc)
|
|
3729
|
+
documentMap.set(doc.id, doc);
|
|
3730
|
+
}
|
|
3731
|
+
return documentMap;
|
|
3732
|
+
}
|
|
3733
|
+
/**
|
|
3734
|
+
* Batch hydrate tasks with their document references.
|
|
3735
|
+
* Collects all document IDs, fetches them in a single query, then populates.
|
|
3736
|
+
*/
|
|
3737
|
+
hydrateTasks(tasks, options) {
|
|
3738
|
+
if (tasks.length === 0) {
|
|
3739
|
+
return [];
|
|
3740
|
+
}
|
|
3741
|
+
// Collect all document IDs to fetch
|
|
3742
|
+
const documentIds = [];
|
|
3743
|
+
for (const task of tasks) {
|
|
3744
|
+
if (options.description && task.descriptionRef) {
|
|
3745
|
+
documentIds.push(task.descriptionRef);
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
// Batch fetch all documents
|
|
3749
|
+
const documentMap = this.batchFetchDocuments(documentIds);
|
|
3750
|
+
// Hydrate each task
|
|
3751
|
+
const hydrated = tasks.map((task) => {
|
|
3752
|
+
const result = { ...task };
|
|
3753
|
+
if (options.description && task.descriptionRef) {
|
|
3754
|
+
const doc = documentMap.get(task.descriptionRef);
|
|
3755
|
+
if (doc) {
|
|
3756
|
+
result.description = doc.content;
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
return result;
|
|
3760
|
+
});
|
|
3761
|
+
return hydrated;
|
|
3762
|
+
}
|
|
3763
|
+
/**
|
|
3764
|
+
* Hydrate a single message with its document references.
|
|
3765
|
+
* Resolves contentRef -> content and attachments -> attachmentContents.
|
|
3766
|
+
*/
|
|
3767
|
+
async hydrateMessage(message, options) {
|
|
3768
|
+
const hydrated = { ...message };
|
|
3769
|
+
if (options.content && message.contentRef) {
|
|
3770
|
+
const doc = await this.get(message.contentRef);
|
|
3771
|
+
if (doc) {
|
|
3772
|
+
hydrated.content = doc.content;
|
|
3773
|
+
}
|
|
3774
|
+
}
|
|
3775
|
+
if (options.attachments && message.attachments && message.attachments.length > 0) {
|
|
3776
|
+
const attachmentContents = [];
|
|
3777
|
+
for (const attachmentId of message.attachments) {
|
|
3778
|
+
const doc = await this.get(attachmentId);
|
|
3779
|
+
if (doc) {
|
|
3780
|
+
attachmentContents.push(doc.content);
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
hydrated.attachmentContents = attachmentContents;
|
|
3784
|
+
}
|
|
3785
|
+
return hydrated;
|
|
3786
|
+
}
|
|
3787
|
+
/**
|
|
3788
|
+
* Batch hydrate messages with their document references.
|
|
3789
|
+
* Collects all document IDs, fetches them in a single query, then populates.
|
|
3790
|
+
*/
|
|
3791
|
+
hydrateMessages(messages, options) {
|
|
3792
|
+
if (messages.length === 0) {
|
|
3793
|
+
return [];
|
|
3794
|
+
}
|
|
3795
|
+
// Collect all document IDs to fetch
|
|
3796
|
+
const documentIds = [];
|
|
3797
|
+
for (const message of messages) {
|
|
3798
|
+
if (options.content && message.contentRef) {
|
|
3799
|
+
documentIds.push(message.contentRef);
|
|
3800
|
+
}
|
|
3801
|
+
if (options.attachments && message.attachments) {
|
|
3802
|
+
for (const attachmentId of message.attachments) {
|
|
3803
|
+
documentIds.push(attachmentId);
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
// Batch fetch all documents
|
|
3808
|
+
const documentMap = this.batchFetchDocuments(documentIds);
|
|
3809
|
+
// Hydrate each message
|
|
3810
|
+
const hydrated = messages.map((message) => {
|
|
3811
|
+
const result = { ...message };
|
|
3812
|
+
if (options.content && message.contentRef) {
|
|
3813
|
+
const doc = documentMap.get(message.contentRef);
|
|
3814
|
+
if (doc) {
|
|
3815
|
+
result.content = doc.content;
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3818
|
+
if (options.attachments && message.attachments && message.attachments.length > 0) {
|
|
3819
|
+
const attachmentContents = [];
|
|
3820
|
+
for (const attachmentId of message.attachments) {
|
|
3821
|
+
const doc = documentMap.get(attachmentId);
|
|
3822
|
+
if (doc) {
|
|
3823
|
+
attachmentContents.push(doc.content);
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
result.attachmentContents = attachmentContents;
|
|
3827
|
+
}
|
|
3828
|
+
return result;
|
|
3829
|
+
});
|
|
3830
|
+
return hydrated;
|
|
3831
|
+
}
|
|
3832
|
+
/**
|
|
3833
|
+
* Hydrate a single library with its document references.
|
|
3834
|
+
* Resolves descriptionRef -> description.
|
|
3835
|
+
*/
|
|
3836
|
+
async hydrateLibrary(library, options) {
|
|
3837
|
+
const hydrated = { ...library };
|
|
3838
|
+
if (options.description && library.descriptionRef) {
|
|
3839
|
+
const doc = await this.get(library.descriptionRef);
|
|
3840
|
+
if (doc) {
|
|
3841
|
+
hydrated.description = doc.content;
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
return hydrated;
|
|
3845
|
+
}
|
|
3846
|
+
/**
|
|
3847
|
+
* Batch hydrate libraries with their document references.
|
|
3848
|
+
* Collects all document IDs, fetches them in a single query, then populates.
|
|
3849
|
+
*/
|
|
3850
|
+
hydrateLibraries(libraries, options) {
|
|
3851
|
+
if (libraries.length === 0) {
|
|
3852
|
+
return [];
|
|
3853
|
+
}
|
|
3854
|
+
// Collect all document IDs to fetch
|
|
3855
|
+
const documentIds = [];
|
|
3856
|
+
for (const library of libraries) {
|
|
3857
|
+
if (options.description && library.descriptionRef) {
|
|
3858
|
+
documentIds.push(library.descriptionRef);
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
// Batch fetch all documents
|
|
3862
|
+
const documentMap = this.batchFetchDocuments(documentIds);
|
|
3863
|
+
// Hydrate each library
|
|
3864
|
+
const hydrated = libraries.map((library) => {
|
|
3865
|
+
const result = { ...library };
|
|
3866
|
+
if (options.description && library.descriptionRef) {
|
|
3867
|
+
const doc = documentMap.get(library.descriptionRef);
|
|
3868
|
+
if (doc) {
|
|
3869
|
+
result.description = doc.content;
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
return result;
|
|
3873
|
+
});
|
|
3874
|
+
return hydrated;
|
|
3875
|
+
}
|
|
3876
|
+
// --------------------------------------------------------------------------
|
|
3877
|
+
// Cache Management (Internal)
|
|
3878
|
+
// --------------------------------------------------------------------------
|
|
3879
|
+
/**
|
|
3880
|
+
* Rebuild the blocked cache from scratch.
|
|
3881
|
+
*
|
|
3882
|
+
* Use this for:
|
|
3883
|
+
* - Initial population after migration
|
|
3884
|
+
* - Recovery from cache corruption
|
|
3885
|
+
* - Periodic consistency checks
|
|
3886
|
+
*
|
|
3887
|
+
* @returns Statistics about the rebuild
|
|
3888
|
+
*/
|
|
3889
|
+
rebuildBlockedCache() {
|
|
3890
|
+
return this.blockedCache.rebuild();
|
|
3891
|
+
}
|
|
3892
|
+
}
|
|
3893
|
+
// ============================================================================
|
|
3894
|
+
// Factory Function
|
|
3895
|
+
// ============================================================================
|
|
3896
|
+
/**
|
|
3897
|
+
* Create a new QuarryAPI instance
|
|
3898
|
+
*
|
|
3899
|
+
* @param backend - The storage backend to use
|
|
3900
|
+
* @returns A new QuarryAPI instance
|
|
3901
|
+
*/
|
|
3902
|
+
export function createQuarryAPI(backend) {
|
|
3903
|
+
return new QuarryAPIImpl(backend);
|
|
3904
|
+
}
|
|
3905
|
+
//# sourceMappingURL=quarry-api.js.map
|