Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false
}
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ yarn add discloud.app
[How to manage custom domains](#how-to-manage-custom-domains)
[How to manage subdomains](#how-to-manage-subdomains)
[How to manage shared/team applications](#how-to-manage-sharedteam-applications)
[How to create snapshots](#how-to-create-snapshots)

```js
// index.js
Expand Down Expand Up @@ -329,4 +330,36 @@ await discloud.sharedApps.stop("APP_ID"); // Promise<void>
const status = await discloud.sharedApps.status.fetch("APP_ID"); // Promise<SharedAppStatus>
```

> **Note:** In v1.x, use `discloud.teamApps` instead of `discloud.sharedApps`. See the [Migration Guide](./MIGRATION.md) for more details.
### How to create snapshots
```js
const { discloud } = require("discloud.app");

// Create a new snapshot
await discloud.snapshots.create("APP_ID");

// List snapshots for the app
await discloud.snapshots.fetch("APP_ID");

// Fetch all snapshots
await discloud.snapshots.fetch();
```

### How to download the snapshot
```js
const { discloud } = require("discloud.app");

// List the snapshots
const snapshots = await discloud.snapshots.fetch("APP_ID");

// Get the most recent snapshot (last of the Map)
const snap = [...snapshots.values()].pop();

// Download the snapshot
await snap.download("./backups");

- If you want a specific one by version (YYYYMMDD-HHMMSS format):
const snapshots = await discloud.snapshots.fetch("APP_ID");
const snap = snapshots.get("APP_ID-20260420-153000"); // appId-version
```

> **Note:** In v1.x, use `discloud.teamApps` instead of `discloud.sharedApps`. See the [Migration Guide](./MIGRATION.md) for more details.
1 change: 1 addition & 0 deletions packages/api-types/rest/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from "./base";
export * from "./customdomain";
export * from "./locale";
export * from "./routes";
export * from "./snapshot"
export * from "./subdomain";
export * from "./team";
export * from "./upload";
Expand Down
50 changes: 50 additions & 0 deletions packages/api-types/rest/v2/snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { type BaseApiApp } from "./app";
import { type RESTApiBaseResult } from "./base";

export type ApiSnapshotApp = BaseApiApp

export interface ApiSnapshotVersion {
version: string
size: number | string
date: number | string
}

export interface ApiSnapshotDownload {
url: string
expiresAt: string
version: string
size: number
}

export interface ApiSnapshotCreated {
version: string
size: string
url: string
allVersions: ApiSnapshotVersion[]
}

export interface ApiSnapshotListItem extends ApiSnapshotVersion {
appID: string
}

export interface RESTGetApiSnapshotListResult extends RESTApiBaseResult {
page: number
limit: number
total: number
backups: ApiSnapshotListItem[]
}

export interface RESTGetApiSnapshotResult extends RESTApiBaseResult {
app: ApiSnapshotApp
versions: ApiSnapshotVersion[]
}

export interface RESTGetApiSnapshotVersionResult extends RESTApiBaseResult {
app: ApiSnapshotApp
download: ApiSnapshotDownload
}

export interface RESTPostApiSnapshotResult extends RESTApiBaseResult {
app: ApiSnapshotApp
snapshot: ApiSnapshotCreated
}
2 changes: 2 additions & 0 deletions packages/discloud.app/src/discloudApp/DiscloudApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SubdomainsManager from "../managers/SubdomainsManager";
import User from "../structures/User";
import { DefaultDiscloudAppOptions } from "../util/constants";
import Deprecation from "../util/deprecation";
import SnapshotsManager from "../managers/SnapshotsManager";

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

Expand Down
63 changes: 63 additions & 0 deletions packages/discloud.app/src/managers/BaseSnapshotsManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { type ApiSnapshotVersion } from "@discloudapp/api-types/v2";
import { type Instanciable } from "../@types";
import type DiscloudApp from "../discloudApp/DiscloudApp";
import type BaseSnapshot from "../structures/BaseSnapshot";
import CachedManager from "./CachedManager";

export type PartialApiSnapshot = Partial<ApiSnapshotVersion> & { appId: string, version: string };

export default abstract class BaseSnapshotsManager<T extends Instanciable<typeof BaseSnapshot>> extends CachedManager<string, T> {
constructor(discloudApp: DiscloudApp, holds: T) {
super(discloudApp, holds);
}

protected _key(appId: string, version: string): string {
return `${appId}-${version}`;
}

protected _add(data: PartialApiSnapshot): InstanceType<T> {
const key = this._key(data.appId, data.version);

const existing = this._patch(key, data);
if (existing) return existing;

const entry = new this.holds(this.discloudApp, data.appId, data) as InstanceType<T>;

this._cache.set(key, entry);
return entry;
}

protected _addMany(data: PartialApiSnapshot[]): Map<string, InstanceType<T>> {
const cache = new Map<string, InstanceType<T>>();

for (const element of data) {
const obj = this._add(element);
cache.set(this._key(element.appId, obj.version), obj);
}

return cache;
}

protected _clear(data?: PartialApiSnapshot[]): void {
if (!data?.length) return this._cache.clear();

const mapped = new Set(data.map((v) => this._key(v.appId, v.version)));

for (const key of this._cache.keys()) {
if (!mapped.has(key)) this._delete(key);
}
}

protected _delete(key: string): boolean {
return this._cache.delete(key);
}

protected _deleteMany(keys: string[]) {
for (const key of keys) this._delete(key);
}

protected _patch(key: string, data: Partial<ApiSnapshotVersion>): InstanceType<T> | undefined {
// @ts-expect-error ts(2445)
return this._cache.get(key)?._patch(data);
}
}
163 changes: 163 additions & 0 deletions packages/discloud.app/src/managers/SnapshotsManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import {
type ApiSnapshotDownload,
type RESTGetApiSnapshotListResult,
type RESTGetApiSnapshotResult,
type RESTGetApiSnapshotVersionResult,
type RESTPostApiSnapshotResult,
Routes,
} from "@discloudapp/api-types/v2";
import { DiscloudAPIError } from "@discloudapp/rest";
import { constants } from "http2";
import type DiscloudApp from "../discloudApp/DiscloudApp";
import Snapshot from "../structures/Snapshot";
import { validateNonEmptyString } from "../util/assertions";
import BaseSnapshotsManager from "./BaseSnapshotsManager";

export interface FetchSnapshotsOptions {
page?: number;
limit?: number;
summary?: boolean;
}

/**
* Manager for versioned snapshots on Discloud
*/
export default class SnapshotsManager extends BaseSnapshotsManager<
typeof Snapshot
> {
constructor(discloudApp: DiscloudApp) {
super(discloudApp, Snapshot);
}

/**
* Create a new versioned snapshot for an app
*
* @throws `400`
* @throws `403`
* @throws `404` not found
* @throws `409` app busy
*
* @param appID - Your app id
*/
async create(appID: string) {
validateNonEmptyString(appID);

const data = await this.discloudApp.rest.post<RESTPostApiSnapshotResult>(
Routes.snapshot(appID),
);

return this._add({
appId: appID,
version: data.snapshot.version,
size: data.snapshot.size,
});
}

/**
* Generate a temporary download url for a specific snapshot version
*
* @throws `400`
* @throws `404` not found
*
* @param appID - Your app id
* @param version - Snapshot version in `YYYYMMDD-HHMMSS` format
*/
async getDownloadUrl(
appID: string,
version: string,
): Promise<ApiSnapshotDownload> {
validateNonEmptyString(appID);
validateNonEmptyString(version);

const data =
await this.discloudApp.rest.get<RESTGetApiSnapshotVersionResult>(
Routes.snapshot(appID, version),
);

return data.download;
}

/**
* Get all versioned snapshots for an app on Discloud
*
* @throws `400`
* @throws `404` not found
*
* @param appID - Your app id
*/
async fetch(appID: string): Promise<Map<string, Snapshot>>;
/**
* Get all versioned snapshots of the authenticated user
*
* @throws `400`
*/
async fetch(
appID?: "all",
options?: FetchSnapshotsOptions,
): Promise<Map<string, Snapshot>>;
async fetch(appID: string = "all", options: FetchSnapshotsOptions = {}) {
if (appID === "all") return this.#fetchMany(options);

validateNonEmptyString(appID);

try {
const data = await this.discloudApp.rest.get<RESTGetApiSnapshotResult>(
Routes.snapshot(appID),
);

this._clear(
data.versions.map((version) => ({ appId: appID, ...version })),
);

return this._addMany(
data.versions.map((version) => ({ appId: appID, ...version })),
);
} catch (error) {
if (error instanceof DiscloudAPIError) {
switch (error.code) {
case constants.HTTP_STATUS_NOT_FOUND:
this._clear();
break;

default:
throw error;
}
}

throw error;
}
}

async #fetchMany(options: FetchSnapshotsOptions) {
const query: Record<string, string> = {};

if (options.page !== undefined) query.page = String(options.page);
if (options.limit !== undefined) query.limit = String(options.limit);
if (options.summary !== undefined) query.summary = String(options.summary);

try {
const data =
await this.discloudApp.rest.get<RESTGetApiSnapshotListResult>(
Routes.snapshot(),
{ query },
);

return this._addMany(
data.backups.map((backup) => ({ appId: backup.appID, ...backup })),
);
} catch (error) {
if (error instanceof DiscloudAPIError) {
switch (error.code) {
case constants.HTTP_STATUS_NOT_FOUND:
this._clear();
break;

default:
throw error;
}
}

throw error;
}
}
}
23 changes: 23 additions & 0 deletions packages/discloud.app/src/structures/BaseSnapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ApiSnapshotVersion } from "@discloudapp/api-types/v2";
import type DiscloudApp from "../discloudApp/DiscloudApp";
import Base from "./Base";

export default abstract class BaseSnapshot extends Base {
constructor(discloudApp: DiscloudApp, readonly appId: string, data: ApiSnapshotVersion) {
super(discloudApp);

this.version = data.version;
}

declare readonly version: string;
declare size: number | string;
declare date: number | string;

protected _path(data: Partial<ApiSnapshotVersion>): this {
if (data.size !== undefined) this.size = data.size;

if (data.date !== undefined) this.date = data.date;

return super._patch(data);
}
}
Loading
Loading