@promptowl/contextnest-community 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,74 @@
1
1
  import {
2
2
  canUserApprove,
3
3
  resolveStewardsForNode
4
- } from "./chunk-2FXVMVZJ.js";
4
+ } from "./chunk-K22GWPT4.js";
5
5
  import {
6
+ createVersion,
7
+ setApprovedVersion
8
+ } from "./chunk-JMZ75ZCD.js";
9
+ import {
10
+ config,
6
11
  getDb
7
- } from "./chunk-2TW25QEA.js";
12
+ } from "./chunk-KQCWNHDM.js";
8
13
 
9
14
  // src/governance/review-service.ts
10
15
  import { v4 as uuid } from "uuid";
16
+
17
+ // src/nodes/engine.ts
18
+ import { join } from "path";
19
+ import {
20
+ NestStorage,
21
+ GraphQueryEngine,
22
+ VersionManager
23
+ } from "@promptowl/contextnest-engine";
24
+ var NestEngineCache = class {
25
+ cache = /* @__PURE__ */ new Map();
26
+ get(nestId) {
27
+ let engine = this.cache.get(nestId);
28
+ if (!engine) {
29
+ const nestPath = join(config.DATA_ROOT, "nests", nestId);
30
+ const storage = new NestStorage(nestPath);
31
+ const query = new GraphQueryEngine(storage);
32
+ const versions = new VersionManager(storage);
33
+ engine = { storage, query, versions };
34
+ this.cache.set(nestId, engine);
35
+ }
36
+ return engine;
37
+ }
38
+ evict(nestId) {
39
+ this.cache.delete(nestId);
40
+ }
41
+ };
42
+ var engineCache = new NestEngineCache();
43
+
44
+ // src/governance/safe-publish.ts
45
+ import {
46
+ publishDocument,
47
+ serializeDocument
48
+ } from "@promptowl/contextnest-engine";
49
+ async function safePublishDocument(storage, docId, options) {
50
+ const node = await storage.readDocument(docId);
51
+ const cleanedFrontmatter = stripUndefinedDeep(node.frontmatter);
52
+ const cleanedNode = { ...node, frontmatter: cleanedFrontmatter };
53
+ await storage.writeDocument(docId, serializeDocument(cleanedNode));
54
+ return publishDocument(storage, docId, options);
55
+ }
56
+ function stripUndefinedDeep(value) {
57
+ if (Array.isArray(value)) {
58
+ return value.filter((v) => v !== void 0).map((v) => stripUndefinedDeep(v));
59
+ }
60
+ if (value && typeof value === "object") {
61
+ const out = {};
62
+ for (const [k, v] of Object.entries(value)) {
63
+ if (v === void 0) continue;
64
+ out[k] = stripUndefinedDeep(v);
65
+ }
66
+ return out;
67
+ }
68
+ return value;
69
+ }
70
+
71
+ // src/governance/review-service.ts
11
72
  function submitForReview(params) {
12
73
  const db = getDb();
13
74
  const existing = db.prepare(
@@ -35,7 +96,7 @@ function submitForReview(params) {
35
96
  ).run(params.nestId, params.nodeId, params.version);
36
97
  return getReviewRequest(id);
37
98
  }
38
- function approve(params) {
99
+ async function approve(params) {
39
100
  const db = getDb();
40
101
  const pending = db.prepare(
41
102
  "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1"
@@ -65,12 +126,45 @@ function approve(params) {
65
126
  pending.id
66
127
  );
67
128
  db.prepare(
68
- "UPDATE node_versions SET status = 'approved' WHERE nest_id = ? AND node_id = ? AND version = ?"
129
+ "UPDATE node_versions SET status = 'published' WHERE nest_id = ? AND node_id = ? AND version = ?"
69
130
  ).run(params.nestId, params.nodeId, params.version);
70
131
  db.prepare(
71
132
  `INSERT OR REPLACE INTO approved_versions (nest_id, node_id, approved_version, approved_by)
72
133
  VALUES (?, ?, ?, ?)`
73
134
  ).run(params.nestId, params.nodeId, params.version, params.approvedBy);
135
+ try {
136
+ const { storage } = engineCache.get(params.nestId);
137
+ const result = await safePublishDocument(storage, params.nodeId, {
138
+ editedBy: params.approvedBy,
139
+ note: params.note || `Approved review request ${pending.id}`
140
+ });
141
+ const engineVersion = result.versionEntry.version;
142
+ if (engineVersion !== params.version) {
143
+ const node = result.node;
144
+ const tags = node.frontmatter.tags || [];
145
+ createVersion({
146
+ nestId: params.nestId,
147
+ nodeId: params.nodeId,
148
+ version: engineVersion,
149
+ content: node.body || "",
150
+ author: params.approvedBy,
151
+ status: "published",
152
+ tags,
153
+ changeNote: params.note || `Approved review request ${pending.id}`
154
+ });
155
+ setApprovedVersion(
156
+ params.nestId,
157
+ params.nodeId,
158
+ engineVersion,
159
+ params.approvedBy
160
+ );
161
+ }
162
+ } catch (err) {
163
+ console.error(
164
+ `publishDocument failed for ${params.nestId}/${params.nodeId} on approve:`,
165
+ err
166
+ );
167
+ }
74
168
  return getReviewRequest(pending.id);
75
169
  }
76
170
  function reject(params) {
@@ -189,6 +283,8 @@ function rowToReviewRequest(row) {
189
283
  }
190
284
 
191
285
  export {
286
+ engineCache,
287
+ safePublishDocument,
192
288
  submitForReview,
193
289
  approve,
194
290
  reject,
@@ -1,12 +1,16 @@
1
1
  import {
2
2
  getDb
3
- } from "./chunk-2TW25QEA.js";
3
+ } from "./chunk-KQCWNHDM.js";
4
4
 
5
5
  // src/governance/version-service.ts
6
6
  import { createHash } from "crypto";
7
7
  function hashContent(content) {
8
8
  return createHash("sha256").update(content).digest("hex");
9
9
  }
10
+ var SYSTEM_AUTHOR_PREFIX = "system:auto-publish:";
11
+ function systemAuthor(email) {
12
+ return `${SYSTEM_AUTHOR_PREFIX}${email}`;
13
+ }
10
14
  function createVersion(params) {
11
15
  const db = getDb();
12
16
  const contentHash = hashContent(params.content);
@@ -49,8 +53,10 @@ function getVersion(nestId, nodeId, version) {
49
53
  function getCurrentVersion(nestId, nodeId) {
50
54
  const db = getDb();
51
55
  const row = db.prepare(
52
- "SELECT MAX(version) as v FROM node_versions WHERE nest_id = ? AND node_id = ?"
53
- ).get(nestId, nodeId);
56
+ `SELECT MAX(version) as v FROM node_versions
57
+ WHERE nest_id = ? AND node_id = ?
58
+ AND author NOT LIKE ?`
59
+ ).get(nestId, nodeId, `${SYSTEM_AUTHOR_PREFIX}%`);
54
60
  return row?.v || 0;
55
61
  }
56
62
  function getApprovedVersion(nestId, nodeId) {
@@ -70,8 +76,13 @@ function setApprovedVersion(nestId, nodeId, version, approvedBy) {
70
76
  function checkConflict(nestId, nodeId, baseVersion) {
71
77
  const db = getDb();
72
78
  const current = db.prepare(
73
- "SELECT version, content_hash, author, created_at FROM node_versions WHERE nest_id = ? AND node_id = ? ORDER BY version DESC LIMIT 1"
74
- ).get(nestId, nodeId);
79
+ `SELECT version, content_hash, author, created_at
80
+ FROM node_versions
81
+ WHERE nest_id = ? AND node_id = ?
82
+ AND author NOT LIKE ?
83
+ ORDER BY version DESC
84
+ LIMIT 1`
85
+ ).get(nestId, nodeId, `${SYSTEM_AUTHOR_PREFIX}%`);
75
86
  if (!current) {
76
87
  return { conflict: false, currentVersion: 0, currentHash: "" };
77
88
  }
@@ -114,11 +125,13 @@ function getDisplayStatus(nestId, nodeId) {
114
125
  ORDER BY version DESC LIMIT 1`
115
126
  ).get(nestId, nodeId);
116
127
  if (!current) return "draft";
117
- if (current.status === "approved") {
128
+ if (current.status === "published" || current.status === "approved") {
118
129
  const approved = db.prepare(
119
130
  "SELECT approved_version FROM approved_versions WHERE nest_id = ? AND node_id = ?"
120
131
  ).get(nestId, nodeId);
121
- if (approved?.approved_version === current.version) return "approved";
132
+ if (approved?.approved_version === current.version) {
133
+ return current.status === "published" ? "published" : "approved";
134
+ }
122
135
  return "draft";
123
136
  }
124
137
  if (current.status === "rejected") return "rejected";
@@ -138,6 +151,8 @@ function rowToVersion(row) {
138
151
 
139
152
  export {
140
153
  hashContent,
154
+ SYSTEM_AUTHOR_PREFIX,
155
+ systemAuthor,
141
156
  createVersion,
142
157
  getVersions,
143
158
  getVersion,
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  config,
3
3
  getDb
4
- } from "./chunk-2TW25QEA.js";
4
+ } from "./chunk-KQCWNHDM.js";
5
5
 
6
6
  // src/governance/stewardship-service.ts
7
7
  import { v4 as uuid } from "uuid";
@@ -108,8 +108,8 @@ function assignSteward(data) {
108
108
  const id = uuid();
109
109
  db.prepare(
110
110
  `INSERT INTO stewards
111
- (id, nest_id, scope, node_pattern, tag_name, user_email, user_id, role, can_approve, can_reject, assigned_by, is_active)
112
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
111
+ (id, nest_id, scope, node_pattern, tag_name, user_email, user_id, role, assigned_by, is_active)
112
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
113
113
  ).run(
114
114
  id,
115
115
  data.nestId,
@@ -119,8 +119,6 @@ function assignSteward(data) {
119
119
  data.userEmail,
120
120
  data.userId || null,
121
121
  data.role,
122
- data.canApprove ? 1 : 0,
123
- data.canReject ? 1 : 0,
124
122
  data.assignedBy,
125
123
  data.isActive ? 1 : 0
126
124
  );
@@ -128,7 +126,21 @@ function assignSteward(data) {
128
126
  }
129
127
  function removeSteward(id) {
130
128
  const db = getDb();
131
- db.prepare("UPDATE stewards SET is_active = 0 WHERE id = ?").run(id);
129
+ const remove = db.transaction((stewardId) => {
130
+ const row = db.prepare("SELECT nest_id, user_email FROM stewards WHERE id = ?").get(stewardId);
131
+ db.prepare("DELETE FROM stewards WHERE id = ?").run(stewardId);
132
+ if (!row) return;
133
+ const remaining = db.prepare(
134
+ "SELECT 1 FROM stewards WHERE nest_id = ? AND LOWER(user_email) = LOWER(?) AND is_active = 1 LIMIT 1"
135
+ ).get(row.nest_id, row.user_email);
136
+ if (remaining) return;
137
+ const user = db.prepare("SELECT id FROM users WHERE LOWER(email) = LOWER(?)").get(row.user_email);
138
+ if (!user) return;
139
+ db.prepare(
140
+ "DELETE FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
141
+ ).run(row.nest_id, user.id);
142
+ });
143
+ remove(id);
132
144
  }
133
145
  function getSteward(id) {
134
146
  const db = getDb();
@@ -208,10 +220,6 @@ async function createStewardRecord(params) {
208
220
  if (!params.documentId) throw new Error("documentId required for document scope");
209
221
  nodePattern = params.documentId;
210
222
  break;
211
- case "folder":
212
- if (!params.folderPath) throw new Error("folderPath required for folder scope");
213
- nodePattern = params.folderPath;
214
- break;
215
223
  case "tag":
216
224
  if (!params.tagName) throw new Error("tagName required for tag scope");
217
225
  tagName = params.tagName.trim().replace(/^#+/, "").toLowerCase();
@@ -249,8 +257,6 @@ async function createStewardRecord(params) {
249
257
  userEmail: email,
250
258
  userId: userRow?.id,
251
259
  role: user.role ?? "reviewer",
252
- canApprove: user.canApprove !== false,
253
- canReject: user.canReject !== false,
254
260
  assignedBy: params.assignedBy,
255
261
  assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
256
262
  isActive: true
@@ -283,17 +289,7 @@ function resolve(nestId, nodeId) {
283
289
  WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'document'
284
290
  AND s.node_pattern = ?
285
291
  UNION ALL
286
- SELECT s.*, 2 AS priority, ('folder: ' || s.node_pattern) AS match_source
287
- FROM stewards s
288
- WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'folder'
289
- AND s.node_pattern IS NOT NULL
290
- AND instr(s.node_pattern, '*') = 0
291
- AND (
292
- ? LIKE s.node_pattern || '/%'
293
- OR s.node_pattern = ?
294
- )
295
- UNION ALL
296
- SELECT s.*, 3 AS priority, ('tag: ' || s.tag_name) AS match_source
292
+ SELECT s.*, 2 AS priority, ('tag: ' || s.tag_name) AS match_source
297
293
  FROM stewards s
298
294
  JOIN node_tag_index nt
299
295
  ON nt.nest_id = s.nest_id
@@ -301,7 +297,7 @@ function resolve(nestId, nodeId) {
301
297
  WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'tag'
302
298
  AND nt.node_id = ?
303
299
  UNION ALL
304
- SELECT s.*, 4 AS priority, 'nest-level steward' AS match_source
300
+ SELECT s.*, 3 AS priority, 'nest-level steward' AS match_source
305
301
  FROM stewards s
306
302
  WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'nest'
307
303
  ORDER BY priority ASC, user_email ASC
@@ -312,10 +308,6 @@ function resolve(nestId, nodeId) {
312
308
  // document branch
313
309
  nestId,
314
310
  nodeId,
315
- nestId,
316
- // folder branch (path-prefix OR whole-nest folder)
317
- nestId,
318
- nodeId,
319
311
  // tag branch
320
312
  nestId
321
313
  // nest branch
@@ -325,23 +317,6 @@ function resolve(nestId, nodeId) {
325
317
  priority: row.priority,
326
318
  source: row.match_source
327
319
  }));
328
- const legacyFolderGlobs = db.prepare(
329
- `SELECT * FROM stewards
330
- WHERE nest_id = ? AND is_active = 1 AND scope = 'folder'
331
- AND node_pattern IS NOT NULL AND instr(node_pattern, '*') > 0`
332
- ).all(nestId);
333
- for (const row of legacyFolderGlobs) {
334
- if (globMatch(nodeId, row.node_pattern)) {
335
- resolved.push({
336
- steward: rowToSteward(row),
337
- priority: 2,
338
- source: `folder: ${row.node_pattern}`
339
- });
340
- }
341
- }
342
- if (legacyFolderGlobs.length > 0) {
343
- resolved.sort((a, b) => a.priority - b.priority);
344
- }
345
320
  if (resolved.length > 0) {
346
321
  return { stewards: resolved, fallbackToOwner: false };
347
322
  }
@@ -408,7 +383,7 @@ function canUserApprove(nestId, nodeId, userEmail) {
408
383
  };
409
384
  }
410
385
  const match = resolved.find(
411
- (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "reviewer" && r.steward.canApprove
386
+ (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "reviewer"
412
387
  );
413
388
  if (!match) {
414
389
  return {
@@ -444,14 +419,6 @@ function canUserAccess(nestId, nodeId, userEmail) {
444
419
  }
445
420
  return { allowed: false, reason: "no steward assignment", role: null };
446
421
  }
447
- function globMatch(value, pattern) {
448
- if (pattern === "*") return true;
449
- if (!pattern.includes("*")) return value === pattern;
450
- const regex = new RegExp(
451
- "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$"
452
- );
453
- return regex.test(value);
454
- }
455
422
  function syncFromConfig(nestId, config2) {
456
423
  const db = getDb();
457
424
  let count = 0;
@@ -474,8 +441,6 @@ function syncFromConfig(nestId, config2) {
474
441
  userEmail: entry.email.toLowerCase(),
475
442
  userId: user?.id,
476
443
  role,
477
- canApprove: entry.can_approve !== false,
478
- canReject: entry.can_reject !== false,
479
444
  assignedBy: "config",
480
445
  assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
481
446
  isActive: true
@@ -486,11 +451,6 @@ function syncFromConfig(nestId, config2) {
486
451
  if (config2.nest) {
487
452
  addEntries("nest", config2.nest);
488
453
  }
489
- if (config2.folders) {
490
- for (const [pattern, entries] of Object.entries(config2.folders)) {
491
- addEntries("folder", entries, { nodePattern: pattern });
492
- }
493
- }
494
454
  if (config2.tags) {
495
455
  for (const [tagName, entries] of Object.entries(config2.tags)) {
496
456
  addEntries("tag", entries, { tagName });
@@ -513,8 +473,6 @@ function rowToSteward(row) {
513
473
  userEmail: row.user_email,
514
474
  userId: row.user_id || void 0,
515
475
  role: row.role,
516
- canApprove: !!row.can_approve,
517
- canReject: !!row.can_reject,
518
476
  assignedBy: row.assigned_by,
519
477
  assignedAt: row.assigned_at,
520
478
  isActive: !!row.is_active
@@ -10,12 +10,15 @@ var envCandidates = [
10
10
  join(__dirname, "..", ".env")
11
11
  ];
12
12
  var envFileLoaded = envCandidates.find((p) => existsSync(p)) || null;
13
- if (envFileLoaded) {
13
+ var isTestRun = !!process.env.VITEST;
14
+ if (envFileLoaded && !isTestRun) {
14
15
  dotenv.config({ path: envFileLoaded, override: true });
15
16
  }
16
- console.log(
17
- `[config] dotenv: ${envFileLoaded ? `loaded ${envFileLoaded}` : "no .env file found"}`
18
- );
17
+ if (!isTestRun) {
18
+ console.log(
19
+ `[config] dotenv: ${envFileLoaded ? `loaded ${envFileLoaded}` : "no .env file found"}`
20
+ );
21
+ }
19
22
  function dataRoot() {
20
23
  return process.env.DATA_ROOT || join(process.cwd(), "data");
21
24
  }
@@ -78,6 +81,10 @@ import Database from "better-sqlite3";
78
81
  import { mkdirSync } from "fs";
79
82
  import { dirname as dirname2 } from "path";
80
83
 
84
+ // src/shared/constants.ts
85
+ var ANON_USER_ID = "00000000-0000-0000-0000-000000000000";
86
+ var ANON_EMAIL = "admin@localhost";
87
+
81
88
  // src/db/migrations.ts
82
89
  function runMigrations(db2) {
83
90
  db2.exec(`
@@ -148,19 +155,17 @@ function runMigrations(db2) {
148
155
  db2.exec(`
149
156
  -- Steward assignments (mirrors PromptOwl ContextSteward model)
150
157
  -- scope+target combination determines what the steward governs
151
- -- Resolution priority: document(1) > folder(2) > tag(3) > nest(4)
158
+ -- Resolution priority: document(1) > tag(2) > nest(3)
152
159
  CREATE TABLE IF NOT EXISTS stewards (
153
160
  id TEXT PRIMARY KEY,
154
161
  nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
155
- scope TEXT NOT NULL CHECK(scope IN ('document', 'folder', 'tag', 'nest')),
156
- node_pattern TEXT, -- glob for document/folder scope (e.g. "nodes/api-*")
162
+ scope TEXT NOT NULL CHECK(scope IN ('document', 'tag', 'nest')),
163
+ node_pattern TEXT, -- exact node id for document scope
157
164
  tag_name TEXT, -- for tag scope
158
165
  user_email TEXT NOT NULL,
159
166
  user_id TEXT REFERENCES users(id),
160
167
  role TEXT NOT NULL DEFAULT 'reviewer'
161
168
  CHECK(role IN ('editor', 'reviewer', 'admin')),
162
- can_approve INTEGER NOT NULL DEFAULT 1,
163
- can_reject INTEGER NOT NULL DEFAULT 1,
164
169
  assigned_by TEXT NOT NULL,
165
170
  assigned_at TEXT NOT NULL DEFAULT (datetime('now')),
166
171
  is_active INTEGER NOT NULL DEFAULT 1
@@ -231,6 +236,19 @@ function runMigrations(db2) {
231
236
  if (!userCols.includes("is_invited")) {
232
237
  db2.exec("ALTER TABLE users ADD COLUMN is_invited INTEGER NOT NULL DEFAULT 0");
233
238
  }
239
+ const stewardCols = db2.prepare("PRAGMA table_info(stewards)").all().map((c) => c.name);
240
+ if (stewardCols.length > 0) {
241
+ if (!stewardCols.includes("can_approve")) {
242
+ db2.exec(
243
+ "ALTER TABLE stewards ADD COLUMN can_approve INTEGER NOT NULL DEFAULT 1"
244
+ );
245
+ }
246
+ if (!stewardCols.includes("can_reject")) {
247
+ db2.exec(
248
+ "ALTER TABLE stewards ADD COLUMN can_reject INTEGER NOT NULL DEFAULT 1"
249
+ );
250
+ }
251
+ }
234
252
  db2.exec(`
235
253
  CREATE TABLE IF NOT EXISTS schema_migrations (
236
254
  id TEXT PRIMARY KEY,
@@ -245,24 +263,23 @@ function runMigrations(db2) {
245
263
  CREATE TABLE stewards_new (
246
264
  id TEXT PRIMARY KEY,
247
265
  nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
248
- scope TEXT NOT NULL CHECK(scope IN ('document', 'folder', 'tag', 'nest')),
266
+ scope TEXT NOT NULL CHECK(scope IN ('document', 'tag', 'nest')),
249
267
  node_pattern TEXT,
250
268
  tag_name TEXT,
251
269
  user_email TEXT NOT NULL,
252
270
  user_id TEXT REFERENCES users(id),
253
271
  role TEXT NOT NULL DEFAULT 'reviewer'
254
272
  CHECK(role IN ('editor', 'reviewer', 'viewer')),
255
- can_approve INTEGER NOT NULL DEFAULT 1,
256
- can_reject INTEGER NOT NULL DEFAULT 1,
257
273
  assigned_by TEXT NOT NULL,
258
274
  assigned_at TEXT NOT NULL DEFAULT (datetime('now')),
259
275
  is_active INTEGER NOT NULL DEFAULT 1
260
276
  );
261
277
 
262
- -- Copy rows; map legacy 'admin' role to 'reviewer', normalize tag_name + email
278
+ -- Copy rows; map legacy 'admin' role to 'reviewer', normalize tag_name + email.
279
+ -- Legacy can_approve/can_reject columns are dropped here (role is now the sole signal).
263
280
  INSERT INTO stewards_new
264
281
  (id, nest_id, scope, node_pattern, tag_name, user_email, user_id,
265
- role, can_approve, can_reject, assigned_by, assigned_at, is_active)
282
+ role, assigned_by, assigned_at, is_active)
266
283
  SELECT
267
284
  id, nest_id, scope, node_pattern,
268
285
  CASE
@@ -272,7 +289,7 @@ function runMigrations(db2) {
272
289
  lower(user_email),
273
290
  user_id,
274
291
  CASE role WHEN 'admin' THEN 'reviewer' ELSE role END,
275
- can_approve, can_reject, assigned_by, assigned_at, is_active
292
+ assigned_by, assigned_at, is_active
276
293
  FROM stewards;
277
294
 
278
295
  DROP TABLE stewards;
@@ -291,10 +308,6 @@ function runMigrations(db2) {
291
308
  ON stewards(nest_id, node_pattern, user_email)
292
309
  WHERE scope = 'document' AND node_pattern IS NOT NULL AND is_active = 1;
293
310
 
294
- CREATE UNIQUE INDEX idx_stewards_uniq_folder
295
- ON stewards(nest_id, node_pattern, user_email)
296
- WHERE scope = 'folder' AND node_pattern IS NOT NULL AND is_active = 1;
297
-
298
311
  CREATE UNIQUE INDEX idx_stewards_uniq_tag
299
312
  ON stewards(nest_id, tag_name, user_email)
300
313
  WHERE scope = 'tag' AND tag_name IS NOT NULL AND is_active = 1;
@@ -303,9 +316,6 @@ function runMigrations(db2) {
303
316
  CREATE INDEX idx_stewards_tag_lookup
304
317
  ON stewards(nest_id, tag_name)
305
318
  WHERE scope = 'tag' AND is_active = 1;
306
- CREATE INDEX idx_stewards_folder_lookup
307
- ON stewards(nest_id, node_pattern)
308
- WHERE scope = 'folder' AND is_active = 1;
309
319
  CREATE INDEX idx_stewards_doc_lookup
310
320
  ON stewards(nest_id, node_pattern)
311
321
  WHERE scope = 'document' AND is_active = 1;
@@ -401,6 +411,115 @@ function runMigrations(db2) {
401
411
  recordMigration("004_license_cache_owner_email");
402
412
  })();
403
413
  }
414
+ if (!hasMigration("005_anon_nest_public_default")) {
415
+ db2.transaction(() => {
416
+ db2.prepare(
417
+ "UPDATE nests SET visibility = 'public' WHERE user_id = ? AND visibility = 'private'"
418
+ ).run(ANON_USER_ID);
419
+ recordMigration("005_anon_nest_public_default");
420
+ })();
421
+ }
422
+ if (!hasMigration("006_node_versions_published_status")) {
423
+ db2.transaction(() => {
424
+ db2.exec(`
425
+ CREATE TABLE node_versions_new (
426
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
427
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
428
+ node_id TEXT NOT NULL,
429
+ version INTEGER NOT NULL,
430
+ content_hash TEXT NOT NULL,
431
+ author TEXT NOT NULL,
432
+ status TEXT NOT NULL DEFAULT 'draft'
433
+ CHECK(status IN ('draft', 'pending_review', 'approved', 'published', 'rejected')),
434
+ change_note TEXT,
435
+ tags_json TEXT,
436
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
437
+ UNIQUE(nest_id, node_id, version)
438
+ );
439
+ INSERT INTO node_versions_new
440
+ (id, nest_id, node_id, version, content_hash, author, status, change_note, tags_json, created_at)
441
+ SELECT
442
+ id, nest_id, node_id, version, content_hash, author, status, change_note, tags_json, created_at
443
+ FROM node_versions;
444
+ DROP TABLE node_versions;
445
+ ALTER TABLE node_versions_new RENAME TO node_versions;
446
+ CREATE INDEX idx_versions_node ON node_versions(nest_id, node_id);
447
+ `);
448
+ recordMigration("006_node_versions_published_status");
449
+ })();
450
+ }
451
+ if (!hasMigration("007_drop_steward_capability_flags")) {
452
+ const stewardCols2 = db2.prepare("PRAGMA table_info(stewards)").all().map((c) => c.name);
453
+ const hasLegacyCols = stewardCols2.includes("can_approve") || stewardCols2.includes("can_reject");
454
+ if (hasLegacyCols) {
455
+ db2.transaction(() => {
456
+ if (stewardCols2.includes("can_approve")) {
457
+ db2.exec("ALTER TABLE stewards DROP COLUMN can_approve");
458
+ }
459
+ if (stewardCols2.includes("can_reject")) {
460
+ db2.exec("ALTER TABLE stewards DROP COLUMN can_reject");
461
+ }
462
+ recordMigration("007_drop_steward_capability_flags");
463
+ })();
464
+ } else {
465
+ recordMigration("007_drop_steward_capability_flags");
466
+ }
467
+ }
468
+ if (!hasMigration("008_drop_steward_folder_scope")) {
469
+ db2.transaction(() => {
470
+ db2.exec("DELETE FROM stewards WHERE scope = 'folder'");
471
+ db2.exec("DROP INDEX IF EXISTS idx_stewards_uniq_folder");
472
+ db2.exec("DROP INDEX IF EXISTS idx_stewards_folder_lookup");
473
+ const tbl = db2.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='stewards'").get();
474
+ if (tbl?.sql && tbl.sql.includes("'folder'")) {
475
+ db2.exec(`
476
+ CREATE TABLE stewards_new (
477
+ id TEXT PRIMARY KEY,
478
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
479
+ scope TEXT NOT NULL CHECK(scope IN ('document', 'tag', 'nest')),
480
+ node_pattern TEXT,
481
+ tag_name TEXT,
482
+ user_email TEXT NOT NULL,
483
+ user_id TEXT REFERENCES users(id),
484
+ role TEXT NOT NULL DEFAULT 'reviewer'
485
+ CHECK(role IN ('editor', 'reviewer', 'viewer')),
486
+ assigned_by TEXT NOT NULL,
487
+ assigned_at TEXT NOT NULL DEFAULT (datetime('now')),
488
+ is_active INTEGER NOT NULL DEFAULT 1
489
+ );
490
+ INSERT INTO stewards_new
491
+ (id, nest_id, scope, node_pattern, tag_name, user_email, user_id,
492
+ role, assigned_by, assigned_at, is_active)
493
+ SELECT
494
+ id, nest_id, scope, node_pattern, tag_name, user_email, user_id,
495
+ role, assigned_by, assigned_at, is_active
496
+ FROM stewards;
497
+ DROP TABLE stewards;
498
+ ALTER TABLE stewards_new RENAME TO stewards;
499
+
500
+ CREATE INDEX idx_stewards_nest ON stewards(nest_id);
501
+ CREATE INDEX idx_stewards_email ON stewards(user_email);
502
+ CREATE INDEX idx_stewards_scope ON stewards(nest_id, scope);
503
+ CREATE UNIQUE INDEX idx_stewards_uniq_nest
504
+ ON stewards(nest_id, user_email)
505
+ WHERE scope = 'nest' AND is_active = 1;
506
+ CREATE UNIQUE INDEX idx_stewards_uniq_document
507
+ ON stewards(nest_id, node_pattern, user_email)
508
+ WHERE scope = 'document' AND node_pattern IS NOT NULL AND is_active = 1;
509
+ CREATE UNIQUE INDEX idx_stewards_uniq_tag
510
+ ON stewards(nest_id, tag_name, user_email)
511
+ WHERE scope = 'tag' AND tag_name IS NOT NULL AND is_active = 1;
512
+ CREATE INDEX idx_stewards_tag_lookup
513
+ ON stewards(nest_id, tag_name)
514
+ WHERE scope = 'tag' AND is_active = 1;
515
+ CREATE INDEX idx_stewards_doc_lookup
516
+ ON stewards(nest_id, node_pattern)
517
+ WHERE scope = 'document' AND is_active = 1;
518
+ `);
519
+ }
520
+ recordMigration("008_drop_steward_folder_scope");
521
+ })();
522
+ }
404
523
  }
405
524
 
406
525
  // src/db/client.ts
@@ -419,5 +538,7 @@ function getDb() {
419
538
 
420
539
  export {
421
540
  config,
541
+ ANON_USER_ID,
542
+ ANON_EMAIL,
422
543
  getDb
423
544
  };