memoir-cli 3.12.0 → 3.15.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.
Files changed (74) hide show
  1. package/README.md +128 -137
  2. package/bin/memoir-work.js +9 -0
  3. package/bin/memoir.js +50 -8
  4. package/docs/AUDIT-REMEDIATION.md +55 -0
  5. package/docs/CASE_TAPE_AMNESIA.md +39 -0
  6. package/docs/HANDOFF-SECURITY-AUDIT.md +106 -0
  7. package/docs/LOCAL-HANDOFF-VALIDATION.md +129 -0
  8. package/docs/MCP-V2-MIGRATION.md +17 -0
  9. package/docs/PROJECT-HANDOFF.md +301 -0
  10. package/docs/PROJECT-MAP-TRIAL.md +149 -0
  11. package/docs/PROJECT-VIEW-DEBUG.md +66 -0
  12. package/docs/PROJECT-VIEW-VALIDATION.md +136 -0
  13. package/docs/RELEASE-3.14-VALIDATION.md +36 -0
  14. package/docs/RELIABILITY-ROLLOUT.md +57 -0
  15. package/docs/RETRIEVAL-INDEX.md +45 -0
  16. package/docs/RETRIEVAL-RESULTS.md +26 -0
  17. package/docs/SPEC.md +684 -0
  18. package/evals/CONTINUITY-PROTOCOL.md +45 -0
  19. package/evals/cases.json +200 -0
  20. package/evals/results/retrieval-2026-09-05.json +5333 -0
  21. package/evals/retrieval-performance.mjs +99 -0
  22. package/evals/run.mjs +87 -0
  23. package/package.json +13 -5
  24. package/src/adapters/index.js +13 -6
  25. package/src/adapters/restore.js +83 -36
  26. package/src/cloud/storage.js +130 -93
  27. package/src/commands/activate.js +18 -7
  28. package/src/commands/cloud.js +55 -4
  29. package/src/commands/consolidate.js +49 -10
  30. package/src/commands/diff.js +2 -2
  31. package/src/commands/doctor.js +3 -3
  32. package/src/commands/push.js +156 -161
  33. package/src/commands/recall.js +1 -1
  34. package/src/commands/restore.js +32 -44
  35. package/src/commands/resume.js +15 -164
  36. package/src/commands/session.js +51 -9
  37. package/src/commands/snapshot.js +6 -7
  38. package/src/commands/status.js +23 -1
  39. package/src/commands/upgrade.js +11 -9
  40. package/src/commands/validate.js +3 -0
  41. package/src/commands/view.js +2 -2
  42. package/src/commands/why.js +4 -3
  43. package/src/config.js +9 -40
  44. package/src/context/capture.js +126 -32
  45. package/src/context/handoffs.js +72 -0
  46. package/src/events/summary.js +122 -0
  47. package/src/integrations/setup.js +88 -0
  48. package/src/mcp.js +105 -152
  49. package/src/memory/lexical-index.js +65 -0
  50. package/src/memory/repository.js +16 -0
  51. package/src/memory/scope.js +65 -0
  52. package/src/memory/search.js +165 -70
  53. package/src/memory/store.js +141 -0
  54. package/src/providers/index.js +182 -51
  55. package/src/providers/restore.js +5 -1
  56. package/src/security/encryption.js +34 -60
  57. package/src/security/files.js +155 -0
  58. package/src/session/brief.js +47 -0
  59. package/src/session/inject.js +12 -6
  60. package/src/session/lock.js +39 -118
  61. package/src/session/migrations.js +6 -0
  62. package/src/session/render.js +34 -4
  63. package/src/session/state.js +200 -33
  64. package/src/work/cli.js +64 -0
  65. package/src/work/errors.js +8 -0
  66. package/src/work/server.js +28 -0
  67. package/src/work/setup.js +96 -0
  68. package/src/work/store.js +340 -0
  69. package/src/work/ui/app.js +398 -0
  70. package/src/work/ui/index.html +45 -0
  71. package/src/work/ui/style.css +248 -0
  72. package/src/work/view.js +93 -0
  73. package/src/workspace/tracker.js +84 -332
  74. package/supabase/migrations/202609050001_backup_versions.sql +50 -0
@@ -1,350 +1,102 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import crypto from 'crypto';
4
5
  import { execFileSync } from 'child_process';
6
+ import { relativeFile, readSafeFile, writeSafeFile, restoreFileSet } from '../security/files.js';
7
+ import { scanForSecrets } from '../security/scanner.js';
8
+ import { projectIdentity } from '../memory/scope.js';
9
+ import { repositoryState } from '../memory/repository.js';
5
10
 
6
- const home = os.homedir();
11
+ const skippedDirectories = new Set(['.git', 'node_modules', '.next', '.cache', '.venv', 'venv', 'dist', 'build', '__pycache__']);
12
+ const sensitive = /(^|\/)(\.env(?:\..*)?|\.npmrc|\.pypirc|credentials[^/]*|secrets?[^/]*|id_rsa|id_ed25519)$|\.(pem|key|p12|pfx)$/i;
7
13
 
8
- /**
9
- * Scan home directory for git projects and build a workspace manifest.
10
- * Tracks: git remote URL, branch, last commit, uncommitted changes (as patch).
11
- * For non-git projects with AI configs, bundles them as tar.gz.
12
- */
14
+ // Explicit opt-in captures only the active project. No home-wide discovery,
15
+ // external archive extraction, or remote repository instructions are executed.
13
16
  export async function scanWorkspace(stagingDir, spinner, opts = {}) {
14
- const maxDepth = opts.maxDepth || 3;
15
- const maxBundleSize = opts.maxBundleSize || 50 * 1024 * 1024; // 50MB
16
-
17
- const skipDirs = new Set([
18
- 'node_modules', '.git', '.next', '.vercel', 'dist', 'build',
19
- '__pycache__', '.venv', 'venv', '.cache', '.npm', '.bun',
20
- 'Library', '.Trash', 'Applications', 'Pictures', 'Music',
21
- 'Movies', 'Public', 'Downloads', '.local', '.cargo', '.rustup',
22
- '.docker', '.ssh', '.config', '.claude', '.gemini'
23
- ]);
24
-
25
- // Project markers files that indicate "this is a project"
26
- const projectMarkers = [
27
- 'package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml',
28
- 'requirements.txt', 'Gemfile', 'pom.xml', 'build.gradle',
29
- 'Makefile', 'CMakeLists.txt', '.project', 'CLAUDE.md',
30
- 'GEMINI.md', 'AGENTS.md', 'README.md',
31
- // Also detect dirs with .git or multiple content files
32
- '.gitignore', 'index.html', 'main.py', 'app.py', 'index.js',
33
- ];
34
-
35
- // Also detect dirs with multiple markdown/code files as potential projects
36
- const isContentProject = (entries) => {
37
- const mdFiles = entries.filter(e => !e.isDirectory() && e.name.endsWith('.md'));
38
- return mdFiles.length >= 2; // 2+ markdown files = likely a writing project
39
- };
40
-
41
- const projects = [];
42
-
43
- const scanDir = async (dir, depth = 0) => {
44
- if (depth > maxDepth) return;
45
- let entries;
46
- try {
47
- entries = await fs.readdir(dir, { withFileTypes: true });
48
- } catch { return; }
49
-
50
- // Check if this dir is a project
51
- const hasMarker = entries.some(e => !e.isDirectory() && projectMarkers.includes(e.name));
52
- const hasContent = isContentProject(entries);
53
-
54
- if ((hasMarker || hasContent) && dir !== home) {
55
- const info = await getProjectInfo(dir);
56
- if (info) projects.push(info);
57
- // Don't recurse into sub-projects deeper than this
58
- return;
59
- }
60
-
61
- // Recurse into subdirectories
62
- for (const entry of entries) {
63
- if (!entry.isDirectory()) continue;
64
- if (entry.name.startsWith('.') && entry.name !== '.github') continue;
65
- if (skipDirs.has(entry.name)) continue;
66
- await scanDir(path.join(dir, entry.name), depth + 1);
67
- }
68
- };
69
-
70
- if (spinner) spinner.text = 'Scanning workspace for projects...';
71
- await scanDir(home);
72
-
73
- // Build manifest
74
- const manifest = {
75
- version: 1,
76
- machine: os.hostname(),
77
- platform: process.platform,
78
- home: home,
79
- scannedAt: new Date().toISOString(),
80
- projects: []
81
- };
82
-
83
- const bundleDir = path.join(stagingDir, 'workspace-bundles');
84
-
85
- for (const proj of projects) {
86
- const entry = {
87
- name: proj.name,
88
- relativePath: proj.relativePath,
89
- originalPath: proj.path,
90
- type: proj.hasGit ? 'git' : 'bundle',
91
- };
92
-
93
- if (proj.hasGit) {
94
- entry.gitRemote = proj.gitRemote;
95
- entry.branch = proj.branch;
96
- entry.lastCommit = proj.lastCommit;
97
- entry.lastCommitMessage = proj.lastCommitMessage;
98
-
99
- // Save uncommitted changes as patch
100
- if (proj.hasDirtyWork && proj.lastCommit) {
101
- try {
102
- const patchDir = path.join(stagingDir, 'workspace-patches');
103
- await fs.ensureDir(patchDir);
104
- const diff = execFileSync('git', ['diff', 'HEAD'], {
105
- cwd: proj.path,
106
- maxBuffer: 10 * 1024 * 1024,
107
- timeout: 10000
108
- }).toString();
109
- if (diff.trim()) {
110
- const patchFile = `${proj.name}.patch`;
111
- await fs.writeFile(path.join(patchDir, patchFile), diff);
112
- entry.patchFile = patchFile;
113
- }
114
- } catch {
115
- // Patch capture is best-effort
116
- }
117
- }
118
- } else {
119
- // Bundle non-git project (if small enough)
120
- const size = await getDirSize(proj.path);
121
- if (size <= maxBundleSize) {
122
- try {
123
- await fs.ensureDir(bundleDir);
124
- const bundleName = `${proj.name}.tar.gz`;
125
- execFileSync('tar', [
126
- 'czf', path.join(bundleDir, bundleName),
127
- '-C', path.dirname(proj.path),
128
- '--exclude', 'node_modules',
129
- '--exclude', '.git',
130
- '--exclude', '__pycache__',
131
- '--exclude', '.venv',
132
- '--exclude', 'dist',
133
- '--exclude', 'build',
134
- proj.name
135
- ], { stdio: 'ignore', timeout: 30000 });
136
- entry.bundleFile = bundleName;
137
- entry.bundleSize = (await fs.stat(path.join(bundleDir, bundleName))).size;
138
- } catch {
139
- // Bundle is best-effort
140
- entry.bundleFailed = true;
141
- }
142
- } else {
143
- entry.tooLarge = true;
144
- entry.size = size;
17
+ const project = await fs.realpath(path.resolve(opts.project || process.env.MEMOIR_PROJECT_ROOT || process.cwd()));
18
+ const repository = repositoryState(project);
19
+ let candidates = [];
20
+ if (repository.head) {
21
+ candidates = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], {
22
+ cwd: project, encoding: 'utf8', timeout: 10000, maxBuffer: 16 * 1024 * 1024,
23
+ }).split('\0').filter(Boolean);
24
+ } else {
25
+ const walk = async (dir, prefix = '') => {
26
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
27
+ if (skippedDirectories.has(entry.name)) continue;
28
+ const rel = prefix + entry.name;
29
+ if (entry.isDirectory()) await walk(path.join(dir, entry.name), rel + '/');
30
+ else candidates.push(rel);
31
+ if (candidates.length > 50000) throw new Error('Workspace file count limit exceeded');
145
32
  }
33
+ };
34
+ await walk(project);
35
+ }
36
+ if (candidates.length > 50000) throw new Error('Workspace file count limit exceeded');
37
+ const projectId = projectIdentity(project);
38
+ const key = crypto.createHash('sha256').update(projectId).digest('hex').slice(0, 16);
39
+ const files = [], omitted = [];
40
+ let bytes = 0;
41
+ for (const candidate of [...new Set(candidates)].sort()) {
42
+ const rel = relativeFile(candidate);
43
+ if (rel.split('/').some(p => skippedDirectories.has(p)) || sensitive.test(rel)) { omitted.push({ path: rel, reason: 'excluded' }); continue; }
44
+ let content;
45
+ try { content = await readSafeFile(project, rel); }
46
+ catch (err) {
47
+ if (err.code === 'ENOENT') { omitted.push({ path: rel, reason: 'deleted' }); continue; }
48
+ throw err;
146
49
  }
147
-
148
- manifest.projects.push(entry);
50
+ // A heuristic, not a guarantee: omitted matches remain visible in the manifest.
51
+ if (scanForSecrets(content.toString('utf8')).found.length) { omitted.push({ path: rel, reason: 'potential-secret' }); continue; }
52
+ bytes += content.length;
53
+ if (bytes > (opts.maxBundleSize || 50 * 1024 * 1024)) throw new Error('Workspace size limit exceeded');
54
+ const stored = 'workspace-files/' + key + '/' + rel;
55
+ await writeSafeFile(stagingDir, stored, content);
56
+ files.push({ path: rel, stored, sha256: crypto.createHash('sha256').update(content).digest('hex') });
149
57
  }
150
-
151
- // Save manifest
152
- await fs.writeFile(
153
- path.join(stagingDir, 'workspace.json'),
154
- JSON.stringify(manifest, null, 2)
155
- );
156
-
58
+ const manifest = { version: 2, scannedAt: new Date().toISOString(), projects: [{
59
+ name: path.basename(project), identity: projectId, key, type: 'files',
60
+ repository, files, omitted, size: bytes,
61
+ }] };
62
+ await writeSafeFile(stagingDir, 'workspace.json', JSON.stringify(manifest, null, 2));
157
63
  return manifest;
158
64
  }
159
65
 
160
- /**
161
- * Restore workspace on this machine from a manifest.
162
- * Clones git projects, unpacks bundles, applies patches.
163
- */
66
+ // Recovery produces a separate directory for inspection, never overlays an
67
+ // existing checkout. The recorded commit is evidence, not a claim of Git history.
164
68
  export async function restoreWorkspace(sourceDir, spinner, autoYes = false) {
165
- const manifestPath = path.join(sourceDir, 'workspace.json');
166
- if (!await fs.pathExists(manifestPath)) return null;
167
-
168
- const manifest = await fs.readJson(manifestPath);
169
- if (!manifest.projects || manifest.projects.length === 0) return null;
170
-
171
- const results = { cloned: [], unpacked: [], patched: [], skipped: [] };
172
-
173
- for (const proj of manifest.projects) {
174
- // Determine where to put this project on the local machine
175
- const localPath = resolveLocalPath(proj);
176
-
177
- // Skip if project already exists locally
178
- if (await fs.pathExists(localPath)) {
179
- // But check if we should apply a patch
180
- if (proj.patchFile) {
181
- const patchPath = path.join(sourceDir, 'workspace-patches', proj.patchFile);
182
- if (await fs.pathExists(patchPath)) {
183
- try {
184
- execFileSync('git', ['apply', '--check', patchPath], {
185
- cwd: localPath, stdio: 'ignore'
186
- });
187
- execFileSync('git', ['apply', patchPath], {
188
- cwd: localPath, stdio: 'ignore'
189
- });
190
- results.patched.push({ name: proj.name, path: localPath });
191
- } catch {
192
- // Patch didn't apply cleanly — skip
193
- }
194
- }
195
- }
196
- results.skipped.push({ name: proj.name, path: localPath, reason: 'exists' });
197
- continue;
198
- }
199
-
200
- if (proj.type === 'git' && proj.gitRemote) {
201
- // Clone the repo
202
- if (spinner) spinner.text = `Cloning ${proj.name}...`;
203
- try {
204
- await fs.ensureDir(path.dirname(localPath));
205
- execFileSync('git', ['clone', proj.gitRemote, localPath], {
206
- stdio: 'ignore',
207
- timeout: 120000
208
- });
209
-
210
- // Checkout the right branch
211
- if (proj.branch && proj.branch !== 'main' && proj.branch !== 'master') {
212
- try {
213
- execFileSync('git', ['checkout', proj.branch], {
214
- cwd: localPath, stdio: 'ignore'
215
- });
216
- } catch {}
217
- }
218
-
219
- // Apply patch if available
220
- if (proj.patchFile) {
221
- const patchPath = path.join(sourceDir, 'workspace-patches', proj.patchFile);
222
- if (await fs.pathExists(patchPath)) {
223
- try {
224
- execFileSync('git', ['apply', patchPath], {
225
- cwd: localPath, stdio: 'ignore'
226
- });
227
- results.patched.push({ name: proj.name, path: localPath });
228
- } catch {}
229
- }
230
- }
231
-
232
- results.cloned.push({ name: proj.name, path: localPath, remote: proj.gitRemote });
233
- } catch (err) {
234
- results.skipped.push({ name: proj.name, reason: `clone failed: ${err.message}` });
235
- }
236
- } else if (proj.bundleFile) {
237
- // Unpack bundle
238
- const bundlePath = path.join(sourceDir, 'workspace-bundles', proj.bundleFile);
239
- if (await fs.pathExists(bundlePath)) {
240
- if (spinner) spinner.text = `Unpacking ${proj.name}...`;
241
- try {
242
- await fs.ensureDir(path.dirname(localPath));
243
- execFileSync('tar', ['xzf', bundlePath, '-C', path.dirname(localPath)], {
244
- stdio: 'ignore',
245
- timeout: 60000
246
- });
247
- results.unpacked.push({ name: proj.name, path: localPath });
248
- } catch (err) {
249
- results.skipped.push({ name: proj.name, reason: `unpack failed: ${err.message}` });
250
- }
251
- }
252
- } else {
253
- results.skipped.push({ name: proj.name, reason: proj.tooLarge ? 'too large' : 'no source' });
69
+ let raw;
70
+ try { raw = await readSafeFile(sourceDir, 'workspace.json'); }
71
+ catch (err) { if (err.code === 'ENOENT') return null; throw err; }
72
+ const manifest = JSON.parse(raw.toString());
73
+ if (manifest.version !== 2) throw new Error('Legacy workspace archives cannot be safely auto-restored. Keep the backup and inspect its archive separately, or create a new workspace snapshot.');
74
+ if (!Array.isArray(manifest.projects) || manifest.projects.length > 100) throw new Error('Invalid workspace manifest');
75
+ const plans = [];
76
+ let totalBytes = 0, totalFiles = 0;
77
+ for (const project of manifest.projects) {
78
+ if (!/^[a-f0-9]{16}$/.test(project.key) || !Array.isArray(project.files)) throw new Error('Invalid workspace project');
79
+ const entries = [];
80
+ for (const file of project.files) {
81
+ if (++totalFiles > 50000) throw new Error('Workspace file count limit exceeded');
82
+ const rel = relativeFile(file.path);
83
+ if (file.stored !== 'workspace-files/' + project.key + '/' + rel) throw new Error('Invalid workspace source path');
84
+ const content = await readSafeFile(sourceDir, file.stored);
85
+ totalBytes += content.length;
86
+ if (totalBytes > 256 * 1024 * 1024) throw new Error('Workspace size limit exceeded');
87
+ if (crypto.createHash('sha256').update(content).digest('hex') !== file.sha256) throw new Error('Workspace content failed verification');
88
+ entries.push({ path: rel, content });
254
89
  }
90
+ plans.push({ project, entries });
255
91
  }
256
-
257
- return results;
258
- }
259
-
260
- /**
261
- * Figure out where a project should live on this machine.
262
- */
263
- function resolveLocalPath(proj) {
264
- // If the project had a relative path from home, use that
265
- if (proj.relativePath) {
266
- return path.join(home, proj.relativePath);
267
- }
268
- // Default: put it in home directory
269
- return path.join(home, proj.name);
270
- }
271
-
272
- /**
273
- * Get info about a project directory.
274
- */
275
- async function getProjectInfo(dir) {
276
- const name = path.basename(dir);
277
- const relativePath = path.relative(home, dir);
278
- const info = {
279
- name,
280
- path: dir,
281
- relativePath,
282
- hasGit: false,
283
- gitRemote: null,
284
- branch: null,
285
- lastCommit: null,
286
- lastCommitMessage: null,
287
- hasDirtyWork: false,
288
- };
289
-
290
- // Check for git
291
- const gitDir = path.join(dir, '.git');
292
- if (await fs.pathExists(gitDir)) {
293
- info.hasGit = true;
294
- try {
295
- const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
296
- cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
297
- }).toString().trim();
298
- info.gitRemote = remote;
299
- } catch {}
300
-
301
- try {
302
- info.branch = execFileSync('git', ['branch', '--show-current'], {
303
- cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
304
- }).toString().trim();
305
- } catch {}
306
-
307
- try {
308
- info.lastCommit = execFileSync('git', ['log', '-1', '--format=%H'], {
309
- cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
310
- }).toString().trim();
311
- info.lastCommitMessage = execFileSync('git', ['log', '-1', '--format=%s'], {
312
- cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
313
- }).toString().trim();
314
- } catch {}
315
-
316
- try {
317
- const status = execFileSync('git', ['status', '--porcelain'], {
318
- cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
319
- }).toString().trim();
320
- info.hasDirtyWork = status.length > 0;
321
- } catch {}
92
+ const results = { cloned: [], unpacked: [], patched: [], skipped: [] };
93
+ for (const { project, entries } of plans) {
94
+ const parent = path.join(os.homedir(), 'memoir-restored');
95
+ await fs.ensureDir(parent);
96
+ const destination = await fs.mkdtemp(path.join(parent, project.key + '-'));
97
+ try { await restoreFileSet(destination, entries); }
98
+ catch (err) { await fs.remove(destination); throw err; }
99
+ results.unpacked.push({ name: project.name, path: destination, omitted: project.omitted?.length || 0 });
322
100
  }
323
-
324
- return info;
325
- }
326
-
327
- /**
328
- * Get total size of a directory (excluding common heavy dirs).
329
- */
330
- async function getDirSize(dir) {
331
- let size = 0;
332
- const skip = new Set(['node_modules', '.git', '__pycache__', '.venv', 'dist', 'build']);
333
-
334
- const walk = async (d) => {
335
- let entries;
336
- try { entries = await fs.readdir(d, { withFileTypes: true }); } catch { return; }
337
- for (const e of entries) {
338
- if (e.isDirectory()) {
339
- if (!skip.has(e.name)) await walk(path.join(d, e.name));
340
- } else {
341
- try {
342
- const stat = await fs.stat(path.join(d, e.name));
343
- size += stat.size;
344
- } catch {}
345
- }
346
- }
347
- };
348
- await walk(dir);
349
- return size;
101
+ return results;
350
102
  }
@@ -0,0 +1,50 @@
1
+ -- Review and apply to the existing Memoir Supabase project before deploying
2
+ -- the new cloud writer. No old backups are deleted or renumbered.
3
+ -- If duplicate (user_id, version) rows exist, the unique index fails; inspect
4
+ -- and reconcile those records before retrying this transactional migration.
5
+ begin;
6
+
7
+ alter table public.backups add column if not exists encryption_format text not null default 'legacy';
8
+ alter table public.backups add column if not exists source_backup_id uuid;
9
+
10
+ create unique index if not exists memoir_backups_user_version
11
+ on public.backups (user_id, version);
12
+
13
+ create table if not exists public.memoir_backup_counters (
14
+ user_id uuid primary key references auth.users(id) on delete cascade,
15
+ next_version bigint not null check (next_version > 0)
16
+ );
17
+ alter table public.memoir_backup_counters enable row level security;
18
+ revoke all on public.memoir_backup_counters from public, anon, authenticated;
19
+
20
+ create or replace function public.memoir_next_backup_version()
21
+ returns bigint
22
+ language plpgsql
23
+ security definer
24
+ set search_path = ''
25
+ as $$
26
+ declare
27
+ actor uuid := auth.uid();
28
+ allocated bigint;
29
+ begin
30
+ if actor is null then
31
+ raise exception 'Authentication required';
32
+ end if;
33
+
34
+ insert into public.memoir_backup_counters as counter (user_id, next_version)
35
+ select actor, coalesce(max(b.version), 0) + 1
36
+ from public.backups b where b.user_id = actor
37
+ on conflict (user_id) do update
38
+ set next_version = greatest(
39
+ counter.next_version + 1,
40
+ (select coalesce(max(b.version), 0) + 1 from public.backups b where b.user_id = actor)
41
+ )
42
+ returning next_version into allocated;
43
+
44
+ return allocated;
45
+ end;
46
+ $$;
47
+
48
+ revoke all on function public.memoir_next_backup_version() from public, anon;
49
+ grant execute on function public.memoir_next_backup_version() to authenticated;
50
+ commit;