@danypops/papyrus 0.47.2 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/artifact/artifact-scope-store.ts +37 -7
- package/src/artifact/in-memory-artifact-scope-store.ts +123 -0
- package/src/artifact/sqlite-artifact-scope-store.ts +148 -27
- package/src/cli/rules-command.ts +92 -0
- package/src/cli.ts +21 -2
- package/src/constants.ts +3 -1
- package/src/db.ts +55 -0
- package/src/domain/project-registry.ts +58 -0
- package/src/domain/task-scope.ts +4 -14
- package/src/handlers/registry.ts +3 -1
- package/src/handlers/rules.ts +91 -4
- package/src/modules/rules.ts +29 -1
- package/src/ops.ts +1 -0
- package/src/ports/project-registry-store.ts +13 -0
- package/src/rules/rules-service.ts +105 -16
- package/src/service.ts +18 -4
- package/src/stores/in-memory-project-registry-store.ts +100 -0
- package/src/stores/sqlite-project-registry-store.ts +132 -0
- package/src/stores/sqlite-task-scope-store.ts +12 -120
- package/src/stores/task-scope-store.ts +30 -71
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { TASK_PROJECT_ALIAS_MAX_COUNT } from "../constants.ts";
|
|
4
|
+
import type { Project, RegisterProjectInput } from "../domain/project-registry.ts";
|
|
5
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
6
|
+
|
|
7
|
+
export function uniqueAliases(values: readonly string[], name: string): string[] {
|
|
8
|
+
const seen = new Set([name.trim().toLowerCase()]);
|
|
9
|
+
const aliases = values.flatMap((value) => {
|
|
10
|
+
const trimmed = value.trim();
|
|
11
|
+
const key = trimmed.toLowerCase();
|
|
12
|
+
if (!trimmed || seen.has(key)) return [];
|
|
13
|
+
seen.add(key);
|
|
14
|
+
return [trimmed];
|
|
15
|
+
});
|
|
16
|
+
if (aliases.length > TASK_PROJECT_ALIAS_MAX_COUNT) {
|
|
17
|
+
throw new Error(`project aliases cannot exceed ${TASK_PROJECT_ALIAS_MAX_COUNT} entries`);
|
|
18
|
+
}
|
|
19
|
+
return aliases;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extracted from what was TaskScopeStore's own project-registry bookkeeping (project list/exact
|
|
24
|
+
* resolve/register/rename/move) -- moving a registered project's root must rewrite every
|
|
25
|
+
* consumer's own rows keyed by that root (Task's own TaskProjectScope/TaskViewPreference,
|
|
26
|
+
* and independently an ArtifactScopeStore's own membership rows), and more than one consumer
|
|
27
|
+
* can share a single registry instance specifically so they resolve against identical project
|
|
28
|
+
* identities. subscribeRootMoved supports that: every subscriber is notified, not just the
|
|
29
|
+
* first, so sharing one instance across TaskScopeStore and ArtifactScopeStore never silently
|
|
30
|
+
* drops the other's rewrite.
|
|
31
|
+
*/
|
|
32
|
+
export class InMemoryProjectRegistryStore implements ProjectRegistryStore {
|
|
33
|
+
private readonly projectRows = new Map<string, Project>();
|
|
34
|
+
private readonly rootMovedListeners: Array<(previousRoot: string, nextRoot: string) => void> = [];
|
|
35
|
+
|
|
36
|
+
subscribeRootMoved(listener: (previousRoot: string, nextRoot: string) => void): void {
|
|
37
|
+
this.rootMovedListeners.push(listener);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
projects(query: string | undefined, limit: number): Project[] {
|
|
41
|
+
const needle = query?.trim().toLowerCase();
|
|
42
|
+
return [...this.projectRows.values()]
|
|
43
|
+
.filter(
|
|
44
|
+
(project) =>
|
|
45
|
+
!needle ||
|
|
46
|
+
project.name.toLowerCase().includes(needle) ||
|
|
47
|
+
project.projectRoot.toLowerCase().includes(needle) ||
|
|
48
|
+
project.aliases.some((alias) => alias.toLowerCase().includes(needle)),
|
|
49
|
+
)
|
|
50
|
+
.sort((left, right) => left.name.localeCompare(right.name) || left.projectRoot.localeCompare(right.projectRoot))
|
|
51
|
+
.slice(0, limit);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
matchingProjects(reference: string): Project[] {
|
|
55
|
+
const needle = reference.trim().toLowerCase();
|
|
56
|
+
return [...this.projectRows.values()]
|
|
57
|
+
.filter(
|
|
58
|
+
(project) =>
|
|
59
|
+
project.id.toLowerCase() === needle ||
|
|
60
|
+
project.name.toLowerCase() === needle ||
|
|
61
|
+
project.projectRoot.toLowerCase() === needle ||
|
|
62
|
+
project.aliases.some((alias) => alias.toLowerCase() === needle),
|
|
63
|
+
)
|
|
64
|
+
.slice(0, 11);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
registerProject(input: RegisterProjectInput): Project {
|
|
68
|
+
const now = new Date().toISOString();
|
|
69
|
+
const byRoot = [...this.projectRows.values()].find((project) => project.projectRoot === input.projectRoot);
|
|
70
|
+
const existing = input.existingId ? this.projectRows.get(input.existingId) : byRoot;
|
|
71
|
+
const name = input.name?.trim() || existing?.name || basename(input.projectRoot) || input.projectRoot;
|
|
72
|
+
const aliases = uniqueAliases(
|
|
73
|
+
[...(existing?.aliases ?? []), ...(existing && existing.name !== name ? [existing.name] : []), ...(input.aliases ?? [])],
|
|
74
|
+
name,
|
|
75
|
+
);
|
|
76
|
+
const project: Project = {
|
|
77
|
+
id: existing?.id ?? randomUUID(),
|
|
78
|
+
name,
|
|
79
|
+
aliases,
|
|
80
|
+
projectRoot: input.projectRoot,
|
|
81
|
+
createdAt: existing?.createdAt ?? now,
|
|
82
|
+
updatedAt: now,
|
|
83
|
+
};
|
|
84
|
+
if (existing && existing.projectRoot !== project.projectRoot) {
|
|
85
|
+
for (const listener of this.rootMovedListeners) listener(existing.projectRoot, project.projectRoot);
|
|
86
|
+
}
|
|
87
|
+
this.projectRows.set(project.id, project);
|
|
88
|
+
return project;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Exposed for a caller (e.g. InMemoryTaskScopeStore) that needs to look up a project by its exact current root without going through the bounded matchingProjects search. */
|
|
92
|
+
byRoot(projectRoot: string): Project | undefined {
|
|
93
|
+
return [...this.projectRows.values()].find((project) => project.projectRoot === projectRoot);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Exposed for a caller (e.g. InMemoryArtifactScopeStore) that needs a project's own current fields (its root, for the legacy single-root compatibility view) from a stored membership id, without going through the needle-matching matchingProjects search. */
|
|
97
|
+
byId(id: string): Project | undefined {
|
|
98
|
+
return this.projectRows.get(id);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import type { Db } from "../db.ts";
|
|
4
|
+
import { inTransaction } from "../db.ts";
|
|
5
|
+
import type { Project, RegisterProjectInput } from "../domain/project-registry.ts";
|
|
6
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
7
|
+
import { uniqueAliases } from "./in-memory-project-registry-store.ts";
|
|
8
|
+
|
|
9
|
+
interface ProjectRow {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
aliases_json: string;
|
|
13
|
+
project_root: string;
|
|
14
|
+
created_at: string;
|
|
15
|
+
updated_at: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function projectFromRow(row: ProjectRow): Project {
|
|
19
|
+
return {
|
|
20
|
+
id: row.id,
|
|
21
|
+
name: row.name,
|
|
22
|
+
aliases: JSON.parse(row.aliases_json) as string[],
|
|
23
|
+
projectRoot: row.project_root,
|
|
24
|
+
createdAt: row.created_at,
|
|
25
|
+
updatedAt: row.updated_at,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Backed by `task_projects` -- the same table name Task's own migration (v26) established --
|
|
31
|
+
* kept as-is rather than renamed, so an existing database's registered project ids/names/roots
|
|
32
|
+
* survive this extraction with zero migration. Kind-neutral in behavior: nothing here reads or
|
|
33
|
+
* writes a Task-shaped row: onRootMoved lets a composing store (TaskScopeStore,
|
|
34
|
+
* ArtifactScopeStore) react to a root move for its own kind-specific rows in the same
|
|
35
|
+
* transaction, without this store needing to know either of them exist.
|
|
36
|
+
*/
|
|
37
|
+
export class SQLiteProjectRegistryStore implements ProjectRegistryStore {
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly db: Db,
|
|
40
|
+
private readonly onRootMoved?: (db: Db, previousRoot: string, nextRoot: string) => void,
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
projects(query: string | undefined, limit: number): Project[] {
|
|
44
|
+
const needle = query?.trim().toLowerCase();
|
|
45
|
+
if (!needle) {
|
|
46
|
+
return (
|
|
47
|
+
this.db
|
|
48
|
+
.prepare(
|
|
49
|
+
"SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects ORDER BY name, project_root LIMIT ?",
|
|
50
|
+
)
|
|
51
|
+
.all(limit) as ProjectRow[]
|
|
52
|
+
).map(projectFromRow);
|
|
53
|
+
}
|
|
54
|
+
return (
|
|
55
|
+
this.db
|
|
56
|
+
.prepare(`
|
|
57
|
+
SELECT id, name, aliases_json, project_root, created_at, updated_at
|
|
58
|
+
FROM task_projects
|
|
59
|
+
WHERE instr(lower(name), ?) > 0
|
|
60
|
+
OR instr(lower(project_root), ?) > 0
|
|
61
|
+
OR EXISTS (
|
|
62
|
+
SELECT 1 FROM json_each(task_projects.aliases_json)
|
|
63
|
+
WHERE instr(lower(CAST(json_each.value AS TEXT)), ?) > 0
|
|
64
|
+
)
|
|
65
|
+
ORDER BY name, project_root
|
|
66
|
+
LIMIT ?
|
|
67
|
+
`)
|
|
68
|
+
.all(needle, needle, needle, limit) as ProjectRow[]
|
|
69
|
+
).map(projectFromRow);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
matchingProjects(reference: string): Project[] {
|
|
73
|
+
const needle = reference.trim().toLowerCase();
|
|
74
|
+
return (
|
|
75
|
+
this.db
|
|
76
|
+
.prepare(`
|
|
77
|
+
SELECT id, name, aliases_json, project_root, created_at, updated_at
|
|
78
|
+
FROM task_projects
|
|
79
|
+
WHERE lower(id) = ? OR lower(name) = ? OR lower(project_root) = ?
|
|
80
|
+
OR EXISTS (
|
|
81
|
+
SELECT 1 FROM json_each(task_projects.aliases_json)
|
|
82
|
+
WHERE lower(CAST(json_each.value AS TEXT)) = ?
|
|
83
|
+
)
|
|
84
|
+
ORDER BY name, project_root
|
|
85
|
+
LIMIT 11
|
|
86
|
+
`)
|
|
87
|
+
.all(needle, needle, needle, needle) as ProjectRow[]
|
|
88
|
+
).map(projectFromRow);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
registerProject(input: RegisterProjectInput): Project {
|
|
92
|
+
return inTransaction(this.db, () => {
|
|
93
|
+
const row = (
|
|
94
|
+
input.existingId
|
|
95
|
+
? this.db
|
|
96
|
+
.prepare("SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects WHERE id = ?")
|
|
97
|
+
.get(input.existingId)
|
|
98
|
+
: this.db
|
|
99
|
+
.prepare("SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects WHERE project_root = ?")
|
|
100
|
+
.get(input.projectRoot)
|
|
101
|
+
) as ProjectRow | null;
|
|
102
|
+
const existing = row ? projectFromRow(row) : undefined;
|
|
103
|
+
const now = new Date().toISOString();
|
|
104
|
+
const name = input.name?.trim() || existing?.name || basename(input.projectRoot) || input.projectRoot;
|
|
105
|
+
const aliases = uniqueAliases(
|
|
106
|
+
[...(existing?.aliases ?? []), ...(existing && existing.name !== name ? [existing.name] : []), ...(input.aliases ?? [])],
|
|
107
|
+
name,
|
|
108
|
+
);
|
|
109
|
+
const project: Project = {
|
|
110
|
+
id: existing?.id ?? randomUUID(),
|
|
111
|
+
name,
|
|
112
|
+
aliases,
|
|
113
|
+
projectRoot: input.projectRoot,
|
|
114
|
+
createdAt: existing?.createdAt ?? now,
|
|
115
|
+
updatedAt: now,
|
|
116
|
+
};
|
|
117
|
+
if (existing && existing.projectRoot !== project.projectRoot) this.onRootMoved?.(this.db, existing.projectRoot, project.projectRoot);
|
|
118
|
+
this.db
|
|
119
|
+
.prepare(`
|
|
120
|
+
INSERT INTO task_projects (id, name, aliases_json, project_root, created_at, updated_at)
|
|
121
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
122
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
123
|
+
name = excluded.name,
|
|
124
|
+
aliases_json = excluded.aliases_json,
|
|
125
|
+
project_root = excluded.project_root,
|
|
126
|
+
updated_at = excluded.updated_at
|
|
127
|
+
`)
|
|
128
|
+
.run(project.id, project.name, JSON.stringify(project.aliases), project.projectRoot, project.createdAt, project.updatedAt);
|
|
129
|
+
return project;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { basename } from "node:path";
|
|
3
|
-
import { TASK_PROJECT_ALIAS_MAX_COUNT } from "../constants.ts";
|
|
4
1
|
import type { Db } from "../db.ts";
|
|
5
2
|
import { inTransaction } from "../db.ts";
|
|
6
3
|
import type {
|
|
@@ -11,45 +8,21 @@ import type {
|
|
|
11
8
|
TaskViewMode,
|
|
12
9
|
TaskViewPreference,
|
|
13
10
|
} from "../domain/task-scope.ts";
|
|
11
|
+
import { SQLiteProjectRegistryStore } from "./sqlite-project-registry-store.ts";
|
|
14
12
|
import type { TaskScopeStore } from "./task-scope-store.ts";
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
project_root: string;
|
|
21
|
-
created_at: string;
|
|
22
|
-
updated_at: string;
|
|
14
|
+
/** Rewrites every task_scopes/task_views row pinned to a project root that just moved -- called inside the same registerProject() transaction, so a rename/move is atomic with the rewrite. */
|
|
15
|
+
function rewriteTaskRowsForMovedRoot(db: Db, previousRoot: string, nextRoot: string): void {
|
|
16
|
+
db.prepare("UPDATE task_scopes SET project_root = ? WHERE project_root = ?").run(nextRoot, previousRoot);
|
|
17
|
+
db.prepare("UPDATE task_views SET project_root = ? WHERE project_root = ?").run(nextRoot, previousRoot);
|
|
23
18
|
}
|
|
24
19
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
id: row.id,
|
|
28
|
-
name: row.name,
|
|
29
|
-
aliases: JSON.parse(row.aliases_json) as string[],
|
|
30
|
-
projectRoot: row.project_root,
|
|
31
|
-
createdAt: row.created_at,
|
|
32
|
-
updatedAt: row.updated_at,
|
|
33
|
-
};
|
|
34
|
-
}
|
|
20
|
+
export class SQLiteTaskScopeStore implements TaskScopeStore {
|
|
21
|
+
private readonly registry: SQLiteProjectRegistryStore;
|
|
35
22
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const aliases = values.flatMap((value) => {
|
|
39
|
-
const trimmed = value.trim();
|
|
40
|
-
const key = trimmed.toLowerCase();
|
|
41
|
-
if (!trimmed || seen.has(key)) return [];
|
|
42
|
-
seen.add(key);
|
|
43
|
-
return [trimmed];
|
|
44
|
-
});
|
|
45
|
-
if (aliases.length > TASK_PROJECT_ALIAS_MAX_COUNT) {
|
|
46
|
-
throw new Error(`project aliases cannot exceed ${TASK_PROJECT_ALIAS_MAX_COUNT} entries`);
|
|
23
|
+
constructor(private readonly db: Db) {
|
|
24
|
+
this.registry = new SQLiteProjectRegistryStore(db, rewriteTaskRowsForMovedRoot);
|
|
47
25
|
}
|
|
48
|
-
return aliases;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export class SQLiteTaskScopeStore implements TaskScopeStore {
|
|
52
|
-
constructor(private readonly db: Db) {}
|
|
53
26
|
|
|
54
27
|
assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope {
|
|
55
28
|
inTransaction(this.db, () => {
|
|
@@ -115,95 +88,14 @@ export class SQLiteTaskScopeStore implements TaskScopeStore {
|
|
|
115
88
|
}
|
|
116
89
|
|
|
117
90
|
projects(query: string | undefined, limit: number): TaskProject[] {
|
|
118
|
-
|
|
119
|
-
if (!needle) {
|
|
120
|
-
return (
|
|
121
|
-
this.db
|
|
122
|
-
.prepare(
|
|
123
|
-
"SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects ORDER BY name, project_root LIMIT ?",
|
|
124
|
-
)
|
|
125
|
-
.all(limit) as ProjectRow[]
|
|
126
|
-
).map(projectFromRow);
|
|
127
|
-
}
|
|
128
|
-
return (
|
|
129
|
-
this.db
|
|
130
|
-
.prepare(`
|
|
131
|
-
SELECT id, name, aliases_json, project_root, created_at, updated_at
|
|
132
|
-
FROM task_projects
|
|
133
|
-
WHERE instr(lower(name), ?) > 0
|
|
134
|
-
OR instr(lower(project_root), ?) > 0
|
|
135
|
-
OR EXISTS (
|
|
136
|
-
SELECT 1 FROM json_each(task_projects.aliases_json)
|
|
137
|
-
WHERE instr(lower(CAST(json_each.value AS TEXT)), ?) > 0
|
|
138
|
-
)
|
|
139
|
-
ORDER BY name, project_root
|
|
140
|
-
LIMIT ?
|
|
141
|
-
`)
|
|
142
|
-
.all(needle, needle, needle, limit) as ProjectRow[]
|
|
143
|
-
).map(projectFromRow);
|
|
91
|
+
return this.registry.projects(query, limit);
|
|
144
92
|
}
|
|
145
93
|
|
|
146
94
|
matchingProjects(reference: string): TaskProject[] {
|
|
147
|
-
|
|
148
|
-
return (
|
|
149
|
-
this.db
|
|
150
|
-
.prepare(`
|
|
151
|
-
SELECT id, name, aliases_json, project_root, created_at, updated_at
|
|
152
|
-
FROM task_projects
|
|
153
|
-
WHERE lower(id) = ? OR lower(name) = ? OR lower(project_root) = ?
|
|
154
|
-
OR EXISTS (
|
|
155
|
-
SELECT 1 FROM json_each(task_projects.aliases_json)
|
|
156
|
-
WHERE lower(CAST(json_each.value AS TEXT)) = ?
|
|
157
|
-
)
|
|
158
|
-
ORDER BY name, project_root
|
|
159
|
-
LIMIT 11
|
|
160
|
-
`)
|
|
161
|
-
.all(needle, needle, needle, needle) as ProjectRow[]
|
|
162
|
-
).map(projectFromRow);
|
|
95
|
+
return this.registry.matchingProjects(reference);
|
|
163
96
|
}
|
|
164
97
|
|
|
165
98
|
registerProject(input: RegisterTaskProjectInput): TaskProject {
|
|
166
|
-
return
|
|
167
|
-
const row = (
|
|
168
|
-
input.existingId
|
|
169
|
-
? this.db
|
|
170
|
-
.prepare("SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects WHERE id = ?")
|
|
171
|
-
.get(input.existingId)
|
|
172
|
-
: this.db
|
|
173
|
-
.prepare("SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects WHERE project_root = ?")
|
|
174
|
-
.get(input.projectRoot)
|
|
175
|
-
) as ProjectRow | null;
|
|
176
|
-
const existing = row ? projectFromRow(row) : undefined;
|
|
177
|
-
const now = new Date().toISOString();
|
|
178
|
-
const name = input.name?.trim() || existing?.name || basename(input.projectRoot) || input.projectRoot;
|
|
179
|
-
const aliases = uniqueAliases(
|
|
180
|
-
[...(existing?.aliases ?? []), ...(existing && existing.name !== name ? [existing.name] : []), ...(input.aliases ?? [])],
|
|
181
|
-
name,
|
|
182
|
-
);
|
|
183
|
-
const project: TaskProject = {
|
|
184
|
-
id: existing?.id ?? randomUUID(),
|
|
185
|
-
name,
|
|
186
|
-
aliases,
|
|
187
|
-
projectRoot: input.projectRoot,
|
|
188
|
-
createdAt: existing?.createdAt ?? now,
|
|
189
|
-
updatedAt: now,
|
|
190
|
-
};
|
|
191
|
-
if (existing && existing.projectRoot !== project.projectRoot) {
|
|
192
|
-
this.db.prepare("UPDATE task_scopes SET project_root = ? WHERE project_root = ?").run(project.projectRoot, existing.projectRoot);
|
|
193
|
-
this.db.prepare("UPDATE task_views SET project_root = ? WHERE project_root = ?").run(project.projectRoot, existing.projectRoot);
|
|
194
|
-
}
|
|
195
|
-
this.db
|
|
196
|
-
.prepare(`
|
|
197
|
-
INSERT INTO task_projects (id, name, aliases_json, project_root, created_at, updated_at)
|
|
198
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
199
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
200
|
-
name = excluded.name,
|
|
201
|
-
aliases_json = excluded.aliases_json,
|
|
202
|
-
project_root = excluded.project_root,
|
|
203
|
-
updated_at = excluded.updated_at
|
|
204
|
-
`)
|
|
205
|
-
.run(project.id, project.name, JSON.stringify(project.aliases), project.projectRoot, project.createdAt, project.updatedAt);
|
|
206
|
-
return project;
|
|
207
|
-
});
|
|
99
|
+
return this.registry.registerProject(input);
|
|
208
100
|
}
|
|
209
101
|
}
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { basename } from "node:path";
|
|
3
|
-
import { TASK_PROJECT_ALIAS_MAX_COUNT } from "../constants.ts";
|
|
4
1
|
import type {
|
|
5
2
|
RegisterTaskProjectInput,
|
|
6
3
|
TaskProject,
|
|
@@ -9,6 +6,8 @@ import type {
|
|
|
9
6
|
TaskViewMode,
|
|
10
7
|
TaskViewPreference,
|
|
11
8
|
} from "../domain/task-scope.ts";
|
|
9
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
10
|
+
import { InMemoryProjectRegistryStore } from "./in-memory-project-registry-store.ts";
|
|
12
11
|
|
|
13
12
|
export interface TaskScopeStore {
|
|
14
13
|
assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope;
|
|
@@ -21,32 +20,38 @@ export interface TaskScopeStore {
|
|
|
21
20
|
registerProject(input: RegisterTaskProjectInput): TaskProject;
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return [trimmed];
|
|
32
|
-
});
|
|
33
|
-
if (aliases.length > TASK_PROJECT_ALIAS_MAX_COUNT) {
|
|
34
|
-
throw new Error(`project aliases cannot exceed ${TASK_PROJECT_ALIAS_MAX_COUNT} entries`);
|
|
35
|
-
}
|
|
36
|
-
return aliases;
|
|
37
|
-
}
|
|
38
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Task's own scope/view bookkeeping, composing a ProjectRegistryStore for the project-catalog
|
|
25
|
+
* methods (projects/matchingProjects/registerProject) rather than implementing that bookkeeping
|
|
26
|
+
* itself -- see project-registry-store.ts. Pass a shared registry instance to keep Task and a
|
|
27
|
+
* non-Task ArtifactScopeStore resolving against the exact same project identities; omitted, this
|
|
28
|
+
* store gets its own private one (matching this class's own behavior before the extraction).
|
|
29
|
+
*/
|
|
39
30
|
export class InMemoryTaskScopeStore implements TaskScopeStore {
|
|
40
31
|
private readonly scopes = new Map<string, TaskProjectScope>();
|
|
41
32
|
private readonly views = new Map<string, TaskViewPreference>();
|
|
42
|
-
private readonly
|
|
33
|
+
private readonly registry: InMemoryProjectRegistryStore;
|
|
34
|
+
|
|
35
|
+
constructor(registry?: ProjectRegistryStore) {
|
|
36
|
+
this.registry = registry instanceof InMemoryProjectRegistryStore ? registry : new InMemoryProjectRegistryStore();
|
|
37
|
+
this.registry.subscribeRootMoved((previousRoot, nextRoot) => this.onRootMoved(previousRoot, nextRoot));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private onRootMoved(previousRoot: string, nextRoot: string): void {
|
|
41
|
+
for (const [taskId, scope] of this.scopes) {
|
|
42
|
+
if (scope.projectRoot === previousRoot) this.scopes.set(taskId, { ...scope, projectRoot: nextRoot });
|
|
43
|
+
}
|
|
44
|
+
const view = this.views.get(previousRoot);
|
|
45
|
+
if (view) {
|
|
46
|
+
this.views.delete(previousRoot);
|
|
47
|
+
this.views.set(nextRoot, { ...view, projectRoot: nextRoot });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
43
50
|
|
|
44
51
|
assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope {
|
|
45
52
|
const scope = { taskId, ...(projectRoot === undefined ? {} : { projectRoot }), source };
|
|
46
53
|
this.scopes.set(taskId, scope);
|
|
47
|
-
if (projectRoot !== undefined && !
|
|
48
|
-
this.registerProject({ projectRoot });
|
|
49
|
-
}
|
|
54
|
+
if (projectRoot !== undefined && !this.registry.byRoot(projectRoot)) this.registerProject({ projectRoot });
|
|
50
55
|
return scope;
|
|
51
56
|
}
|
|
52
57
|
|
|
@@ -73,60 +78,14 @@ export class InMemoryTaskScopeStore implements TaskScopeStore {
|
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
projects(query: string | undefined, limit: number): TaskProject[] {
|
|
76
|
-
|
|
77
|
-
return [...this.projectRows.values()]
|
|
78
|
-
.filter(
|
|
79
|
-
(project) =>
|
|
80
|
-
!needle ||
|
|
81
|
-
project.name.toLowerCase().includes(needle) ||
|
|
82
|
-
project.projectRoot.toLowerCase().includes(needle) ||
|
|
83
|
-
project.aliases.some((alias) => alias.toLowerCase().includes(needle)),
|
|
84
|
-
)
|
|
85
|
-
.sort((left, right) => left.name.localeCompare(right.name) || left.projectRoot.localeCompare(right.projectRoot))
|
|
86
|
-
.slice(0, limit);
|
|
81
|
+
return this.registry.projects(query, limit);
|
|
87
82
|
}
|
|
88
83
|
|
|
89
84
|
matchingProjects(reference: string): TaskProject[] {
|
|
90
|
-
|
|
91
|
-
return [...this.projectRows.values()]
|
|
92
|
-
.filter(
|
|
93
|
-
(project) =>
|
|
94
|
-
project.id.toLowerCase() === needle ||
|
|
95
|
-
project.name.toLowerCase() === needle ||
|
|
96
|
-
project.projectRoot.toLowerCase() === needle ||
|
|
97
|
-
project.aliases.some((alias) => alias.toLowerCase() === needle),
|
|
98
|
-
)
|
|
99
|
-
.slice(0, 11);
|
|
85
|
+
return this.registry.matchingProjects(reference);
|
|
100
86
|
}
|
|
101
87
|
|
|
102
88
|
registerProject(input: RegisterTaskProjectInput): TaskProject {
|
|
103
|
-
|
|
104
|
-
const byRoot = [...this.projectRows.values()].find((project) => project.projectRoot === input.projectRoot);
|
|
105
|
-
const existing = input.existingId ? this.projectRows.get(input.existingId) : byRoot;
|
|
106
|
-
const name = input.name?.trim() || existing?.name || basename(input.projectRoot) || input.projectRoot;
|
|
107
|
-
const aliases = uniqueAliases(
|
|
108
|
-
[...(existing?.aliases ?? []), ...(existing && existing.name !== name ? [existing.name] : []), ...(input.aliases ?? [])],
|
|
109
|
-
name,
|
|
110
|
-
);
|
|
111
|
-
const project: TaskProject = {
|
|
112
|
-
id: existing?.id ?? randomUUID(),
|
|
113
|
-
name,
|
|
114
|
-
aliases,
|
|
115
|
-
projectRoot: input.projectRoot,
|
|
116
|
-
createdAt: existing?.createdAt ?? now,
|
|
117
|
-
updatedAt: now,
|
|
118
|
-
};
|
|
119
|
-
if (existing && existing.projectRoot !== project.projectRoot) {
|
|
120
|
-
for (const [taskId, scope] of this.scopes) {
|
|
121
|
-
if (scope.projectRoot === existing.projectRoot) this.scopes.set(taskId, { ...scope, projectRoot: project.projectRoot });
|
|
122
|
-
}
|
|
123
|
-
const view = this.views.get(existing.projectRoot);
|
|
124
|
-
if (view) {
|
|
125
|
-
this.views.delete(existing.projectRoot);
|
|
126
|
-
this.views.set(project.projectRoot, { ...view, projectRoot: project.projectRoot });
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
this.projectRows.set(project.id, project);
|
|
130
|
-
return project;
|
|
89
|
+
return this.registry.registerProject(input);
|
|
131
90
|
}
|
|
132
91
|
}
|