@onpeek/nuvio 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +83 -0
- package/assets/nuvio-wordmark-dark.png +0 -0
- package/assets/nuvio-wordmark-light.png +0 -0
- package/dist/cli/confirmation.js +45 -0
- package/dist/cli/registry.js +14 -0
- package/dist/commands/account.js +66 -0
- package/dist/commands/addons.js +84 -0
- package/dist/commands/collections.js +147 -0
- package/dist/commands/helpers.js +186 -0
- package/dist/commands/index.js +26 -0
- package/dist/commands/library.js +105 -0
- package/dist/commands/plugins.js +58 -0
- package/dist/commands/profiles.js +126 -0
- package/dist/commands/providers.js +57 -0
- package/dist/commands/sessions.js +46 -0
- package/dist/commands/settings.js +43 -0
- package/dist/commands/trackers.js +84 -0
- package/dist/commands/undo.js +165 -0
- package/dist/config.js +60 -0
- package/dist/index.js +195 -0
- package/dist/mask.js +33 -0
- package/dist/nuvio/auth.js +123 -0
- package/dist/nuvio/call-context.js +20 -0
- package/dist/nuvio/client.js +149 -0
- package/dist/nuvio/errors.js +43 -0
- package/dist/nuvio/keys.js +23 -0
- package/dist/nuvio/ops/account.js +91 -0
- package/dist/nuvio/ops/addons.js +126 -0
- package/dist/nuvio/ops/collections.js +158 -0
- package/dist/nuvio/ops/library.js +139 -0
- package/dist/nuvio/ops/plan.js +728 -0
- package/dist/nuvio/ops/plugins.js +127 -0
- package/dist/nuvio/ops/profiles.js +383 -0
- package/dist/nuvio/ops/providers.js +103 -0
- package/dist/nuvio/ops/readers.js +57 -0
- package/dist/nuvio/ops/sessions.js +36 -0
- package/dist/nuvio/ops/settings.js +143 -0
- package/dist/nuvio/ops/trackers.js +80 -0
- package/dist/nuvio/ops/transitions.js +100 -0
- package/dist/nuvio/paths.js +74 -0
- package/dist/nuvio/safe-fetch.js +131 -0
- package/dist/nuvio/schemas.js +143 -0
- package/dist/nuvio/snapshots.js +664 -0
- package/dist/nuvio/types.js +1 -0
- package/dist/version.js +12 -0
- package/package.json +62 -0
- package/skills/nuvio/SKILL.md +19 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { callCache } from './call-context.js';
|
|
2
|
+
import { errorFromResponse, NuvioError } from './errors.js';
|
|
3
|
+
/** Transient statuses worth retrying (reads, and writes that opted into idempotency). */
|
|
4
|
+
const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
|
5
|
+
const MAX_RETRIES = 5;
|
|
6
|
+
const BASE_RETRY_DELAY_MS = 400;
|
|
7
|
+
const MAX_RETRY_DELAY_MS = 10_000;
|
|
8
|
+
const MAX_RETRY_AFTER_MS = 60_000;
|
|
9
|
+
function sleep(ms) {
|
|
10
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Pure retry-delay calculation (exported for tests).
|
|
14
|
+
* `Retry-After` always wins; otherwise equal jitter: half the exponential cap
|
|
15
|
+
* plus a bounded random half, so the delay stays within `[cap/2, cap]`.
|
|
16
|
+
*/
|
|
17
|
+
export function computeRetryDelayMs(res, attempt, rng = Math.random) {
|
|
18
|
+
const header = res?.headers.get('retry-after');
|
|
19
|
+
if (header) {
|
|
20
|
+
const seconds = Number(header);
|
|
21
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
22
|
+
return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS);
|
|
23
|
+
const date = Date.parse(header);
|
|
24
|
+
if (!Number.isNaN(date))
|
|
25
|
+
return Math.min(Math.max(date - Date.now(), 0), MAX_RETRY_AFTER_MS);
|
|
26
|
+
}
|
|
27
|
+
const cap = Math.min(BASE_RETRY_DELAY_MS * 2 ** attempt, MAX_RETRY_DELAY_MS);
|
|
28
|
+
const half = cap / 2;
|
|
29
|
+
return Math.floor(half + rng() * half);
|
|
30
|
+
}
|
|
31
|
+
function backoffMs(attempt) {
|
|
32
|
+
return computeRetryDelayMs(null, attempt);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Thin, typed wrapper over the Nuvio backend (Supabase: PostgREST + GoTrue).
|
|
36
|
+
*
|
|
37
|
+
* Retry policy:
|
|
38
|
+
* - reads (and explicitly idempotent requests): retry network errors, 408, 429,
|
|
39
|
+
* 500, 502, 503, 504 with exponential backoff + Retry-After;
|
|
40
|
+
* - non-idempotent writes: never retried automatically (a lost response must not
|
|
41
|
+
* silently double-apply a mutation);
|
|
42
|
+
* - 401: a single transparent token refresh, then one retry.
|
|
43
|
+
*/
|
|
44
|
+
export class NuvioClient {
|
|
45
|
+
cfg;
|
|
46
|
+
auth;
|
|
47
|
+
constructor(cfg, auth) {
|
|
48
|
+
this.cfg = cfg;
|
|
49
|
+
this.auth = auth;
|
|
50
|
+
}
|
|
51
|
+
async send(path, options, token) {
|
|
52
|
+
const url = `${this.cfg.backendUrl}${path}${options.query ? `?${options.query}` : ''}`;
|
|
53
|
+
const headers = {
|
|
54
|
+
apikey: this.cfg.publishableKey,
|
|
55
|
+
Authorization: `Bearer ${token}`,
|
|
56
|
+
};
|
|
57
|
+
if (options.body !== undefined)
|
|
58
|
+
headers['Content-Type'] = 'application/json';
|
|
59
|
+
if (options.requestId)
|
|
60
|
+
headers['Idempotency-Key'] = options.requestId;
|
|
61
|
+
return fetch(url, {
|
|
62
|
+
method: options.method ?? 'GET',
|
|
63
|
+
headers,
|
|
64
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
65
|
+
signal: AbortSignal.timeout(this.cfg.backendTimeoutMs),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
async sendWithRetry(path, options, token) {
|
|
69
|
+
const method = options.method ?? 'GET';
|
|
70
|
+
// Retry is driven purely by the operation's intrinsic idempotency. An
|
|
71
|
+
// Idempotency-Key is never sufficient on its own: the backend does not dedupe.
|
|
72
|
+
const idempotent = options.idempotent ?? method === 'GET';
|
|
73
|
+
let attempt = 0;
|
|
74
|
+
for (;;) {
|
|
75
|
+
let res;
|
|
76
|
+
try {
|
|
77
|
+
res = await this.send(path, options, token);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (idempotent && !options.noRetry && attempt < MAX_RETRIES) {
|
|
81
|
+
await sleep(backoffMs(attempt));
|
|
82
|
+
attempt += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
if (idempotent && !options.noRetry && RETRY_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
|
|
88
|
+
await sleep(computeRetryDelayMs(res, attempt));
|
|
89
|
+
attempt += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
return res;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async request(path, options = {}) {
|
|
96
|
+
if (!options.cache)
|
|
97
|
+
return this.execute(path, options);
|
|
98
|
+
const method = options.method ?? 'GET';
|
|
99
|
+
const key = `${method} ${path}${options.query ? `?${options.query}` : ''}` +
|
|
100
|
+
`${options.body === undefined ? '' : ` ${JSON.stringify(options.body)}`}`;
|
|
101
|
+
return callCache(key, () => this.execute(path, options));
|
|
102
|
+
}
|
|
103
|
+
async execute(path, options) {
|
|
104
|
+
let token = await this.auth.getAccessToken();
|
|
105
|
+
let res = await this.sendWithRetry(path, options, token);
|
|
106
|
+
if (res.status === 401 && !options.noRetry) {
|
|
107
|
+
await this.auth.forceRefresh();
|
|
108
|
+
token = await this.auth.getAccessToken();
|
|
109
|
+
res = await this.sendWithRetry(path, options, token);
|
|
110
|
+
}
|
|
111
|
+
if (res.status === 204)
|
|
112
|
+
return undefined;
|
|
113
|
+
const text = await res.text();
|
|
114
|
+
const json = text ? safeJson(text) : null;
|
|
115
|
+
if (!res.ok)
|
|
116
|
+
throw errorFromResponse(res.status, json ?? text);
|
|
117
|
+
return json;
|
|
118
|
+
}
|
|
119
|
+
/** Call a PostgREST RPC function (a write unless the caller opts into read semantics). */
|
|
120
|
+
async rpc(name, args = {}, options = {}) {
|
|
121
|
+
return this.request(`/rest/v1/rpc/${name}`, { method: 'POST', body: args, ...options });
|
|
122
|
+
}
|
|
123
|
+
/** A read-only RPC: cached per call and retried safely when transient. */
|
|
124
|
+
async readRpc(name, args = {}) {
|
|
125
|
+
return this.rpc(name, args, { cache: true, idempotent: true });
|
|
126
|
+
}
|
|
127
|
+
/** Query a PostgREST table. Reads are cached and retried by default. */
|
|
128
|
+
async select(table, params = 'select=*') {
|
|
129
|
+
return this.request(`/rest/v1/${table}`, { query: params, cache: true, idempotent: true });
|
|
130
|
+
}
|
|
131
|
+
get backendUrl() {
|
|
132
|
+
return this.cfg.backendUrl;
|
|
133
|
+
}
|
|
134
|
+
get currentUserId() {
|
|
135
|
+
return this.auth.userId;
|
|
136
|
+
}
|
|
137
|
+
get currentEmail() {
|
|
138
|
+
return this.auth.email;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function safeJson(text) {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(text);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export { NuvioError };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Error thrown for any non-2xx response from the Nuvio / Supabase API. */
|
|
2
|
+
export class NuvioError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
details;
|
|
6
|
+
hint;
|
|
7
|
+
constructor(message, options = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'NuvioError';
|
|
10
|
+
this.status = options.status;
|
|
11
|
+
this.code = options.code;
|
|
12
|
+
this.details = options.details;
|
|
13
|
+
this.hint = options.hint;
|
|
14
|
+
}
|
|
15
|
+
toHuman() {
|
|
16
|
+
const parts = [this.message];
|
|
17
|
+
if (this.code)
|
|
18
|
+
parts.push(`(code: ${this.code})`);
|
|
19
|
+
if (this.status)
|
|
20
|
+
parts.push(`[http ${this.status}]`);
|
|
21
|
+
if (this.hint)
|
|
22
|
+
parts.push(`hint: ${this.hint}`);
|
|
23
|
+
return parts.join(' ');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Parse an error body from PostgREST / GoTrue. */
|
|
27
|
+
export function errorFromResponse(status, body) {
|
|
28
|
+
if (body && typeof body === 'object') {
|
|
29
|
+
const b = body;
|
|
30
|
+
const message = (typeof b.message === 'string' && b.message) ||
|
|
31
|
+
(typeof b.msg === 'string' && b.msg) ||
|
|
32
|
+
(typeof b.error_description === 'string' && b.error_description) ||
|
|
33
|
+
(typeof b.error === 'string' && b.error) ||
|
|
34
|
+
`Request failed with status ${status}`;
|
|
35
|
+
return new NuvioError(message, {
|
|
36
|
+
status,
|
|
37
|
+
code: typeof b.code === 'string' ? b.code : typeof b.error === 'string' ? b.error : undefined,
|
|
38
|
+
details: b.details,
|
|
39
|
+
hint: typeof b.hint === 'string' ? b.hint : null,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return new NuvioError(`Request failed with status ${status}`, { status });
|
|
43
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for resource keys. Direct ops, plan planners and
|
|
3
|
+
* snapshots must all agree, otherwise undo/redo would target different records.
|
|
4
|
+
*/
|
|
5
|
+
/** e.g. `tt1` for a movie, `tt1_s2e5` for an episode (episode defaults to 0). */
|
|
6
|
+
export function progressKeyOf(entry) {
|
|
7
|
+
return entry.season != null
|
|
8
|
+
? `${entry.content_id}_s${entry.season}e${entry.episode ?? 0}`
|
|
9
|
+
: entry.content_id;
|
|
10
|
+
}
|
|
11
|
+
/** e.g. `tt1|2|5`; missing season/episode are encoded as -1. */
|
|
12
|
+
export function historyKeyOf(item) {
|
|
13
|
+
return `${item.content_id}|${item.season ?? -1}|${item.episode ?? -1}`;
|
|
14
|
+
}
|
|
15
|
+
export function libraryKeyOf(item) {
|
|
16
|
+
return `${item.content_type}:${item.content_id}`;
|
|
17
|
+
}
|
|
18
|
+
/** The stored progress key when the backend provides one, else the derived key. */
|
|
19
|
+
export function storedProgressKey(item) {
|
|
20
|
+
if (typeof item.progress_key === 'string' && item.progress_key)
|
|
21
|
+
return item.progress_key;
|
|
22
|
+
return progressKeyOf(item);
|
|
23
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export async function getAccount(client) {
|
|
2
|
+
return client.request('/auth/v1/user');
|
|
3
|
+
}
|
|
4
|
+
export async function getSyncOverview(client) {
|
|
5
|
+
return client.readRpc('get_sync_overview', {});
|
|
6
|
+
}
|
|
7
|
+
export async function listAvatars(client) {
|
|
8
|
+
return client.readRpc('get_avatar_catalog', {});
|
|
9
|
+
}
|
|
10
|
+
const BACKUP_METADATA_KEYS = new Set(['version', 'exported_at', 'exportedAt', 'generated_at']);
|
|
11
|
+
/**
|
|
12
|
+
* Best-effort check that the backend actually narrowed the export. Verification
|
|
13
|
+
* is positive-only: the response must contain at least one requested section and
|
|
14
|
+
* nothing outside it, and selected profiles/platforms must be a subset. Anything
|
|
15
|
+
* that cannot be confirmed counts as unverified (so we never assume a scoped
|
|
16
|
+
* export when the backend may have ignored the arguments).
|
|
17
|
+
*/
|
|
18
|
+
function verifyScopeApplied(backup, options) {
|
|
19
|
+
if (!backup || typeof backup !== 'object' || Array.isArray(backup))
|
|
20
|
+
return false;
|
|
21
|
+
const record = backup;
|
|
22
|
+
const keys = Object.keys(record).filter((k) => !BACKUP_METADATA_KEYS.has(k));
|
|
23
|
+
if (options.scope?.length) {
|
|
24
|
+
const allowed = new Set(options.scope);
|
|
25
|
+
const requested = keys.filter((k) => allowed.has(k));
|
|
26
|
+
const foreign = keys.filter((k) => !allowed.has(k));
|
|
27
|
+
if (requested.length === 0 || foreign.length > 0)
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (options.profile_ids?.length) {
|
|
31
|
+
const profiles = record.profiles;
|
|
32
|
+
if (!Array.isArray(profiles))
|
|
33
|
+
return false;
|
|
34
|
+
const wanted = new Set(options.profile_ids);
|
|
35
|
+
const ids = profiles.map((p) => p.profile_index ??
|
|
36
|
+
p.profile_id);
|
|
37
|
+
if (ids.some((id) => typeof id !== 'number' || !wanted.has(id)))
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (options.platforms?.length) {
|
|
41
|
+
const settings = record.settings;
|
|
42
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings))
|
|
43
|
+
return false;
|
|
44
|
+
const wanted = new Set(options.platforms);
|
|
45
|
+
if (Object.keys(settings).some((p) => !wanted.has(p)))
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Export an account backup. With no scope this is a full backup; passing scope
|
|
52
|
+
* entries, profile ids or platforms asks the backend to narrow the export.
|
|
53
|
+
*
|
|
54
|
+
* If scope was requested but the response cannot positively confirm it, the
|
|
55
|
+
* result carries `scope_verified: false` and an explicit warning — there is no
|
|
56
|
+
* silent fallback to treating an unscoped backup as scoped.
|
|
57
|
+
*/
|
|
58
|
+
export async function exportBackup(client, options = {}) {
|
|
59
|
+
const requested = Boolean(options.scope?.length || options.profile_ids?.length || options.platforms?.length);
|
|
60
|
+
// Backup export is a read: use the cached, retry-safe read path.
|
|
61
|
+
if (!requested)
|
|
62
|
+
return client.readRpc('sync_export_account_backup', {});
|
|
63
|
+
const args = {};
|
|
64
|
+
if (options.scope?.length)
|
|
65
|
+
args.p_scope = options.scope;
|
|
66
|
+
if (options.profile_ids?.length)
|
|
67
|
+
args.p_profile_ids = options.profile_ids;
|
|
68
|
+
if (options.platforms?.length)
|
|
69
|
+
args.p_platforms = options.platforms;
|
|
70
|
+
const backup = await client.readRpc('sync_export_account_backup', args);
|
|
71
|
+
if (verifyScopeApplied(backup, options)) {
|
|
72
|
+
return { backup, scope_requested: options, scope_verified: true };
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
backup,
|
|
76
|
+
scope_requested: options,
|
|
77
|
+
scope_verified: false,
|
|
78
|
+
warning: 'The backend did not confirm scope/profile_ids/platforms for this export. The returned data may be a ' +
|
|
79
|
+
'FULL backup — do not treat it as scoped. Verify backend support before relying on it.',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export async function health(client) {
|
|
83
|
+
return client.readRpc('health_ping', {});
|
|
84
|
+
}
|
|
85
|
+
export async function restoreBackup(client, backup, apply) {
|
|
86
|
+
const diff = ['~ restore account backup (replace mode) — this overwrites profiles/addons/library/settings'];
|
|
87
|
+
if (apply) {
|
|
88
|
+
await client.rpc('sync_restore_account_backup', { p_backup: backup, p_mode: 'replace' });
|
|
89
|
+
}
|
|
90
|
+
return { applied: apply, changed: true, before: {}, after: {}, diff };
|
|
91
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { NuvioError } from '../errors.js';
|
|
2
|
+
export async function listAddons(client, profileId) {
|
|
3
|
+
return client.select('addons', `select=id,user_id,profile_id,url,name,enabled,sort_order,created_at,updated_at` +
|
|
4
|
+
`&profile_id=eq.${profileId}&order=sort_order.asc,created_at.asc`);
|
|
5
|
+
}
|
|
6
|
+
export function toPushShape(addons) {
|
|
7
|
+
return addons.map((a) => ({
|
|
8
|
+
url: a.url,
|
|
9
|
+
name: a.name,
|
|
10
|
+
enabled: a.enabled,
|
|
11
|
+
sort_order: a.sort_order,
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
function assertHttpUrl(url) {
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = new URL(url);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new NuvioError(`Invalid addon URL: ${url}`);
|
|
21
|
+
}
|
|
22
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
23
|
+
throw new NuvioError(`Addon URL must be http(s): ${url}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function diffAddons(before, after) {
|
|
27
|
+
const diff = [];
|
|
28
|
+
const beforeByUrl = new Map(before.map((a) => [a.url, a]));
|
|
29
|
+
const afterByUrl = new Map(after.map((a) => [a.url, a]));
|
|
30
|
+
for (const [url, a] of afterByUrl) {
|
|
31
|
+
const prev = beforeByUrl.get(url);
|
|
32
|
+
if (!prev)
|
|
33
|
+
diff.push(`+ add ${url} (enabled=${a.enabled}, order=${a.sort_order})`);
|
|
34
|
+
else {
|
|
35
|
+
if (prev.enabled !== a.enabled)
|
|
36
|
+
diff.push(`~ ${url} enabled ${prev.enabled} -> ${a.enabled}`);
|
|
37
|
+
if (prev.name !== a.name)
|
|
38
|
+
diff.push(`~ ${url} name ${prev.name ?? 'null'} -> ${a.name ?? 'null'}`);
|
|
39
|
+
if (prev.sort_order !== a.sort_order)
|
|
40
|
+
diff.push(`~ ${url} order ${prev.sort_order} -> ${a.sort_order}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const url of beforeByUrl.keys()) {
|
|
44
|
+
if (!afterByUrl.has(url))
|
|
45
|
+
diff.push(`- remove ${url}`);
|
|
46
|
+
}
|
|
47
|
+
return diff.sort();
|
|
48
|
+
}
|
|
49
|
+
function nextSort(before) {
|
|
50
|
+
return before.reduce((max, a) => Math.max(max, a.sort_order + 1), 0);
|
|
51
|
+
}
|
|
52
|
+
async function commit(client, profileId, originId, before, after, apply) {
|
|
53
|
+
const diff = diffAddons(before, after);
|
|
54
|
+
const changed = diff.length > 0;
|
|
55
|
+
if (apply && changed) {
|
|
56
|
+
await client.rpc('sync_push_addons', {
|
|
57
|
+
p_profile_id: profileId,
|
|
58
|
+
p_addons: after,
|
|
59
|
+
p_origin_client_id: originId,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return { applied: apply && changed, changed, before, after, diff };
|
|
63
|
+
}
|
|
64
|
+
export async function addAddon(client, profileId, input, originId, apply) {
|
|
65
|
+
assertHttpUrl(input.url);
|
|
66
|
+
const before = toPushShape(await listAddons(client, profileId));
|
|
67
|
+
if (before.some((a) => a.url === input.url)) {
|
|
68
|
+
throw new NuvioError(`This addon is already installed on profile ${profileId}: ${input.url}`);
|
|
69
|
+
}
|
|
70
|
+
const after = [
|
|
71
|
+
...before,
|
|
72
|
+
{
|
|
73
|
+
url: input.url,
|
|
74
|
+
name: input.name ?? null,
|
|
75
|
+
enabled: input.enabled ?? true,
|
|
76
|
+
sort_order: input.sort_order ?? nextSort(before),
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
// Invariant: exactly one new item, nothing else removed or altered.
|
|
80
|
+
if (after.length !== before.length + 1)
|
|
81
|
+
throw new NuvioError('Internal invariant failed (addon add)');
|
|
82
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
83
|
+
}
|
|
84
|
+
export async function updateAddon(client, profileId, match, changes, originId, apply) {
|
|
85
|
+
const rows = await listAddons(client, profileId);
|
|
86
|
+
const target = rows.find((a) => (match.url ? a.url === match.url : a.id === match.id));
|
|
87
|
+
if (!target)
|
|
88
|
+
throw new NuvioError(`Addon not found on profile ${profileId}`);
|
|
89
|
+
const before = toPushShape(rows);
|
|
90
|
+
const after = before.map((a) => a.url === target.url
|
|
91
|
+
? {
|
|
92
|
+
url: a.url,
|
|
93
|
+
name: changes.name !== undefined ? changes.name : a.name,
|
|
94
|
+
enabled: changes.enabled !== undefined ? changes.enabled : a.enabled,
|
|
95
|
+
sort_order: changes.sort_order !== undefined ? changes.sort_order : a.sort_order,
|
|
96
|
+
}
|
|
97
|
+
: a);
|
|
98
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
99
|
+
}
|
|
100
|
+
export async function removeAddon(client, profileId, match, originId, apply) {
|
|
101
|
+
const rows = await listAddons(client, profileId);
|
|
102
|
+
const target = rows.find((a) => (match.url ? a.url === match.url : a.id === match.id));
|
|
103
|
+
if (!target)
|
|
104
|
+
throw new NuvioError(`Addon not found on profile ${profileId}`);
|
|
105
|
+
const before = toPushShape(rows);
|
|
106
|
+
const after = before.filter((a) => a.url !== target.url);
|
|
107
|
+
if (after.length !== before.length - 1)
|
|
108
|
+
throw new NuvioError('Internal invariant failed (addon remove)');
|
|
109
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
110
|
+
}
|
|
111
|
+
export async function reorderAddons(client, profileId, orderedUrls, originId, apply) {
|
|
112
|
+
const rows = await listAddons(client, profileId);
|
|
113
|
+
const before = toPushShape(rows);
|
|
114
|
+
const beforeUrls = before.map((a) => a.url).sort();
|
|
115
|
+
const wanted = [...orderedUrls].sort();
|
|
116
|
+
if (beforeUrls.length !== wanted.length || beforeUrls.some((u, i) => u !== wanted[i])) {
|
|
117
|
+
throw new NuvioError('Reorder must list every installed addon exactly once (use list_addons to get the current set).');
|
|
118
|
+
}
|
|
119
|
+
const after = orderedUrls.map((url, index) => {
|
|
120
|
+
const base = before.find((a) => a.url === url);
|
|
121
|
+
return { ...base, sort_order: index };
|
|
122
|
+
});
|
|
123
|
+
if (after.length !== before.length)
|
|
124
|
+
throw new NuvioError('Internal invariant failed (addon reorder)');
|
|
125
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
126
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { NuvioError } from '../errors.js';
|
|
2
|
+
export async function getCollections(client, profileId) {
|
|
3
|
+
const rows = await client.readRpc('sync_pull_collections', { p_profile_id: profileId });
|
|
4
|
+
return rows[0]?.collections_json ?? [];
|
|
5
|
+
}
|
|
6
|
+
function assertUniqueIds(collections) {
|
|
7
|
+
const ids = new Set();
|
|
8
|
+
for (const c of collections) {
|
|
9
|
+
if (!c.id)
|
|
10
|
+
throw new NuvioError('Every collection needs an id');
|
|
11
|
+
if (ids.has(c.id))
|
|
12
|
+
throw new NuvioError(`Duplicate collection id: ${c.id}`);
|
|
13
|
+
ids.add(c.id);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function canonical(value) {
|
|
17
|
+
if (Array.isArray(value))
|
|
18
|
+
return value.map(canonical);
|
|
19
|
+
if (value && typeof value === 'object') {
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const key of Object.keys(value).sort()) {
|
|
22
|
+
out[key] = canonical(value[key]);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function diffCollections(before, after) {
|
|
29
|
+
const b = new Map(before.map((c) => [c.id, c]));
|
|
30
|
+
const a = new Map(after.map((c) => [c.id, c]));
|
|
31
|
+
const diff = [];
|
|
32
|
+
for (const [id, c] of a) {
|
|
33
|
+
const prev = b.get(id);
|
|
34
|
+
if (!prev)
|
|
35
|
+
diff.push(`+ collection "${c.title}" (${id})`);
|
|
36
|
+
else if (JSON.stringify(canonical(prev)) !== JSON.stringify(canonical(c))) {
|
|
37
|
+
diff.push(`~ collection ${id} updated`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const id of b.keys())
|
|
41
|
+
if (!a.has(id))
|
|
42
|
+
diff.push(`- collection ${id}`);
|
|
43
|
+
if (before.map((c) => c.id).join('|') !== after.map((c) => c.id).join('|')) {
|
|
44
|
+
diff.push('~ collection order changed');
|
|
45
|
+
}
|
|
46
|
+
return diff.sort();
|
|
47
|
+
}
|
|
48
|
+
async function commit(client, profileId, originId, before, after, apply) {
|
|
49
|
+
const diff = diffCollections(before, after);
|
|
50
|
+
if (apply && diff.length > 0) {
|
|
51
|
+
await client.rpc('sync_push_collections', {
|
|
52
|
+
p_profile_id: profileId,
|
|
53
|
+
p_collections_json: after,
|
|
54
|
+
p_origin_client_id: originId,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return { applied: apply && diff.length > 0, changed: diff.length > 0, before, after, diff };
|
|
58
|
+
}
|
|
59
|
+
export async function createCollection(client, profileId, collection, originId, apply) {
|
|
60
|
+
const before = await getCollections(client, profileId);
|
|
61
|
+
if (before.some((c) => c.id === collection.id)) {
|
|
62
|
+
throw new NuvioError(`A collection with id "${collection.id}" already exists`);
|
|
63
|
+
}
|
|
64
|
+
const after = [...before, collection];
|
|
65
|
+
assertUniqueIds(after);
|
|
66
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
67
|
+
}
|
|
68
|
+
export async function updateCollection(client, profileId, collectionId, changes, originId, apply) {
|
|
69
|
+
const before = await getCollections(client, profileId);
|
|
70
|
+
const target = before.find((c) => c.id === collectionId);
|
|
71
|
+
if (!target)
|
|
72
|
+
throw new NuvioError(`Collection "${collectionId}" not found`);
|
|
73
|
+
const after = before.map((c) => (c.id === collectionId ? { ...c, ...changes } : c));
|
|
74
|
+
assertUniqueIds(after);
|
|
75
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
76
|
+
}
|
|
77
|
+
export async function deleteCollection(client, profileId, collectionId, originId, apply) {
|
|
78
|
+
const before = await getCollections(client, profileId);
|
|
79
|
+
if (!before.some((c) => c.id === collectionId))
|
|
80
|
+
throw new NuvioError(`Collection "${collectionId}" not found`);
|
|
81
|
+
const after = before.filter((c) => c.id !== collectionId);
|
|
82
|
+
if (after.length !== before.length - 1)
|
|
83
|
+
throw new NuvioError('Internal invariant failed (collection delete)');
|
|
84
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
85
|
+
}
|
|
86
|
+
export async function addFolder(client, profileId, collectionId, folder, originId, apply) {
|
|
87
|
+
const before = await getCollections(client, profileId);
|
|
88
|
+
const target = before.find((c) => c.id === collectionId);
|
|
89
|
+
if (!target)
|
|
90
|
+
throw new NuvioError(`Collection "${collectionId}" not found`);
|
|
91
|
+
if (target.folders.some((f) => f.id === folder.id))
|
|
92
|
+
throw new NuvioError(`Folder "${folder.id}" already exists in collection "${collectionId}"`);
|
|
93
|
+
const after = before.map((c) => (c.id === collectionId ? { ...c, folders: [...c.folders, folder] } : c));
|
|
94
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
95
|
+
}
|
|
96
|
+
export async function updateFolder(client, profileId, collectionId, folderId, changes, originId, apply) {
|
|
97
|
+
const before = await getCollections(client, profileId);
|
|
98
|
+
const target = before.find((c) => c.id === collectionId);
|
|
99
|
+
if (!target)
|
|
100
|
+
throw new NuvioError(`Collection "${collectionId}" not found`);
|
|
101
|
+
if (!target.folders.some((f) => f.id === folderId))
|
|
102
|
+
throw new NuvioError(`Folder "${folderId}" not found in collection "${collectionId}"`);
|
|
103
|
+
const after = before.map((c) => c.id === collectionId
|
|
104
|
+
? { ...c, folders: c.folders.map((f) => (f.id === folderId ? { ...f, ...changes } : f)) }
|
|
105
|
+
: c);
|
|
106
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
107
|
+
}
|
|
108
|
+
export async function removeFolder(client, profileId, collectionId, folderId, originId, apply) {
|
|
109
|
+
const before = await getCollections(client, profileId);
|
|
110
|
+
const target = before.find((c) => c.id === collectionId);
|
|
111
|
+
if (!target)
|
|
112
|
+
throw new NuvioError(`Collection "${collectionId}" not found`);
|
|
113
|
+
const nextFolders = target.folders.filter((f) => f.id !== folderId);
|
|
114
|
+
if (nextFolders.length !== target.folders.length - 1)
|
|
115
|
+
throw new NuvioError(`Folder "${folderId}" not found in collection "${collectionId}"`);
|
|
116
|
+
const after = before.map((c) => (c.id === collectionId ? { ...c, folders: nextFolders } : c));
|
|
117
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
118
|
+
}
|
|
119
|
+
export async function reorderCollections(client, profileId, orderedIds, originId, apply) {
|
|
120
|
+
const before = await getCollections(client, profileId);
|
|
121
|
+
const current = before.map((c) => c.id).sort();
|
|
122
|
+
const wanted = [...orderedIds].sort();
|
|
123
|
+
if (current.length !== wanted.length || current.some((id, i) => id !== wanted[i])) {
|
|
124
|
+
throw new NuvioError('Reorder must list every collection id exactly once.');
|
|
125
|
+
}
|
|
126
|
+
const after = orderedIds.map((id) => before.find((c) => c.id === id));
|
|
127
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
128
|
+
}
|
|
129
|
+
export async function duplicateCollection(client, profileId, collectionId, newId, newTitle, originId, apply) {
|
|
130
|
+
const before = await getCollections(client, profileId);
|
|
131
|
+
const source = before.find((c) => c.id === collectionId);
|
|
132
|
+
if (!source)
|
|
133
|
+
throw new NuvioError(`Collection "${collectionId}" not found`);
|
|
134
|
+
if (before.some((c) => c.id === newId))
|
|
135
|
+
throw new NuvioError(`Collection "${newId}" already exists`);
|
|
136
|
+
const copy = structuredClone(source);
|
|
137
|
+
copy.id = newId;
|
|
138
|
+
copy.title = newTitle ?? `${source.title} (copy)`;
|
|
139
|
+
copy.folders = copy.folders.map((f) => ({ ...f, id: `${newId}-${f.id}` }));
|
|
140
|
+
const index = before.findIndex((c) => c.id === collectionId);
|
|
141
|
+
const after = [...before.slice(0, index + 1), copy, ...before.slice(index + 1)];
|
|
142
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
143
|
+
}
|
|
144
|
+
export async function reorderCollectionFolders(client, profileId, collectionId, orderedFolderIds, originId, apply) {
|
|
145
|
+
const before = await getCollections(client, profileId);
|
|
146
|
+
const target = before.find((c) => c.id === collectionId);
|
|
147
|
+
if (!target)
|
|
148
|
+
throw new NuvioError(`Collection "${collectionId}" not found`);
|
|
149
|
+
const current = target.folders.map((f) => f.id).sort();
|
|
150
|
+
const wanted = [...orderedFolderIds].sort();
|
|
151
|
+
if (current.length !== wanted.length || current.some((id, i) => id !== wanted[i])) {
|
|
152
|
+
throw new NuvioError('Reorder must list every folder id exactly once.');
|
|
153
|
+
}
|
|
154
|
+
const after = before.map((c) => c.id === collectionId
|
|
155
|
+
? { ...c, folders: orderedFolderIds.map((id) => target.folders.find((f) => f.id === id)) }
|
|
156
|
+
: c);
|
|
157
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
158
|
+
}
|