Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .github/workflows/preview-cli-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,18 @@ jobs:
id: cli_tests
run: pnpm --filter @prisma/cli test

- name: Run compute tests
id: compute_tests
run: pnpm --filter @prisma/compute test

- name: Build CLI package
id: cli_build
run: pnpm --filter @prisma/cli build

- name: Build compute package
id: compute_build
run: pnpm --filter @prisma/compute build

- name: Prepare staged preview package
id: prepare_preview
run: node scripts/prepare-cli-publish.mjs .publish/cli --version '${{ steps.cli_version.outputs.version }}'
Expand All @@ -79,7 +87,9 @@ jobs:
echo
echo "- Version: \`${{ steps.cli_version.outputs.version }}\`"
echo "- CLI tests: \`${{ steps.cli_tests.outcome || 'skipped' }}\`"
echo "- Compute tests: \`${{ steps.compute_tests.outcome || 'skipped' }}\`"
echo "- Build: \`${{ steps.cli_build.outcome || 'skipped' }}\`"
echo "- Compute build: \`${{ steps.compute_build.outcome || 'skipped' }}\`"
echo "- Package staging: \`${{ steps.prepare_preview.outcome || 'skipped' }}\`"
echo "- pkg.pr.new publish: \`${{ steps.publish_preview.outcome || 'skipped' }}\`"
PUBLISH_OUTCOME="${{ steps.publish_preview.outcome || 'skipped' }}"
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture/package-structure.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Package Structure

The repository currently contains one publishable package:
The repository currently contains two publishable packages:

- `packages/cli`: the public Prisma CLI beta package
- `packages/compute`: runtime utilities for deployed Prisma compute applications

The root workspace owns shared scripts, docs, release preparation, and examples.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"packageManager": "pnpm@10.30.0",
"scripts": {
"build:cli": "pnpm --filter @prisma/cli build",
"build:compute": "pnpm --filter @prisma/compute build",
"lint:skills": "node scripts/validate-skills.mjs",
"prepare": "skills add ./skills --skill '*' --agent universal claude-code -y",
"prepare:cli-publish": "node scripts/prepare-cli-publish.mjs",
Expand Down
12 changes: 12 additions & 0 deletions packages/compute/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# AGENTS.md

## Verification

When changing `@prisma/compute`, run the package-local checks:

```bash
pnpm --filter @prisma/compute test
pnpm --filter @prisma/compute build
```

Keep `README.md` consumer-focused because it is included in the published npm package.
50 changes: 50 additions & 0 deletions packages/compute/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Prisma Compute

`@prisma/compute` provides runtime utilities for applications deployed to Prisma
Compute.

## Preventing Application Sleep

Applications deployed to Prisma Compute can sleep after short periods of inactivity. When an application sleeps, Prisma Compute snapshots its memory and resumes from that snapshot when the next request arrives.

This works well for request-driven code, but background work outside the request lifecycle can be interrupted when the application sleeps. Examples include `setTimeout`, `setInterval`, and background `Promise` work.

`@prisma/compute` provides two utilities that signal to Prisma Compute that work is still active and the application should stay awake.

`waitUntil` keeps the application awake until a `Promise` settles. It returns `void`, so callers should keep using the original promise for result and error handling. Pass an `AbortSignal`, usually from `AbortSignal.timeout(ms)`, as a safety bound if the promise does not settle. `waitUntil` can be called multiple times during a single request.

```ts
import { waitUntil } from "@prisma/compute";

waitUntil(doCriticalWork(), { signal: AbortSignal.timeout(30_000) });
```

`ScaleToZeroGuard` is a disposable object that keeps the application awake until the guard is released. Use it for a scoped function or block of background work. `ScaleToZeroGuard` can be created multiple times during a single request and is safe to nest. Pass an `AbortSignal`, usually from `AbortSignal.timeout(ms)`, as a safety bound if release is not reached.

Read more about disposables and the `using` keyword in the [MDN resource management guide](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Resource_management).

```ts
import { ScaleToZeroGuard } from "@prisma/compute";

async function runsInBackground() {
// guard is acquired here
using guard = new ScaleToZeroGuard({ signal: AbortSignal.timeout(30_000) });
await doCriticalWork();
} // guard is released here
```

If `using` is not available, call `.release()` manually. Always release the guard in a `finally` block so it is released even if the guarded code throws.

```ts
import { ScaleToZeroGuard } from "@prisma/compute";

async function runsInBackground() {
const guard = new ScaleToZeroGuard({ signal: AbortSignal.timeout(30_000) });

try {
await doCriticalWork();
} finally {
guard.release();
}
}
```
48 changes: 48 additions & 0 deletions packages/compute/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"name": "@prisma/compute",
"version": "0.1.0-development",
"description": "Utilities for applications running on the Prisma compute runtime.",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=22.12.0"
},
"keywords": [
"prisma",
"compute",
"runtime"
],
"repository": {
"type": "git",
"url": "https://github.com/prisma/prisma-cli.git",
"directory": "packages/compute"
},
"homepage": "https://github.com/prisma/prisma-cli#readme",
"bugs": {
"url": "https://github.com/prisma/prisma-cli/issues"
},
"license": "Apache-2.0",
"scripts": {
"build": "tsdown",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "^22.19.19",
"tsdown": "^0.21.10",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
}
}
5 changes: 5 additions & 0 deletions packages/compute/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
ScaleToZeroGuard,
waitUntil,
type ScaleToZeroGuardOptions,
} from "./scale-to-zero";
59 changes: 59 additions & 0 deletions packages/compute/src/scale-to-zero-control.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import fs from "node:fs";

const DEFAULT_CONTROL_FILE_PATH = "/uk/libukp/scale_to_zero_disable";

type ControlFileState =
| { kind: "uninitialized"; path: string }
| { kind: "unavailable"; path: string }
| { kind: "open"; fd: number; path: string };

let controlFileState: ControlFileState = {
kind: "uninitialized",
path: DEFAULT_CONTROL_FILE_PATH,
};

export type ScaleToZeroSignal = "acquire" | "release";

export function writeScaleToZeroSignal(signal: ScaleToZeroSignal): boolean {
const state = getControlFileState();

if (state.kind !== "open") {
return false;
}

try {
fs.writeSync(state.fd, signal === "acquire" ? "+" : "-");
return true;
} catch {
return false;
}
}

function getControlFileState(): ControlFileState {
if (controlFileState.kind !== "uninitialized") {
return controlFileState;
}

try {
controlFileState = {
kind: "open",
fd: fs.openSync(controlFileState.path, fs.constants.O_WRONLY),
path: controlFileState.path,
};
} catch {
controlFileState = { kind: "unavailable", path: controlFileState.path };
}

return controlFileState;
}

export function configureScaleToZeroControlFileForTests(path: string | undefined): void {
if (controlFileState.kind === "open") {
fs.closeSync(controlFileState.fd);
}

controlFileState = {
kind: "uninitialized",
path: path ?? DEFAULT_CONTROL_FILE_PATH,
};
}
147 changes: 147 additions & 0 deletions packages/compute/src/scale-to-zero.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { writeScaleToZeroSignal } from "./scale-to-zero-control";

/**
* Options for holding a Prisma Compute sleep guard.
*/
export interface ScaleToZeroGuardOptions {
/**
* Signal that releases the guard when aborted.
*
* Use `AbortSignal.timeout(ms)` for a time bound, or pass a request or
* operation signal to tie the guard to caller-owned cancellation. Passing a
* signal is strongly recommended as a safety bound for dangling guards.
*/
signal?: AbortSignal;
}

/**
* Keeps a Prisma Compute application awake for scoped async work.
*
* Creating a guard signals the compute runtime to stay awake. Calling
* {@link ScaleToZeroGuard.release}, leaving a `using` scope, or reaching
* the configured abort signal releases that signal. Release is idempotent, so
* manual release and disposal can be combined safely.
*
* Pass `signal` whenever possible, usually from `AbortSignal.timeout(ms)`, to
* bound how long the guard can keep the instance awake if release is not reached.
*
* Outside the Prisma Compute runtime, where the sleep control endpoint is
* unavailable, the guard is a no-op.
*
* @example
* ```ts
* import { ScaleToZeroGuard } from "@prisma/compute";
*
* using guard = new ScaleToZeroGuard({ signal: AbortSignal.timeout(30_000) });
* await doCriticalWork();
* ```
*
* @example
* ```ts
* const guard = new ScaleToZeroGuard();
* try {
* await doCriticalWork();
* } finally {
* guard.release();
* }
* ```
*/
export class ScaleToZeroGuard implements Disposable {
#active: boolean;
#abortSignal: AbortSignal | undefined;
#abortListener: (() => void) | undefined;

/**
* Creates a guard and immediately signals the compute runtime to stay awake.
*
* If `signal` is already aborted, no signal is written. If `signal` aborts
* while the guard is active, the guard releases itself. Passing a signal is
* recommended as a safety bound if release is not reached.
*/
constructor(options: ScaleToZeroGuardOptions = {}) {
if (options.signal?.aborted) {
this.#active = false;
return;
}

this.#active = writeScaleToZeroSignal("acquire");

if (this.#active && options.signal !== undefined) {
this.#abortSignal = options.signal;
this.#abortListener = () => {
this.release();
};
options.signal.addEventListener("abort", this.#abortListener, {
once: true,
});
}
}

/**
* Releases the guard's keep-awake signal.
*
* This method is idempotent. Calling it multiple times, or calling it before
* a `using` scope exits, writes at most one release signal.
*/
release(): void {
if (!this.#active) {
return;
}

this.#active = false;
this.#removeAbortListener();
writeScaleToZeroSignal("release");
}

/**
* Releases the guard when used with TypeScript's `using` syntax.
*
* Most callers should prefer `using` for scoped work and call
* {@link ScaleToZeroGuard.release} only when release needs to happen before
* the scope exits.
*/
[Symbol.dispose](): void {
this.release();
}

#removeAbortListener(): void {
if (this.#abortSignal === undefined || this.#abortListener === undefined) {
return;
}

this.#abortSignal.removeEventListener("abort", this.#abortListener);
this.#abortSignal = undefined;
this.#abortListener = undefined;
}
}

/**
* Keeps a Prisma Compute application awake until a promise settles.
*
* The guard is acquired immediately, then released from a `finally` handler on
* the passed promise. This function returns `void`; callers should keep using
* the original promise for result and error handling. If `signal` aborts first,
* only the guard is released; the passed promise continues independently.
*
* Pass `signal`, usually from `AbortSignal.timeout(ms)`, to bound guard lifetime
* even when the promise does not settle.
*
* @example
* ```ts
* import { waitUntil } from "@prisma/compute";
*
* waitUntil(sendWebhook(), { signal: AbortSignal.timeout(10_000) });
* ```
*/
export function waitUntil(
promise: PromiseLike<unknown>,
options?: ScaleToZeroGuardOptions,
): void {
const guard = new ScaleToZeroGuard(options);

void Promise.resolve(promise)
.finally(() => {
guard.release();
})
.catch(() => {});
}
Loading
Loading