@codemem/server 0.31.0 → 0.31.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.
package/dist/index.js CHANGED
@@ -1771,6 +1771,20 @@ function missingGuardrailConfirmations(warnings, confirmedTokens) {
1771
1771
  const confirmed = new Set(confirmedTokens);
1772
1772
  return warnings.filter((warning) => warning.requires_confirmation && (!warning.confirmation_token || !confirmed.has(warning.confirmation_token)));
1773
1773
  }
1774
+ function parseViewerProjectMappingInput(body) {
1775
+ const id = optionalViewerInteger(body, "id");
1776
+ const priority = optionalViewerInteger(body, "priority");
1777
+ if (Number.isNaN(id)) throw new Error("id must be an integer");
1778
+ if (Number.isNaN(priority)) throw new Error("priority must be an integer");
1779
+ return {
1780
+ id,
1781
+ workspace_identity: optionalViewerStrictString(body, "workspace_identity"),
1782
+ project_pattern: optionalViewerStrictString(body, "project_pattern"),
1783
+ scope_id: optionalViewerStrictString(body, "scope_id") ?? "",
1784
+ priority,
1785
+ source: optionalViewerStrictString(body, "source") ?? "user"
1786
+ };
1787
+ }
1774
1788
  function coordinatorAdminMutationStatus(message) {
1775
1789
  if (message.includes("scope_not_found") || message.includes("membership_not_found") || message.includes("group_not_found") || message.includes("Group not found") || message.includes("Scope not found") || message.includes("device_not_enrolled_for_scope_group") || message.includes("device must be enrolled")) return 404;
1776
1790
  if (message.includes("not active") || message.includes("scope_not_active") || message.includes("group_archived") || message.includes("Group is archived")) return 409;
@@ -2454,60 +2468,47 @@ function recentScopeRejectionsByPeer(store) {
2454
2468
  return map;
2455
2469
  }
2456
2470
  function legacySharedReviewSummary(store) {
2457
- const row = store.db.prepare(`SELECT COUNT(*) AS memory_count,
2458
- MAX(updated_at) AS last_updated_at
2459
- FROM memory_items
2460
- WHERE scope_id = ?
2461
- AND active = 1
2462
- AND deleted_at IS NULL`).get(LEGACY_SHARED_REVIEW_SCOPE_ID);
2463
- const memoryCount = Number(row?.memory_count ?? 0);
2471
+ const rows = legacySharedReviewRows(store);
2472
+ const memoryCount = rows.length;
2473
+ const lastUpdatedAt = rows.reduce((current, row) => row.updated_at && (!current || row.updated_at > current) ? row.updated_at : current, null);
2474
+ const ownedBySelf = store.buildOwnershipPredicate();
2464
2475
  const candidatesByIdentity = new Map(listProjectScopeCandidates(store.db, { limit: null }).map((candidate) => [candidate.workspace_identity, candidate]));
2465
- const groupRows = store.db.prepare(`SELECT s.project,
2466
- s.cwd,
2467
- s.git_remote,
2468
- s.git_branch,
2469
- m.workspace_id,
2470
- COUNT(*) AS memory_count,
2471
- MAX(m.updated_at) AS last_updated_at
2472
- FROM memory_items m
2473
- LEFT JOIN sessions s ON s.id = m.session_id
2474
- WHERE m.scope_id = ?
2475
- AND m.active = 1
2476
- AND m.deleted_at IS NULL
2477
- GROUP BY s.project, s.cwd, s.git_remote, s.git_branch, m.workspace_id
2478
- ORDER BY memory_count DESC, last_updated_at DESC`).all(LEGACY_SHARED_REVIEW_SCOPE_ID);
2479
2476
  const groupsByIdentity = /* @__PURE__ */ new Map();
2480
- for (const group of groupRows) {
2477
+ for (const row of rows) {
2481
2478
  const identity = canonicalWorkspaceIdentity({
2482
- cwd: group.cwd,
2483
- gitBranch: group.git_branch,
2484
- gitRemote: group.git_remote,
2485
- project: group.project,
2486
- workspaceId: group.workspace_id
2479
+ cwd: row.cwd,
2480
+ gitBranch: row.git_branch,
2481
+ gitRemote: row.git_remote,
2482
+ project: row.project,
2483
+ workspaceId: row.workspace_id
2487
2484
  });
2488
2485
  const candidate = candidatesByIdentity.get(identity.value);
2489
2486
  const resolvedScope = candidate?.resolved_scope_id ?? null;
2490
2487
  const suggestedScope = resolvedScope && resolvedScope !== LOCAL_DEFAULT_SCOPE_ID && resolvedScope !== LEGACY_SHARED_REVIEW_SCOPE_ID ? resolvedScope : candidate?.suggested_scope_id;
2491
2488
  const existing = groupsByIdentity.get(identity.value);
2492
- const memoryCount = Number(group.memory_count ?? 0);
2493
- const lastUpdatedAt = group.last_updated_at ?? null;
2489
+ const isOwnedBySelf = ownedBySelf(row);
2490
+ const rowUpdatedAt = row.updated_at ?? null;
2494
2491
  if (existing) {
2495
- existing.memory_count += memoryCount;
2496
- if (lastUpdatedAt && (!existing.last_updated_at || lastUpdatedAt > existing.last_updated_at)) existing.last_updated_at = lastUpdatedAt;
2492
+ existing.memory_count += 1;
2493
+ if (isOwnedBySelf) existing.reassignable_memory_count += 1;
2494
+ else existing.peer_owned_memory_count += 1;
2495
+ if (rowUpdatedAt && (!existing.last_updated_at || rowUpdatedAt > existing.last_updated_at)) existing.last_updated_at = rowUpdatedAt;
2497
2496
  continue;
2498
2497
  }
2499
2498
  groupsByIdentity.set(identity.value, {
2500
2499
  workspace_identity: identity.value,
2501
2500
  identity_source: identity.source,
2502
- display_project: identity.displayProject ?? group.project ?? group.cwd ?? identity.value,
2503
- memory_count: memoryCount,
2504
- last_updated_at: lastUpdatedAt,
2501
+ display_project: identity.displayProject ?? row.project ?? row.cwd ?? identity.value,
2502
+ memory_count: 1,
2503
+ reassignable_memory_count: isOwnedBySelf ? 1 : 0,
2504
+ peer_owned_memory_count: isOwnedBySelf ? 0 : 1,
2505
+ last_updated_at: rowUpdatedAt,
2505
2506
  suggested_scope_id: suggestedScope ?? null,
2506
2507
  suggestion_reason: suggestedScope ? candidate?.suggestion_reason ?? "Existing project mapping can be reviewed as a destination, but legacy data is not promoted automatically." : null
2507
2508
  });
2508
2509
  }
2509
2510
  const totalGroupCount = groupsByIdentity.size;
2510
- const groups = [...groupsByIdentity.values()].sort((left, right) => right.memory_count - left.memory_count || (right.last_updated_at ?? "").localeCompare(left.last_updated_at ?? "") || left.workspace_identity.localeCompare(right.workspace_identity)).slice(0, 20);
2511
+ const groups = [...groupsByIdentity.values()].sort((left, right) => right.memory_count - left.memory_count || (right.last_updated_at ?? "").localeCompare(left.last_updated_at ?? "") || left.workspace_identity.localeCompare(right.workspace_identity));
2511
2512
  const targetScopes = listSharingDomainSettingsScopes(store.db).filter((scope) => scope.scope_id !== LOCAL_DEFAULT_SCOPE_ID && scope.scope_id !== LEGACY_SHARED_REVIEW_SCOPE_ID).map((scope) => ({
2512
2513
  authority_type: scope.authority_type,
2513
2514
  label: scope.label || scope.scope_id,
@@ -2517,28 +2518,18 @@ function legacySharedReviewSummary(store) {
2517
2518
  scope_id: LEGACY_SHARED_REVIEW_SCOPE_ID,
2518
2519
  memory_count: memoryCount,
2519
2520
  has_data: memoryCount > 0,
2520
- last_updated_at: row?.last_updated_at ?? null,
2521
+ last_updated_at: lastUpdatedAt,
2521
2522
  groups,
2522
2523
  total_group_count: totalGroupCount,
2523
2524
  target_scopes: targetScopes
2524
2525
  };
2525
2526
  }
2526
- function assertLocalDeviceScopeMembership(store, scopeId) {
2527
- if (scopeId === LOCAL_DEFAULT_SCOPE_ID) return;
2528
- if (!store.db.prepare(`SELECT 1
2529
- FROM scope_memberships sm
2530
- JOIN replication_scopes rs ON rs.scope_id = sm.scope_id
2531
- WHERE sm.scope_id = ?
2532
- AND sm.device_id = ?
2533
- AND sm.status = 'active'
2534
- AND rs.status = 'active'
2535
- AND sm.membership_epoch >= rs.membership_epoch
2536
- LIMIT 1`).get(scopeId, store.deviceId)) throw new Error(`local device is not a member of Sharing domain ${scopeId}`);
2537
- }
2538
- function legacySharedReviewRowsForWorkspace(store, workspaceIdentity) {
2527
+ function legacySharedReviewRows(store) {
2539
2528
  return store.db.prepare(`SELECT m.id,
2529
+ m.rev,
2540
2530
  m.active,
2541
2531
  m.deleted_at,
2532
+ m.updated_at,
2542
2533
  m.scope_id,
2543
2534
  s.project,
2544
2535
  s.cwd,
@@ -2552,7 +2543,89 @@ function legacySharedReviewRowsForWorkspace(store, workspaceIdentity) {
2552
2543
  LEFT JOIN sessions s ON s.id = m.session_id
2553
2544
  WHERE m.scope_id = ?
2554
2545
  AND m.active = 1
2555
- AND m.deleted_at IS NULL`).all(LEGACY_SHARED_REVIEW_SCOPE_ID).filter((row) => {
2546
+ AND m.deleted_at IS NULL`).all(LEGACY_SHARED_REVIEW_SCOPE_ID);
2547
+ }
2548
+ function legacySharedReviewConfirmationToken(input) {
2549
+ const ownedBySelf = input.store.buildOwnershipPredicate();
2550
+ const payload = input.rows.map((row) => {
2551
+ const ownership = ownedBySelf(row) ? "local" : "peer";
2552
+ return `${row.id}:${row.rev ?? 0}:${row.scope_id ?? ""}:${ownership}`;
2553
+ }).sort().join(",");
2554
+ return Buffer.from(`${input.workspaceIdentity}|${input.scopeId}|${payload}`, "utf8").toString("base64url");
2555
+ }
2556
+ function projectInventoryRowsForWorkspace(store, workspaceIdentity) {
2557
+ return store.db.prepare(`SELECT m.id,
2558
+ m.rev,
2559
+ s.project,
2560
+ s.cwd,
2561
+ s.git_remote,
2562
+ s.git_branch,
2563
+ m.workspace_id,
2564
+ m.actor_id,
2565
+ m.origin_device_id,
2566
+ m.metadata_json
2567
+ FROM memory_items m
2568
+ LEFT JOIN sessions s ON s.id = m.session_id
2569
+ WHERE m.active = 1
2570
+ AND m.deleted_at IS NULL`).all().filter((row) => {
2571
+ return canonicalWorkspaceIdentity({
2572
+ cwd: row.cwd,
2573
+ gitBranch: row.git_branch,
2574
+ gitRemote: row.git_remote,
2575
+ project: row.project,
2576
+ workspaceId: row.workspace_id
2577
+ }).value === workspaceIdentity;
2578
+ });
2579
+ }
2580
+ function projectForgetConfirmationToken(store, rows) {
2581
+ const ownedBySelf = store.buildOwnershipPredicate();
2582
+ const payload = rows.map((row) => {
2583
+ const ownership = ownedBySelf(row) ? "local" : "peer";
2584
+ return `${row.id}:${row.rev ?? 0}:${ownership}`;
2585
+ }).sort().join(",");
2586
+ return Buffer.from(payload, "utf8").toString("base64url");
2587
+ }
2588
+ function forgetProjectInventoryLocalMemories(store, input) {
2589
+ if (!input.workspaceIdentity.trim()) throw new Error("workspace_identity must be a non-empty string");
2590
+ const rows = projectInventoryRowsForWorkspace(store, input.workspaceIdentity);
2591
+ if (rows.length === 0) throw new Error("project identity not found");
2592
+ const ownedBySelf = store.buildOwnershipPredicate();
2593
+ const localRows = rows.filter((row) => ownedBySelf(row));
2594
+ const confirmationToken = projectForgetConfirmationToken(store, rows);
2595
+ const preview = {
2596
+ confirmation_token: confirmationToken,
2597
+ local_owned_memory_count: localRows.length,
2598
+ peer_owned_memory_count: rows.length - localRows.length,
2599
+ workspace_identity: input.workspaceIdentity
2600
+ };
2601
+ if (!input.confirmed) return {
2602
+ confirmed: false,
2603
+ ...preview
2604
+ };
2605
+ if (input.confirmationToken !== confirmationToken) throw new Error("project memories changed before cleanup; refresh and try again");
2606
+ store.db.transaction(() => {
2607
+ for (const row of localRows) store.forget(row.id);
2608
+ })();
2609
+ return {
2610
+ confirmed: true,
2611
+ forgotten_memory_count: localRows.length,
2612
+ ...preview
2613
+ };
2614
+ }
2615
+ function assertLocalDeviceScopeMembership(store, scopeId) {
2616
+ if (scopeId === LOCAL_DEFAULT_SCOPE_ID) return;
2617
+ if (!store.db.prepare(`SELECT 1
2618
+ FROM scope_memberships sm
2619
+ JOIN replication_scopes rs ON rs.scope_id = sm.scope_id
2620
+ WHERE sm.scope_id = ?
2621
+ AND sm.device_id = ?
2622
+ AND sm.status = 'active'
2623
+ AND rs.status = 'active'
2624
+ AND sm.membership_epoch >= rs.membership_epoch
2625
+ LIMIT 1`).get(scopeId, store.deviceId)) throw new Error(`local device is not a member of Sharing domain ${scopeId}`);
2626
+ }
2627
+ function legacySharedReviewRowsForWorkspace(store, workspaceIdentity) {
2628
+ return legacySharedReviewRows(store).filter((row) => {
2556
2629
  return canonicalWorkspaceIdentity({
2557
2630
  cwd: row.cwd,
2558
2631
  gitBranch: row.git_branch,
@@ -2564,7 +2637,7 @@ function legacySharedReviewRowsForWorkspace(store, workspaceIdentity) {
2564
2637
  }
2565
2638
  function reassignableLegacySharedReviewRows(store, rows) {
2566
2639
  const reassignableRows = rows.filter((row) => store.memoryOwnedBySelf(row));
2567
- if (reassignableRows.length === 0) throw new Error("legacy shared review group has no locally owned memories to reassign");
2640
+ if (reassignableRows.length === 0) throw new Error("This legacy review group contains only peer-owned memories; this device cannot reassign them to a Sharing domain.");
2568
2641
  const currentRows = store.db.prepare(`SELECT id, scope_id, active, deleted_at
2569
2642
  FROM memory_items
2570
2643
  WHERE id IN (${reassignableRows.map(() => "?").join(",")})`).all(...reassignableRows.map((row) => row.id));
@@ -2590,6 +2663,12 @@ function legacySharedReviewReassignmentPreview(store, input) {
2590
2663
  return {
2591
2664
  affected_peer_device_count: peerDeviceIds.length,
2592
2665
  affected_peer_device_ids: peerDeviceIds.slice(0, 5),
2666
+ confirmation_token: legacySharedReviewConfirmationToken({
2667
+ rows,
2668
+ scopeId: input.scopeId,
2669
+ store,
2670
+ workspaceIdentity: input.workspaceIdentity
2671
+ }),
2593
2672
  memory_count: rows.length,
2594
2673
  reassignable_memory_count: reassignableRows.length,
2595
2674
  scope_id: input.scopeId,
@@ -2601,8 +2680,11 @@ function legacySharedReviewReassignmentPreview(store, input) {
2601
2680
  }
2602
2681
  function reassignLegacySharedReviewGroup(store, input) {
2603
2682
  const preview = legacySharedReviewReassignmentPreview(store, input);
2683
+ if (input.confirmationToken !== preview.confirmation_token) throw new Error("legacy shared review group changed before reassignment; refresh and try again");
2604
2684
  const rows = reassignableLegacySharedReviewRows(store, legacySharedReviewRowsForWorkspace(store, input.workspaceIdentity));
2605
- for (const row of rows) store.reassignMemoryScope(row.id, input.scopeId);
2685
+ store.db.transaction(() => {
2686
+ for (const row of rows) store.reassignMemoryScope(row.id, input.scopeId);
2687
+ })();
2606
2688
  return {
2607
2689
  ok: true,
2608
2690
  ...preview,
@@ -3337,6 +3419,32 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3337
3419
  return c.json({ error: message }, message.includes("not found") ? 404 : 400);
3338
3420
  }
3339
3421
  });
3422
+ app.post("/api/sync/projects/forget", async (c) => {
3423
+ const store = getStore();
3424
+ const body = await parseViewerJsonBody(c);
3425
+ if (!body) return c.json({ error: "invalid json" }, 400);
3426
+ try {
3427
+ const workspaceIdentity = optionalViewerStrictString(body, "workspace_identity") ?? "";
3428
+ const confirmationToken = optionalViewerStrictString(body, "confirmation_token");
3429
+ const confirmed = body.confirmed === true;
3430
+ const result = forgetProjectInventoryLocalMemories(store, {
3431
+ confirmationToken,
3432
+ confirmed,
3433
+ workspaceIdentity
3434
+ });
3435
+ if (!confirmed) return c.json({
3436
+ error: "project_forget_confirmation_required",
3437
+ preview: result
3438
+ }, 409);
3439
+ return c.json({
3440
+ ok: true,
3441
+ ...result
3442
+ });
3443
+ } catch (error) {
3444
+ const message = error instanceof Error ? error.message : String(error);
3445
+ return c.json({ error: message }, message.includes("not found") ? 404 : 400);
3446
+ }
3447
+ });
3340
3448
  app.post("/api/sync/legacy-shared-review/reassign", async (c) => {
3341
3449
  const store = getStore();
3342
3450
  const body = await parseViewerJsonBody(c);
@@ -3344,6 +3452,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3344
3452
  try {
3345
3453
  const workspaceIdentity = optionalViewerStrictString(body, "workspace_identity") ?? "";
3346
3454
  const scopeId = optionalViewerStrictString(body, "scope_id") ?? "";
3455
+ const confirmationToken = optionalViewerStrictString(body, "confirmation_token");
3347
3456
  const confirmedOldCopies = body.confirmed_old_copies === true;
3348
3457
  const preview = legacySharedReviewReassignmentPreview(store, {
3349
3458
  scopeId,
@@ -3355,6 +3464,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3355
3464
  preview
3356
3465
  }, 409);
3357
3466
  return c.json(reassignLegacySharedReviewGroup(store, {
3467
+ confirmationToken,
3358
3468
  scopeId,
3359
3469
  workspaceIdentity
3360
3470
  }));
@@ -3368,23 +3478,8 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3368
3478
  const body = await parseViewerJsonBody(c);
3369
3479
  if (!body) return c.json({ error: "invalid json" }, 400);
3370
3480
  try {
3371
- const id = optionalViewerInteger(body, "id");
3372
- const priority = optionalViewerInteger(body, "priority");
3373
- if (Number.isNaN(id)) return c.json({ error: "id must be an integer" }, 400);
3374
- if (Number.isNaN(priority)) return c.json({ error: "priority must be an integer" }, 400);
3375
- const workspaceIdentity = optionalViewerStrictString(body, "workspace_identity");
3376
- const projectPattern = optionalViewerStrictString(body, "project_pattern");
3377
- const scopeId = optionalViewerStrictString(body, "scope_id") ?? "";
3378
- const source = optionalViewerStrictString(body, "source") ?? "user";
3379
3481
  const confirmedGuardrailTokens = optionalViewerStringList(body, "confirmed_guardrail_tokens");
3380
- const mappingInput = {
3381
- id,
3382
- workspace_identity: workspaceIdentity,
3383
- project_pattern: projectPattern,
3384
- scope_id: scopeId,
3385
- priority,
3386
- source
3387
- };
3482
+ const mappingInput = parseViewerProjectMappingInput(body);
3388
3483
  const analysis = analyzeProjectScopeMappingChangeGuardrails(store.db, mappingInput);
3389
3484
  if (analysis.requested_workspace_identity?.startsWith("unmapped:")) return c.json({
3390
3485
  error: "unmapped_project_local_only",
@@ -3412,6 +3507,45 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3412
3507
  return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
3413
3508
  }
3414
3509
  });
3510
+ app.put("/api/sync/sharing-domains/project-mappings/bulk", async (c) => {
3511
+ const store = getStore();
3512
+ const body = await parseViewerJsonBody(c);
3513
+ if (!body) return c.json({ error: "invalid json" }, 400);
3514
+ try {
3515
+ const rawMappings = body.mappings;
3516
+ if (!Array.isArray(rawMappings) || rawMappings.length === 0) return c.json({ error: "mappings must be a non-empty array" }, 400);
3517
+ const mappingInputs = rawMappings.map((raw) => {
3518
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("each mapping must be an object");
3519
+ return parseViewerProjectMappingInput(raw);
3520
+ });
3521
+ const analyses = mappingInputs.map((mappingInput) => analyzeProjectScopeMappingChangeGuardrails(store.db, mappingInput));
3522
+ const unmapped = analyses.find((analysis) => analysis.requested_workspace_identity?.startsWith("unmapped:"));
3523
+ if (unmapped) return c.json({
3524
+ error: "unmapped_project_local_only",
3525
+ message: "Unmapped projects need a stable path, git remote, or workspace id before assigning a Sharing domain.",
3526
+ guardrail_warnings: unmapped.warnings
3527
+ }, 400);
3528
+ const missingConfirmations = analyses.flatMap((analysis) => missingGuardrailConfirmations(analysis.warnings, []));
3529
+ if (missingConfirmations.length > 0) {
3530
+ const missingCodes = [...new Set(missingConfirmations.map((warning) => warning.code))];
3531
+ const missingTokens = [...new Set(missingConfirmations.map((warning) => warning.confirmation_token))].filter((token) => typeof token === "string" && token.length > 0);
3532
+ return c.json({
3533
+ error: "guardrail_confirmation_required",
3534
+ required_guardrails: missingCodes,
3535
+ required_guardrail_tokens: missingTokens,
3536
+ guardrail_warnings: analyses.flatMap((analysis) => analysis.warnings)
3537
+ }, 409);
3538
+ }
3539
+ const saveMappings = store.db.transaction(() => mappingInputs.map((mappingInput) => upsertProjectScopeSettingsMapping(store.db, mappingInput)));
3540
+ return c.json({
3541
+ ok: true,
3542
+ mappings: saveMappings(),
3543
+ guardrail_warnings: analyses.flatMap((analysis) => analysis.warnings)
3544
+ });
3545
+ } catch (error) {
3546
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
3547
+ }
3548
+ });
3415
3549
  app.delete("/api/sync/sharing-domains/project-mappings/:id", (c) => {
3416
3550
  const store = getStore();
3417
3551
  const id = Number(c.req.param("id"));