Skip to content

Commit 7960873

Browse files
committed
feat(snapshots): add versioned backups support
1 parent e5eb903 commit 7960873

10 files changed

Lines changed: 431 additions & 1 deletion

File tree

‎.prettierrc‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"tabWidth": 2,
3+
"useTabs": false,
4+
"semi": true,
5+
"singleQuote": false
6+
}

‎README.md‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ yarn add discloud.app
3737
[How to manage custom domains](#how-to-manage-custom-domains)
3838
[How to manage subdomains](#how-to-manage-subdomains)
3939
[How to manage shared/team applications](#how-to-manage-sharedteam-applications)
40+
[How to create snapshots](#how-to-create-snapshots)
4041

4142
```js
4243
// index.js
@@ -329,4 +330,36 @@ await discloud.sharedApps.stop("APP_ID"); // Promise<void>
329330
const status = await discloud.sharedApps.status.fetch("APP_ID"); // Promise<SharedAppStatus>
330331
```
331332

332-
> **Note:** In v1.x, use `discloud.teamApps` instead of `discloud.sharedApps`. See the [Migration Guide](./MIGRATION.md) for more details.
333+
### How to create snapshots
334+
```js
335+
const { discloud } = require("discloud.app");
336+
337+
// Create a new snapshot
338+
await discloud.snapshots.create("APP_ID");
339+
340+
// List snapshots for the app
341+
await discloud.snapshots.fetch("APP_ID");
342+
343+
// Fetch all snapshots
344+
await discloud.snapshots.fetch();
345+
```
346+
347+
### How to download the snapshot
348+
```js
349+
const { discloud } = require("discloud.app");
350+
351+
// List the snapshots
352+
const snapshots = await discloud.snapshots.fetch("APP_ID");
353+
354+
// Get the most recent snapshot (last of the Map)
355+
const snap = [...snapshots.values()].pop();
356+
357+
// Download the snapshot
358+
await snap.download("./backups");
359+
360+
- If you want a specific one by version (YYYYMMDD-HHMMSS format):
361+
const snapshots = await discloud.snapshots.fetch("APP_ID");
362+
const snap = snapshots.get("APP_ID-20260420-153000"); // appId-version
363+
```
364+
365+
> **Note:** In v1.x, use `discloud.teamApps` instead of `discloud.sharedApps`. See the [Migration Guide](./MIGRATION.md) for more details.

‎packages/api-types/rest/v2/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from "./base";
33
export * from "./customdomain";
44
export * from "./locale";
55
export * from "./routes";
6+
export * from "./snapshot"
67
export * from "./subdomain";
78
export * from "./team";
89
export * from "./upload";
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { type BaseApiApp } from "./app";
2+
import { type RESTApiBaseResult } from "./base";
3+
4+
export type ApiSnapshotApp = BaseApiApp
5+
6+
export interface ApiSnapshotVersion {
7+
version: string
8+
size: number | string
9+
date: number | string
10+
}
11+
12+
export interface ApiSnapshotDownload {
13+
url: string
14+
expiresAt: string
15+
version: string
16+
size: number
17+
}
18+
19+
export interface ApiSnapshotCreated {
20+
version: string
21+
size: string
22+
url: string
23+
allVersions: ApiSnapshotVersion[]
24+
}
25+
26+
export interface ApiSnapshotListItem extends ApiSnapshotVersion {
27+
appID: string
28+
}
29+
30+
export interface RESTGetApiSnapshotListResult extends RESTApiBaseResult {
31+
page: number
32+
limit: number
33+
total: number
34+
backups: ApiSnapshotListItem[]
35+
}
36+
37+
export interface RESTGetApiSnapshotResult extends RESTApiBaseResult {
38+
app: ApiSnapshotApp
39+
versions: ApiSnapshotVersion[]
40+
}
41+
42+
export interface RESTGetApiSnapshotVersionResult extends RESTApiBaseResult {
43+
app: ApiSnapshotApp
44+
download: ApiSnapshotDownload
45+
}
46+
47+
export interface RESTPostApiSnapshotResult extends RESTApiBaseResult {
48+
app: ApiSnapshotApp
49+
snapshot: ApiSnapshotCreated
50+
}

‎packages/discloud.app/src/discloudApp/DiscloudApp.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import SubdomainsManager from "../managers/SubdomainsManager";
99
import User from "../structures/User";
1010
import { DefaultDiscloudAppOptions } from "../util/constants";
1111
import Deprecation from "../util/deprecation";
12+
import SnapshotsManager from "../managers/SnapshotsManager";
1213

1314
const appAptDeprecation = new Deprecation("The appApt property is deprecated. Use apps.apts instead.");
1415
const appTeamDeprecation = new Deprecation("The appTeam property is deprecated. Use apps.moderators instead.");
@@ -20,6 +21,7 @@ export default class DiscloudApp extends EventEmitter<ClientEvents> {
2021
readonly apps = new AppsManager(this);
2122
readonly sharedApps = new SharedAppsManager(this);
2223
readonly customdomains = new CustomdomainsManager(this);
24+
readonly snapshots = new SnapshotsManager(this)
2325
readonly subdomains = new SubdomainsManager(this);
2426
readonly user = new User(this);
2527

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { type ApiSnapshotVersion } from "@discloudapp/api-types/v2";
2+
import { type Instanciable } from "../@types";
3+
import type DiscloudApp from "../discloudApp/DiscloudApp";
4+
import type BaseSnapshot from "../structures/BaseSnapshot";
5+
import CachedManager from "./CachedManager";
6+
7+
export type PartialApiSnapshot = Partial<ApiSnapshotVersion> & { appId: string, version: string };
8+
9+
export default abstract class BaseSnapshotsManager<T extends Instanciable<typeof BaseSnapshot>> extends CachedManager<string, T> {
10+
constructor(discloudApp: DiscloudApp, holds: T) {
11+
super(discloudApp, holds);
12+
}
13+
14+
protected _key(appId: string, version: string): string {
15+
return `${appId}-${version}`;
16+
}
17+
18+
protected _add(data: PartialApiSnapshot): InstanceType<T> {
19+
const key = this._key(data.appId, data.version);
20+
21+
const existing = this._patch(key, data);
22+
if (existing) return existing;
23+
24+
const entry = new this.holds(this.discloudApp, data.appId, data) as InstanceType<T>;
25+
26+
this._cache.set(key, entry);
27+
return entry;
28+
}
29+
30+
protected _addMany(data: PartialApiSnapshot[]): Map<string, InstanceType<T>> {
31+
const cache = new Map<string, InstanceType<T>>();
32+
33+
for (const element of data) {
34+
const obj = this._add(element);
35+
cache.set(this._key(element.appId, obj.version), obj);
36+
}
37+
38+
return cache;
39+
}
40+
41+
protected _clear(data?: PartialApiSnapshot[]): void {
42+
if (!data?.length) return this._cache.clear();
43+
44+
const mapped = new Set(data.map((v) => this._key(v.appId, v.version)));
45+
46+
for (const key of this._cache.keys()) {
47+
if (!mapped.has(key)) this._delete(key);
48+
}
49+
}
50+
51+
protected _delete(key: string): boolean {
52+
return this._cache.delete(key);
53+
}
54+
55+
protected _deleteMany(keys: string[]) {
56+
for (const key of keys) this._delete(key);
57+
}
58+
59+
protected _patch(key: string, data: Partial<ApiSnapshotVersion>): InstanceType<T> | undefined {
60+
// @ts-expect-error ts(2445)
61+
return this._cache.get(key)?._patch(data);
62+
}
63+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import {
2+
type ApiSnapshotDownload,
3+
type RESTGetApiSnapshotListResult,
4+
type RESTGetApiSnapshotResult,
5+
type RESTGetApiSnapshotVersionResult,
6+
type RESTPostApiSnapshotResult,
7+
Routes,
8+
} from "@discloudapp/api-types/v2";
9+
import { DiscloudAPIError } from "@discloudapp/rest";
10+
import { constants } from "http2";
11+
import type DiscloudApp from "../discloudApp/DiscloudApp";
12+
import Snapshot from "../structures/Snapshot";
13+
import { validateNonEmptyString } from "../util/assertions";
14+
import BaseSnapshotsManager from "./BaseSnapshotsManager";
15+
16+
export interface FetchSnapshotsOptions {
17+
page?: number;
18+
limit?: number;
19+
summary?: boolean;
20+
}
21+
22+
/**
23+
* Manager for versioned snapshots on Discloud
24+
*/
25+
export default class SnapshotsManager extends BaseSnapshotsManager<
26+
typeof Snapshot
27+
> {
28+
constructor(discloudApp: DiscloudApp) {
29+
super(discloudApp, Snapshot);
30+
}
31+
32+
/**
33+
* Create a new versioned snapshot for an app
34+
*
35+
* @throws `400`
36+
* @throws `403`
37+
* @throws `404` not found
38+
* @throws `409` app busy
39+
*
40+
* @param appID - Your app id
41+
*/
42+
async create(appID: string) {
43+
validateNonEmptyString(appID);
44+
45+
const data = await this.discloudApp.rest.post<RESTPostApiSnapshotResult>(
46+
Routes.snapshot(appID),
47+
);
48+
49+
return this._add({
50+
appId: appID,
51+
version: data.snapshot.version,
52+
size: data.snapshot.size,
53+
});
54+
}
55+
56+
/**
57+
* Generate a temporary download url for a specific snapshot version
58+
*
59+
* @throws `400`
60+
* @throws `404` not found
61+
*
62+
* @param appID - Your app id
63+
* @param version - Snapshot version in `YYYYMMDD-HHMMSS` format
64+
*/
65+
async getDownloadUrl(
66+
appID: string,
67+
version: string,
68+
): Promise<ApiSnapshotDownload> {
69+
validateNonEmptyString(appID);
70+
validateNonEmptyString(version);
71+
72+
const data =
73+
await this.discloudApp.rest.get<RESTGetApiSnapshotVersionResult>(
74+
Routes.snapshot(appID, version),
75+
);
76+
77+
return data.download;
78+
}
79+
80+
/**
81+
* Get all versioned snapshots for an app on Discloud
82+
*
83+
* @throws `400`
84+
* @throws `404` not found
85+
*
86+
* @param appID - Your app id
87+
*/
88+
async fetch(appID: string): Promise<Map<string, Snapshot>>;
89+
/**
90+
* Get all versioned snapshots of the authenticated user
91+
*
92+
* @throws `400`
93+
*/
94+
async fetch(
95+
appID?: "all",
96+
options?: FetchSnapshotsOptions,
97+
): Promise<Map<string, Snapshot>>;
98+
async fetch(appID: string = "all", options: FetchSnapshotsOptions = {}) {
99+
if (appID === "all") return this.#fetchMany(options);
100+
101+
validateNonEmptyString(appID);
102+
103+
try {
104+
const data = await this.discloudApp.rest.get<RESTGetApiSnapshotResult>(
105+
Routes.snapshot(appID),
106+
);
107+
108+
this._clear(
109+
data.versions.map((version) => ({ appId: appID, ...version })),
110+
);
111+
112+
return this._addMany(
113+
data.versions.map((version) => ({ appId: appID, ...version })),
114+
);
115+
} catch (error) {
116+
if (error instanceof DiscloudAPIError) {
117+
switch (error.code) {
118+
case constants.HTTP_STATUS_NOT_FOUND:
119+
this._clear();
120+
break;
121+
122+
default:
123+
throw error;
124+
}
125+
}
126+
127+
throw error;
128+
}
129+
}
130+
131+
async #fetchMany(options: FetchSnapshotsOptions) {
132+
const query: Record<string, string> = {};
133+
134+
if (options.page !== undefined) query.page = String(options.page);
135+
if (options.limit !== undefined) query.limit = String(options.limit);
136+
if (options.summary !== undefined) query.summary = String(options.summary);
137+
138+
try {
139+
const data =
140+
await this.discloudApp.rest.get<RESTGetApiSnapshotListResult>(
141+
Routes.snapshot(),
142+
{ query },
143+
);
144+
145+
return this._addMany(
146+
data.backups.map((backup) => ({ appId: backup.appID, ...backup })),
147+
);
148+
} catch (error) {
149+
if (error instanceof DiscloudAPIError) {
150+
switch (error.code) {
151+
case constants.HTTP_STATUS_NOT_FOUND:
152+
this._clear();
153+
break;
154+
155+
default:
156+
throw error;
157+
}
158+
}
159+
160+
throw error;
161+
}
162+
}
163+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ApiSnapshotVersion } from "@discloudapp/api-types/v2";
2+
import type DiscloudApp from "../discloudApp/DiscloudApp";
3+
import Base from "./Base";
4+
5+
export default abstract class BaseSnapshot extends Base {
6+
constructor(discloudApp: DiscloudApp, readonly appId: string, data: ApiSnapshotVersion) {
7+
super(discloudApp);
8+
9+
this.version = data.version;
10+
}
11+
12+
declare readonly version: string;
13+
declare size: number | string;
14+
declare date: number | string;
15+
16+
protected _path(data: Partial<ApiSnapshotVersion>): this {
17+
if (data.size !== undefined) this.size = data.size;
18+
19+
if (data.date !== undefined) this.date = data.date;
20+
21+
return super._patch(data);
22+
}
23+
}

0 commit comments

Comments
 (0)