mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0029ea06b | ||
|
|
a471eec57e | ||
|
|
b89b25c371 | ||
|
|
5d27c2aa0e | ||
|
|
bbf812cd6f | ||
|
|
bf488e5b8a | ||
|
|
4f46469780 | ||
|
|
5b12b61d4a | ||
|
|
d142799086 | ||
|
|
7283e66e4e | ||
|
|
f4962faf61 | ||
|
|
6141f1c1ff | ||
|
|
b59e4d24cc |
No files matched your search
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 96;
|
||||
const CURRENT_SCHEMA_VERSION = 97;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -735,6 +735,73 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
expect(columns.map((c) => String(c.name))).toEqual(['send_key']);
|
||||
});
|
||||
|
||||
it('creates event_user_handlers with its unique and holder indexes', async () => {
|
||||
const columns = await client.read(
|
||||
'PRAGMA table_info(`event_user_handlers`)',
|
||||
);
|
||||
expect(columns.map((c) => String(c.name))).toEqual([
|
||||
'id',
|
||||
'app_uid',
|
||||
'holder_user_id',
|
||||
'name',
|
||||
'source',
|
||||
'source_hash',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
const indexes = await client.read(
|
||||
'PRAGMA index_list(`event_user_handlers`)',
|
||||
);
|
||||
expect(
|
||||
indexes.find(
|
||||
(i) => i.name === 'idx_event_user_handlers_app_holder_name',
|
||||
),
|
||||
).toMatchObject({ unique: 1 });
|
||||
expect(
|
||||
indexes.find((i) => i.name === 'idx_event_user_handlers_holder_app'),
|
||||
).toMatchObject({ unique: 0 });
|
||||
const columnsOf = async (index: string) =>
|
||||
(await client.read(`PRAGMA index_info(\`${index}\`)`)).map((c) =>
|
||||
String(c.name),
|
||||
);
|
||||
expect(await columnsOf('idx_event_user_handlers_app_holder_name')).toEqual(
|
||||
['app_uid', 'holder_user_id', 'name'],
|
||||
);
|
||||
expect(await columnsOf('idx_event_user_handlers_holder_app')).toEqual([
|
||||
'holder_user_id',
|
||||
'app_uid',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves event_handlers as it was', async () => {
|
||||
const columns = await client.read('PRAGMA table_info(`event_handlers`)');
|
||||
expect(columns.map((c) => String(c.name))).toEqual([
|
||||
'id',
|
||||
'app_uid',
|
||||
'name',
|
||||
'source',
|
||||
'source_hash',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds event_subscriptions.handler_scope, app scope for existing rows', async () => {
|
||||
const columns = await client.read(
|
||||
'PRAGMA table_info(`event_subscriptions`)',
|
||||
);
|
||||
expect(columns.find((c) => c.name === 'handler_scope')).toMatchObject({
|
||||
notnull: 1,
|
||||
dflt_value: '0',
|
||||
});
|
||||
const indexes = await client.read(
|
||||
'PRAGMA index_list(`event_subscriptions`)',
|
||||
);
|
||||
expect(indexes.map((i) => String(i.name))).toContain(
|
||||
'idx_event_subscriptions_holder',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an already-migrated database untouched on a second boot', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-'));
|
||||
const path = join(dir, 'nested', 'puter.sqlite');
|
||||
|
||||
@@ -135,6 +135,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[93, ['0098_notification-groups.sql']],
|
||||
[94, ['0099_kv-share-handle-notify.sql']],
|
||||
[95, ['0100_notification-send-key.sql']],
|
||||
[96, ['0101_user-scoped-handlers.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Handlers an app's user published into their own scope, kept apart from
|
||||
-- `event_handlers` so code that predates them never reads them. See
|
||||
-- sqlite/0101_user-scoped-handlers.sql for the column rationale.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
|
||||
-- and a guarded column add. There is no per-file applied-state tracking, so a
|
||||
-- replay has to be a no-op.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_user_handlers` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
-- Matches `apps`.`uid` exactly, charset included, so an equality against
|
||||
-- one never falls back to a conversion.
|
||||
`app_uid` char(40) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
||||
`holder_user_id` int unsigned NOT NULL,
|
||||
`name` varchar(128) NOT NULL,
|
||||
`source` mediumtext NOT NULL,
|
||||
`source_hash` char(64) NOT NULL,
|
||||
`created_at` bigint NOT NULL,
|
||||
`updated_at` bigint NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_event_user_handlers_app_holder_name` (`app_uid`, `holder_user_id`, `name`),
|
||||
KEY `idx_event_user_handlers_holder_app` (`holder_user_id`, `app_uid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CALL _puter_add_col('event_subscriptions', 'handler_scope', '`handler_scope` tinyint unsigned NOT NULL DEFAULT 0');
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Handlers an app's user published into their own scope. See
|
||||
-- sqlite/0101_user-scoped-handlers.sql for the column rationale.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_user_handlers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
app_uid VARCHAR(40) NOT NULL,
|
||||
holder_user_id INTEGER NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
source_hash VARCHAR(64) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_user_handlers_app_holder_name
|
||||
ON event_user_handlers (app_uid, holder_user_id, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_user_handlers_holder_app
|
||||
ON event_user_handlers (holder_user_id, app_uid);
|
||||
|
||||
ALTER TABLE event_subscriptions ADD COLUMN IF NOT EXISTS handler_scope smallint NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Handlers an app's user published into their own scope, in a table of their
|
||||
-- own so `event_handlers` stays the app owner's set. Same columns as
|
||||
-- `event_handlers` (see 0076_event-handlers.sql) plus the holder.
|
||||
--
|
||||
-- - `holder_user_id` : the user whose scope this is. A name is unique per
|
||||
-- (app, holder), so the same name can live in the app's
|
||||
-- set and in each holder's.
|
||||
-- - `(holder_user_id, app_uid)` : a holder's scopes, for listing and for the
|
||||
-- rent count.
|
||||
--
|
||||
-- `event_subscriptions.handler_scope` says which table a row's `handler_name`
|
||||
-- refers to: 0 the app's set, 1 the row holder's scope.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_user_handlers` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"app_uid" TEXT NOT NULL,
|
||||
"holder_user_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"source_hash" TEXT NOT NULL,
|
||||
"created_at" INTEGER NOT NULL, -- unix seconds
|
||||
"updated_at" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_event_user_handlers_app_holder_name`
|
||||
ON `event_user_handlers` (`app_uid`, `holder_user_id`, `name`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_user_handlers_holder_app`
|
||||
ON `event_user_handlers` (`holder_user_id`, `app_uid`);
|
||||
|
||||
ALTER TABLE `event_subscriptions` ADD COLUMN `handler_scope` INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -666,9 +666,18 @@ export type EventMap = {
|
||||
// and going away (1→0, whether by removing the last handler or by the
|
||||
// destroy route). `actor.user` is the app's owner, not the caller — a
|
||||
// developer session publishing for an app it owns is the common case, but
|
||||
// billing follows ownership.
|
||||
'events.worker.create': { actor: Actor; appUid: string };
|
||||
'events.worker.destroy': { actor: Actor; appUid: string };
|
||||
// billing follows ownership. For `scope: 'user'` the unit is one user's
|
||||
// handlers in one app instead, and `actor.user` is that user.
|
||||
'events.worker.create': {
|
||||
actor: Actor;
|
||||
appUid: string;
|
||||
scope: 'app' | 'user';
|
||||
};
|
||||
'events.worker.destroy': {
|
||||
actor: Actor;
|
||||
appUid: string;
|
||||
scope: 'app' | 'user';
|
||||
};
|
||||
|
||||
// ---- Outer / GUI broadcast ----
|
||||
'outer.cacheUpdate': {
|
||||
|
||||
@@ -23,16 +23,19 @@
|
||||
*/
|
||||
|
||||
import type { fetch as undiciFetch } from 'undici';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import {
|
||||
DispatcherInvokeTransport,
|
||||
EVENTS_DEPLOYED_HEADER,
|
||||
EVENTS_DEADLINE_MARGIN_MS,
|
||||
EVENTS_DISPATCH_BATCH_PATH,
|
||||
EVENTS_DISPATCH_ERROR_HEADER,
|
||||
EVENTS_DISPATCH_PATH,
|
||||
EVENTS_ERROR_HEADER,
|
||||
EVENTS_HANDLED_HEADER,
|
||||
EVENTS_INVOKE_TIMEOUT_MS,
|
||||
EVENTS_WORKER_LEASE_FACTOR,
|
||||
EventsWorkerInvokerClient,
|
||||
INVOKE_CONNECTIONS,
|
||||
SCHEDULE_INVOKE_CONNECTIONS,
|
||||
@@ -1302,3 +1305,90 @@ describe('outcomeForStatus', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('EventsWorkerInvokerClient overall deadline', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const LEASE_MS = EVENTS_INVOKE_TIMEOUT_MS * EVENTS_WORKER_LEASE_FACTOR;
|
||||
|
||||
/**
|
||||
* A transport on a fake clock: each send costs what `cost` says, and
|
||||
* answers a miss until the deployed header, then a handled 200.
|
||||
*/
|
||||
const timedTransport = (cost: (deployed: boolean) => number) => {
|
||||
const sends: Array<{ deployed: boolean; timeoutMs: number }> = [];
|
||||
const transport = {
|
||||
send: async (call: {
|
||||
deployed?: boolean;
|
||||
timeoutMs: number;
|
||||
}): Promise<unknown> => {
|
||||
const deployed = call.deployed === true;
|
||||
sends.push({ deployed, timeoutMs: call.timeoutMs });
|
||||
// A send never outlasts its own timeout.
|
||||
vi.advanceTimersByTime(
|
||||
Math.min(cost(deployed), call.timeoutMs * 1.5),
|
||||
);
|
||||
return deployed
|
||||
? { status: 200, handled: true }
|
||||
: {
|
||||
status: null,
|
||||
dispatchReason: 'missing',
|
||||
error: 'dispatcher: missing (404)',
|
||||
};
|
||||
},
|
||||
};
|
||||
return { transport, sends };
|
||||
};
|
||||
|
||||
const run = async (
|
||||
cost: (deployed: boolean) => number,
|
||||
deployMs: number,
|
||||
) => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const { transport, sends } = timedTransport(cost);
|
||||
const client = makeClient();
|
||||
client.setTransport(transport as never);
|
||||
client.setMissHandler(async () => {
|
||||
vi.advanceTimersByTime(deployMs);
|
||||
return 'deployed';
|
||||
});
|
||||
const started = Date.now();
|
||||
const result = await client.invoke(REQUEST);
|
||||
return { result, sends, elapsed: Date.now() - started };
|
||||
};
|
||||
|
||||
it('leaves a fast miss as it was: a full-timeout resend', async () => {
|
||||
const { result, sends } = await run(() => 100, 2_000);
|
||||
expect(result).toEqual({ outcome: 'settled', status: 200 });
|
||||
expect(sends).toEqual([
|
||||
{ deployed: false, timeoutMs: EVENTS_INVOKE_TIMEOUT_MS },
|
||||
{ deployed: true, timeoutMs: EVENTS_INVOKE_TIMEOUT_MS },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shortens the resend after a slow deploy, so the call ends before the lease', async () => {
|
||||
// A miss that took 12 s, a deploy that took 30 s, and a resend that
|
||||
// would use everything it is given.
|
||||
const { result, sends, elapsed } = await run(
|
||||
(deployed) => (deployed ? 1e9 : 12_000),
|
||||
30_000,
|
||||
);
|
||||
expect(result.outcome).toBe('settled');
|
||||
expect(sends[1].deployed).toBe(true);
|
||||
expect(sends[1].timeoutMs).toBeLessThan(EVENTS_INVOKE_TIMEOUT_MS);
|
||||
expect(elapsed).toBeLessThanOrEqual(
|
||||
LEASE_MS - EVENTS_DEADLINE_MARGIN_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not resend when the deploy leaves less than the margin', async () => {
|
||||
const { result, sends } = await run(
|
||||
() => 100,
|
||||
LEASE_MS - EVENTS_DEADLINE_MARGIN_MS * 2 + 1_000,
|
||||
);
|
||||
expect(sends).toHaveLength(1);
|
||||
expect(result).toMatchObject({ outcome: 'unavailable', status: null });
|
||||
});
|
||||
});
|
||||
@@ -162,6 +162,19 @@ export type WorkerMissHandler = (
|
||||
/** How long a handler has to answer before the attempt is abandoned. */
|
||||
export const EVENTS_INVOKE_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* How many invoke timeouts a delivery's worker lease lasts. The lease store
|
||||
* reads this, and `invoke()` keeps a whole call, deploy and resend included,
|
||||
* inside it.
|
||||
*/
|
||||
export const EVENTS_WORKER_LEASE_FACTOR = 2;
|
||||
|
||||
/**
|
||||
* Left unspent at the end of the lease, and the least a resend is worth
|
||||
* starting with: below it the call is given up as `unavailable` and retried.
|
||||
*/
|
||||
export const EVENTS_DEADLINE_MARGIN_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Connections held open to the dispatcher, so a busy fleet is not
|
||||
* re-handshaking. Also how many calls one transport makes at once: a call past
|
||||
@@ -255,10 +268,7 @@ export interface EventsInvokeTransport {
|
||||
* handler); retried like `retriable` but never the handler's fault.
|
||||
*/
|
||||
export type WorkerInvokeOutcome =
|
||||
| 'settled'
|
||||
| 'terminal'
|
||||
| 'retriable'
|
||||
| 'unavailable';
|
||||
'settled' | 'terminal' | 'retriable' | 'unavailable';
|
||||
|
||||
export interface WorkerInvokeResult {
|
||||
outcome: WorkerInvokeOutcome;
|
||||
@@ -329,18 +339,49 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
ctx: request.ctx,
|
||||
token: request.token,
|
||||
});
|
||||
const send = (deployed: boolean): Promise<EventsInvokeAnswer> =>
|
||||
const timeoutMs = this.#timeoutMs();
|
||||
// The whole call, deploy and resend included, ends before the lease a
|
||||
// delivery holds would let it be claimed again mid-run.
|
||||
// A short configured timeout gets a proportionally short margin.
|
||||
const margin = Math.min(EVENTS_DEADLINE_MARGIN_MS, timeoutMs / 2);
|
||||
const deadline =
|
||||
Date.now() + timeoutMs * EVENTS_WORKER_LEASE_FACTOR - margin;
|
||||
const send = (
|
||||
deployed: boolean,
|
||||
sendTimeoutMs = timeoutMs,
|
||||
): Promise<EventsInvokeAnswer> =>
|
||||
transport.send({
|
||||
script: request.script,
|
||||
appUid: request.appUid,
|
||||
key: request.key,
|
||||
body: render(),
|
||||
timeoutMs: this.#timeoutMs(),
|
||||
timeoutMs: sendTimeoutMs,
|
||||
...(deployed ? { deployed: true } : {}),
|
||||
...(options.lane ? { lane: options.lane } : {}),
|
||||
...(stampEvent ? { render } : {}),
|
||||
});
|
||||
|
||||
// A send may wait for a connection for half its timeout before the
|
||||
// run's own timeout starts, so it is given what is left divided by that.
|
||||
const resend = (): Promise<EventsInvokeAnswer> | null => {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining < margin) return null;
|
||||
return send(
|
||||
true,
|
||||
Math.floor(
|
||||
Math.min(
|
||||
timeoutMs,
|
||||
remaining / (1 + CONNECTION_WAIT_SHARE),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
const outOfTime = (): WorkerInvokeResult => ({
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: 'out of time to resend after deploy',
|
||||
});
|
||||
|
||||
let result = await send(false);
|
||||
if (this.#missHandler && selfDeployable(result)) {
|
||||
const outcome = await this.#missHandler(
|
||||
@@ -355,7 +396,9 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
error: `deploy: ${outcome}`,
|
||||
...(outcome === DEPLOY_BUSY ? { deployBusy: true } : {}),
|
||||
};
|
||||
result = await send(true);
|
||||
const resent = await resend();
|
||||
if (!resent) return outOfTime();
|
||||
result = resent;
|
||||
} else if (
|
||||
result.status === null &&
|
||||
result.dispatchReason &&
|
||||
@@ -363,7 +406,9 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
) {
|
||||
// No deploy to trigger — just give propagation the one retry
|
||||
// it usually needs, without spending an upload on it.
|
||||
result = await send(true);
|
||||
const resent = await resend();
|
||||
if (!resent) return outOfTime();
|
||||
result = resent;
|
||||
}
|
||||
|
||||
const { status, handled, error, timedOut, retryAfterMs } = result;
|
||||
|
||||
@@ -402,6 +402,7 @@ export class EventsController extends PuterController {
|
||||
handlers: await this.services.events.listHandlers(actor, {
|
||||
appUid:
|
||||
typeof query.appUid === 'string' ? query.appUid : undefined,
|
||||
scope: query.scope,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,12 +34,21 @@ import {
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_MAX,
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_APP,
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
|
||||
EVENTS_HANDLER_PUBLISH_LIMIT,
|
||||
EVENTS_HANDLER_SOURCE_MAX_BYTES,
|
||||
EVENTS_HANDLERS_PER_APP,
|
||||
EVENTS_KV_HANDLES_PER_APP,
|
||||
EVENTS_KV_HANDLES_PER_USER,
|
||||
EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT,
|
||||
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT,
|
||||
EVENTS_SINGLE_DELIVERY_LIMIT,
|
||||
EVENTS_USER_HANDLERS_PER_SCOPE,
|
||||
EVENTS_USER_SCOPE_SOURCE_MAX_BYTES,
|
||||
EVENTS_USER_WORKER_DEPLOYS_PER_HOUR,
|
||||
EVENTS_WORKER_DEPLOYS_PER_HOUR,
|
||||
EVENTS_WORKER_INVOCATION_LIMIT,
|
||||
EVENTS_WORKER_SOURCE_MAX_BYTES,
|
||||
RENTED_UNITS_PER_USER,
|
||||
limitFor,
|
||||
tiered,
|
||||
type TieredLimit,
|
||||
@@ -183,3 +192,42 @@ describe('the delivery budgets', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the handler limits', () => {
|
||||
it('pins the app-scope numbers the quotas page lists', () => {
|
||||
expect(EVENTS_HANDLERS_PER_APP).toBe(100);
|
||||
expect(EVENTS_HANDLER_SOURCE_MAX_BYTES).toBe(64 * 1024);
|
||||
expect(EVENTS_WORKER_SOURCE_MAX_BYTES).toBe(5 * 1024 * 1024);
|
||||
expect(EVENTS_WORKER_DEPLOYS_PER_HOUR).toBe(30);
|
||||
expect(EVENTS_HANDLER_PUBLISH_LIMIT.limit).toBe(60);
|
||||
expect(EVENTS_HANDLER_PUBLISH_LIMIT.window).toBe(60_000);
|
||||
});
|
||||
|
||||
it('pins the user-scope numbers the quotas page lists', () => {
|
||||
expect(EVENTS_USER_HANDLERS_PER_SCOPE).toBe(20);
|
||||
expect(EVENTS_USER_SCOPE_SOURCE_MAX_BYTES).toBe(1024 * 1024);
|
||||
expect(EVENTS_USER_WORKER_DEPLOYS_PER_HOUR).toBe(10);
|
||||
});
|
||||
|
||||
it('keeps a user scope tighter than an app scope', () => {
|
||||
expect(EVENTS_USER_HANDLERS_PER_SCOPE).toBeLessThan(
|
||||
EVENTS_HANDLERS_PER_APP,
|
||||
);
|
||||
expect(EVENTS_USER_SCOPE_SOURCE_MAX_BYTES).toBeLessThan(
|
||||
EVENTS_WORKER_SOURCE_MAX_BYTES,
|
||||
);
|
||||
expect(EVENTS_USER_WORKER_DEPLOYS_PER_HOUR).toBeLessThan(
|
||||
EVENTS_WORKER_DEPLOYS_PER_HOUR,
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the rented-unit cap by plan', () => {
|
||||
expect(RENTED_UNITS_PER_USER.limit).toBe(100);
|
||||
expect(limitFor(RENTED_UNITS_PER_USER, DEFAULT_FREE_SUBSCRIPTION)).toBe(
|
||||
16,
|
||||
);
|
||||
expect(limitFor(RENTED_UNITS_PER_USER, DEFAULT_TEMP_SUBSCRIPTION)).toBe(
|
||||
16,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -269,6 +269,12 @@ export const KV_SHARE_NOTIFY_LIMITS = [
|
||||
*/
|
||||
export const EVENTS_HANDLERS_PER_APP = 100;
|
||||
|
||||
/**
|
||||
* Handlers one user may hold in their own scope of one app. Each is a script of
|
||||
* its own, so the cap is tighter than the app's.
|
||||
*/
|
||||
export const EVENTS_USER_HANDLERS_PER_SCOPE = 20;
|
||||
|
||||
/** Longest a serialized handler may be. */
|
||||
export const EVENTS_HANDLER_SOURCE_MAX_BYTES = 64 * 1024;
|
||||
|
||||
@@ -280,6 +286,9 @@ export const EVENTS_HANDLER_SOURCE_MAX_BYTES = 64 * 1024;
|
||||
*/
|
||||
export const EVENTS_WORKER_SOURCE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** Source one user scope may hold, all its handlers combined. */
|
||||
export const EVENTS_USER_SCOPE_SOURCE_MAX_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Deploys one app's events worker may trigger per hour.
|
||||
*
|
||||
@@ -289,6 +298,16 @@ export const EVENTS_WORKER_SOURCE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
*/
|
||||
export const EVENTS_WORKER_DEPLOYS_PER_HOUR = 30;
|
||||
|
||||
/** Deploys one user scope may trigger per hour. */
|
||||
export const EVENTS_USER_WORKER_DEPLOYS_PER_HOUR = 10;
|
||||
|
||||
/**
|
||||
* Units one user may rent at once: ordinary workers, user scopes, and the
|
||||
* events workers of apps they own. Only creating a unit is refused; holdings
|
||||
* over the cap are kept.
|
||||
*/
|
||||
export const RENTED_UNITS_PER_USER = tiered(100, 16, 16);
|
||||
|
||||
/**
|
||||
* Handlers one `publishAll` may carry. A build step publishes its whole set in
|
||||
* one call, and the set is capped by what an app may hold anyway.
|
||||
|
||||
@@ -25,7 +25,16 @@
|
||||
|
||||
import MockRedis from 'ioredis-mock';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { EventsInvokeCall } from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import type { fetch as undiciFetch } from 'undici';
|
||||
import {
|
||||
DispatcherInvokeTransport,
|
||||
EVENTS_DEPLOYED_HEADER,
|
||||
EVENTS_DISPATCH_ERROR_HEADER,
|
||||
EVENTS_HANDLED_HEADER,
|
||||
EventsWorkerInvokerClient,
|
||||
type EventsInvokeCall,
|
||||
} from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import { INTERNAL_DEPLOY_TARGET } from '../../drivers/workers/WorkerDriver.js';
|
||||
import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
|
||||
import {
|
||||
EventsWorkerDeployer,
|
||||
@@ -51,14 +60,22 @@ interface Owner {
|
||||
type Layers = ConstructorParameters<typeof EventsWorkerDeployer>[0];
|
||||
|
||||
/** Stub layers whose only real dependency is a fresh redis mock. */
|
||||
const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
const makeLayers = (
|
||||
owners: Record<string, Owner | null>,
|
||||
events: Record<string, unknown> = {},
|
||||
) => {
|
||||
const redis = new MockRedis.Cluster(['redis://localhost:7001']);
|
||||
const created: string[] = [];
|
||||
const targets: Array<Record<string, unknown>> = [];
|
||||
/** Apps whose handler sources were read, once per read. */
|
||||
const sourceReads: string[] = [];
|
||||
const layers = {
|
||||
config: {
|
||||
events: { workerRuntime: true, internalSecret: 'sekret' },
|
||||
events: {
|
||||
workerRuntime: true,
|
||||
internalSecret: 'sekret',
|
||||
...events,
|
||||
},
|
||||
// `#namespace()` needs a local-worker shape to resolve without
|
||||
// throwing — this stub never reaches a real Cloudflare or
|
||||
// Miniflare deploy either way.
|
||||
@@ -68,7 +85,7 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
stores: {
|
||||
eventHandler: {
|
||||
setForApp: async () => [{ name: 'h', sourceHash: SOURCE_HASH }],
|
||||
allForApp: async (appUid: string) => {
|
||||
allForApp: async ({ appUid }: { appUid: string }) => {
|
||||
sourceReads.push(appUid);
|
||||
return [
|
||||
{
|
||||
@@ -108,8 +125,12 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
services: {},
|
||||
drivers: {
|
||||
workers: {
|
||||
create: async (args: { workerName: string }) => {
|
||||
create: async (args: {
|
||||
workerName: string;
|
||||
[INTERNAL_DEPLOY_TARGET]?: Record<string, unknown>;
|
||||
}) => {
|
||||
created.push(args.workerName);
|
||||
targets.push(args[INTERNAL_DEPLOY_TARGET] ?? {});
|
||||
return { success: true, errors: [] };
|
||||
},
|
||||
},
|
||||
@@ -118,6 +139,7 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
return {
|
||||
layers: layers as unknown as Layers,
|
||||
created,
|
||||
targets,
|
||||
redis,
|
||||
sourceReads,
|
||||
};
|
||||
@@ -175,6 +197,23 @@ describe('EventsWorkerDeployer.ensure', () => {
|
||||
expect(await redis.get(`ev:deploys:{app-e}:${hour}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('passes events.workerCpuMs to the deploy target', async () => {
|
||||
const { layers, targets } = makeLayers(
|
||||
{ 'app-f': { ownerUserId: 6 } },
|
||||
{ workerCpuMs: 50 },
|
||||
);
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(await deployer.ensure('app-f', SCRIPT)).toBe('deployed');
|
||||
expect(targets[0]!.cpuMs).toBe(50);
|
||||
});
|
||||
|
||||
it('passes no cpuMs when events.workerCpuMs is unset', async () => {
|
||||
const { layers, targets } = makeLayers({ 'app-g': { ownerUserId: 7 } });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(await deployer.ensure('app-g', SCRIPT)).toBe('deployed');
|
||||
expect(targets[0]!.cpuMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deploys normally under the cap', async () => {
|
||||
const { layers } = makeLayers({ 'app-d': { ownerUserId: 4 } });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
@@ -329,3 +368,62 @@ describe('LocalEventsInvokeTransport', () => {
|
||||
expect(bodies).toEqual(['{"n":1}', '{"n":2}']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deploy-on-miss of an evicted script', () => {
|
||||
it('redeploys the evicted script, then resends the run with the deployed marker', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-m': { ownerUserId: 8 } });
|
||||
// The namespace only has the script once the deploy lands: before
|
||||
// that the dispatcher answers a miss.
|
||||
let resident = false;
|
||||
layers.drivers.workers.create = (async (args: {
|
||||
workerName: string;
|
||||
}) => {
|
||||
created.push(args.workerName);
|
||||
resident = true;
|
||||
return { success: true, errors: [] };
|
||||
}) as never;
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
const sent: boolean[] = [];
|
||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
sent.push(headers[EVENTS_DEPLOYED_HEADER] === '1');
|
||||
if (resident)
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { [EVENTS_HANDLED_HEADER]: '1' },
|
||||
});
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'missing' },
|
||||
});
|
||||
}) as unknown as typeof undiciFetch;
|
||||
|
||||
const client = new EventsWorkerInvokerClient({
|
||||
events: { workerRuntime: true },
|
||||
} as IConfig);
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl,
|
||||
}),
|
||||
);
|
||||
client.setMissHandler((appUid, script, options) =>
|
||||
deployer.ensure(appUid, script, options),
|
||||
);
|
||||
|
||||
const result = await client.invoke({
|
||||
script: SCRIPT,
|
||||
appUid: 'app-m',
|
||||
handler: 'h',
|
||||
token: 't',
|
||||
key: 'k',
|
||||
event: {},
|
||||
ctx: {},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ outcome: 'settled', status: 200 });
|
||||
expect(created).toEqual([SCRIPT]);
|
||||
// One miss, then the resend carrying the deployed marker.
|
||||
expect(sent).toEqual([false, true]);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
generateEventsWorkerSource,
|
||||
handlerSetHash,
|
||||
} from '../../services/events/workerSource.js';
|
||||
import { appHandlerScope } from '../../stores/events/EventHandlerStore.js';
|
||||
import type { puterStores } from '../../stores/index.js';
|
||||
import type { IConfig, LayerInstances } from '../../types.js';
|
||||
import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
|
||||
@@ -208,7 +209,9 @@ export class EventsWorkerDeployer {
|
||||
|
||||
// Names and hashes are enough to tell a stale request, so the set's
|
||||
// sources are read and built only once the throttle has let it through.
|
||||
const set = await stores.eventHandler.setForApp(appUid);
|
||||
const set = await stores.eventHandler.setForApp(
|
||||
appHandlerScope(appUid),
|
||||
);
|
||||
if (set.length === 0) return 'no-handlers';
|
||||
// Two apps that published byte-identical sets share one script, which
|
||||
// is sound: the source is the same, and everything per-app arrives with
|
||||
@@ -218,7 +221,9 @@ export class EventsWorkerDeployer {
|
||||
|
||||
if (await this.#tooManyDeploysThisHour(appUid)) return 'throttled';
|
||||
|
||||
const handlers = await stores.eventHandler.allForApp(appUid);
|
||||
const handlers = await stores.eventHandler.allForApp(
|
||||
appHandlerScope(appUid),
|
||||
);
|
||||
const generated = generateEventsWorkerSource(handlers);
|
||||
// The set can move between the two reads.
|
||||
if (eventsWorkerScript(generated.setHash, scope) !== script)
|
||||
@@ -264,6 +269,7 @@ export class EventsWorkerDeployer {
|
||||
runtime: 'events',
|
||||
source: generated.source,
|
||||
omitOwnerToken: true,
|
||||
cpuMs: this.#layers.config.events?.workerCpuMs,
|
||||
// What the script compares an invocation against.
|
||||
// Derived from the script name, so a redeploy of
|
||||
// the same set binds the same key.
|
||||
|
||||
@@ -754,6 +754,50 @@ describe('WorkerDriver.create with a configured deploy backend', () => {
|
||||
expect(putCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
const uploadedMetadata = () => {
|
||||
const [, init] = putCalls().at(-1)!;
|
||||
const form = (init as RequestInit).body as FormData;
|
||||
return JSON.parse(form.get('metadata') as string);
|
||||
};
|
||||
|
||||
it('sends limits.cpu_ms in the upload metadata when cpuMs is set', async () => {
|
||||
const { user, actor } = await makeUser();
|
||||
const path = `/${user.username}/cpu.worker.js`;
|
||||
await writeSource(actor, user.id, path, 'export default {}');
|
||||
const scriptName = `dt-cpu-${user.username}`;
|
||||
await inCtx(actor, () =>
|
||||
target.create({
|
||||
appId: '',
|
||||
workerName: scriptName,
|
||||
filePath: path,
|
||||
[INTERNAL_DEPLOY_TARGET]: { scriptName, cpuMs: 50 },
|
||||
}),
|
||||
);
|
||||
expect(uploadedMetadata().limits).toEqual({ cpu_ms: 50 });
|
||||
});
|
||||
|
||||
it('sends no limits key without cpuMs, and none on an ordinary create', async () => {
|
||||
const { user, actor } = await makeUser();
|
||||
const path = `/${user.username}/nocpu.worker.js`;
|
||||
await writeSource(actor, user.id, path, 'export default {}');
|
||||
const scriptName = `dt-nocpu-${user.username}`;
|
||||
await deployTo(actor, path, scriptName);
|
||||
expect(uploadedMetadata()).not.toHaveProperty('limits');
|
||||
|
||||
const name = `plain-${user.username}`;
|
||||
await inCtx(actor, () =>
|
||||
target.create({ appId: '', workerName: name, filePath: path }),
|
||||
);
|
||||
const metadata = uploadedMetadata();
|
||||
expect(metadata).not.toHaveProperty('limits');
|
||||
expect(Object.keys(metadata)).toEqual([
|
||||
'body_part',
|
||||
'compatibility_flags',
|
||||
'compatibility_date',
|
||||
'bindings',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses a file another user owns, even with manage on it', async () => {
|
||||
const owner = await makeUser();
|
||||
const deployer = await makeUser();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// however the interactions with the Puter API are
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { Actor, makeActor as resolveActor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { DEFAULT_FREE_SUBSCRIPTION } from '../../services/metering/consts.js';
|
||||
import {
|
||||
setupPuterTestEnv,
|
||||
setupTestServer,
|
||||
@@ -797,6 +799,135 @@ describe('WorkerDriver', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('rented-unit cap', () => {
|
||||
const FREE_CAP = 16;
|
||||
const PAID_CAP = 100;
|
||||
|
||||
// The cap sits after the deploy-credentials gate, so the driver needs
|
||||
// credentials to reach it; a create under the cap then fails on the
|
||||
// missing source file, which is what tells it past the cap check.
|
||||
let originalWorkersConfig: unknown;
|
||||
beforeAll(() => {
|
||||
const driverConfig = (
|
||||
target as unknown as { config: Record<string, unknown> }
|
||||
).config;
|
||||
originalWorkersConfig = driverConfig.workers;
|
||||
driverConfig.workers = {
|
||||
XAUTHKEY: 'test-key',
|
||||
ACCOUNTID: 'test-account',
|
||||
};
|
||||
});
|
||||
afterAll(() => {
|
||||
(
|
||||
target as unknown as { config: Record<string, unknown> }
|
||||
).config.workers = originalWorkersConfig;
|
||||
});
|
||||
|
||||
const holding = (
|
||||
plan: string,
|
||||
held: { workers?: number; scopes?: number; owned?: number },
|
||||
) => {
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getActorSubscription',
|
||||
).mockResolvedValue({ id: plan } as never);
|
||||
vi.spyOn(
|
||||
server.stores.subdomain,
|
||||
'countByUserIdAndPrefix',
|
||||
).mockResolvedValue(held.workers ?? 0);
|
||||
vi.spyOn(
|
||||
server.stores.eventHandler,
|
||||
'countUserScopesWithHandlersForUser',
|
||||
).mockResolvedValue(held.scopes ?? 0);
|
||||
vi.spyOn(
|
||||
server.stores.eventHandler,
|
||||
'countAppsWithHandlersForOwner',
|
||||
).mockResolvedValue(held.owned ?? 0);
|
||||
};
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createWorker = () =>
|
||||
inCtx(() =>
|
||||
target.create({
|
||||
appId: 'test-app',
|
||||
workerName: 'capped',
|
||||
filePath: '/test.js',
|
||||
}),
|
||||
);
|
||||
|
||||
it('refuses a free user one unit past the cap, across all three sources', async () => {
|
||||
for (const held of [
|
||||
{ workers: FREE_CAP },
|
||||
{ scopes: FREE_CAP },
|
||||
{ owned: FREE_CAP },
|
||||
{ workers: 10, scopes: 4, owned: 2 },
|
||||
]) {
|
||||
holding(DEFAULT_FREE_SUBSCRIPTION, held);
|
||||
await expect(createWorker()).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a free user create up to the cap', async () => {
|
||||
holding(DEFAULT_FREE_SUBSCRIPTION, {
|
||||
workers: 10,
|
||||
scopes: 3,
|
||||
owned: 2,
|
||||
});
|
||||
await expect(createWorker()).rejects.not.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a paid user the unit after 100', async () => {
|
||||
holding('paid-plan', { workers: 90, scopes: 5, owned: 5 });
|
||||
await expect(createWorker()).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
holding('paid-plan', { workers: 90, scopes: 5, owned: 4 });
|
||||
await expect(createWorker()).rejects.not.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(PAID_CAP).toBe(100);
|
||||
});
|
||||
|
||||
it('keeps a free user over the cap, redeploys what they hold, and refuses a new one', async () => {
|
||||
holding(DEFAULT_FREE_SUBSCRIPTION, { workers: 20 });
|
||||
const user = actor.user;
|
||||
const exists = vi
|
||||
.spyOn(server.stores.subdomain, 'getBySubdomain')
|
||||
.mockImplementation(
|
||||
async (name: string) =>
|
||||
(name === 'workers.puter.capped'
|
||||
? { user_id: user.id, uuid: 'existing-uuid' }
|
||||
: null) as never,
|
||||
);
|
||||
const noApp = makeActor({ app: undefined });
|
||||
const create = (workerName: string) =>
|
||||
inCtx(
|
||||
() =>
|
||||
target.create({
|
||||
appId: '',
|
||||
workerName,
|
||||
filePath: '/test.js',
|
||||
}),
|
||||
noApp,
|
||||
);
|
||||
|
||||
await expect(create('capped')).rejects.not.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
await expect(create('another')).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
exists.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('admission bypass', () => {
|
||||
const unverified = (): Actor =>
|
||||
makeActor({
|
||||
@@ -864,7 +995,7 @@ describe('WorkerDriver', () => {
|
||||
// with an account id also demands a built worker preamble, which
|
||||
// is not built for backend tests.
|
||||
const store = server.stores.subdomain;
|
||||
const originalList = store.listByUserIdAndPrefix.bind(store);
|
||||
const originalCount = store.countByUserIdAndPrefix.bind(store);
|
||||
const driverConfig = (
|
||||
target as unknown as { config: Record<string, unknown> }
|
||||
).config;
|
||||
@@ -873,13 +1004,11 @@ describe('WorkerDriver', () => {
|
||||
XAUTHKEY: 'test-key',
|
||||
ACCOUNTID: 'test-account',
|
||||
};
|
||||
let listCalls = 0;
|
||||
store.listByUserIdAndPrefix = (async () => {
|
||||
listCalls++;
|
||||
return Array.from({ length: 100 }, (_, i) => ({
|
||||
subdomain: `workers.puter.w${i}`,
|
||||
})) as never;
|
||||
}) as typeof store.listByUserIdAndPrefix;
|
||||
let countCalls = 0;
|
||||
store.countByUserIdAndPrefix = (async () => {
|
||||
countCalls++;
|
||||
return 100;
|
||||
}) as typeof store.countByUserIdAndPrefix;
|
||||
|
||||
try {
|
||||
await expect(
|
||||
@@ -891,7 +1020,7 @@ describe('WorkerDriver', () => {
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
expect(listCalls).toBe(1);
|
||||
expect(countCalls).toBe(1);
|
||||
|
||||
// With the bypass the limit is never even counted; the call
|
||||
// fails later, on the missing source file.
|
||||
@@ -905,9 +1034,9 @@ describe('WorkerDriver', () => {
|
||||
}),
|
||||
),
|
||||
).rejects.not.toMatchObject({ statusCode: 403 });
|
||||
expect(listCalls).toBe(1);
|
||||
expect(countCalls).toBe(1);
|
||||
} finally {
|
||||
store.listByUserIdAndPrefix = originalList;
|
||||
store.countByUserIdAndPrefix = originalCount;
|
||||
driverConfig.workers = originalWorkersConfig;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ import { PuterDriver } from '../types.js';
|
||||
import { BoundedTtlMap } from '../../util/boundedTtlMap.js';
|
||||
import { awaitLater } from '../../util/concurrency.js';
|
||||
import { isUniqueViolation } from '../../util/dbError.js';
|
||||
import { assertRentedUnitRoom } from '../../util/rentedUnits.js';
|
||||
import { loadFileInput } from '../util/fileInput.js';
|
||||
import {
|
||||
normalizeLimit,
|
||||
@@ -57,7 +58,6 @@ const CF_BASE_URL = 'https://api.cloudflare.com/client/v4/accounts';
|
||||
export const WORKER_COMPATIBILITY_DATE = '2025-07-15';
|
||||
export const WORKER_COMPATIBILITY_FLAGS = ['global_fetch_strictly_public'];
|
||||
const WORKER_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
const MAX_WORKERS_PER_USER = 100;
|
||||
|
||||
/**
|
||||
* Opt-in key for `create()` that skips the admission checks a _new_ worker has
|
||||
@@ -179,12 +179,17 @@ export interface InternalDeployTarget {
|
||||
* scripts belong to what.
|
||||
*/
|
||||
tags?: string[];
|
||||
/**
|
||||
* Per-invocation CPU limit in milliseconds, sent with the upload. Unset
|
||||
* sends no limit. Ignored by the local runtime.
|
||||
*/
|
||||
cpuMs?: number;
|
||||
}
|
||||
|
||||
const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
// How far to scan an app's child apps when resolving which workers it may
|
||||
// see. A user is capped at MAX_WORKERS_PER_USER workers, so only that many
|
||||
// child apps can actually own one; the ceiling is generous slack over that.
|
||||
// see. A user is capped at their plan's rented units, so only that many
|
||||
// child apps can actually own a worker; the ceiling is generous slack over that.
|
||||
const CHILD_APP_SCAN_LIMIT = 1000;
|
||||
|
||||
// -- Preamble --------------------------------------------------------
|
||||
@@ -452,25 +457,16 @@ export class WorkerDriver extends PuterDriver {
|
||||
|
||||
this.#requireCfConfig();
|
||||
|
||||
// Quota check — count existing workers.puter.* subdomains owned by user
|
||||
//
|
||||
// A deploy target creates no such row, so this count can neither see
|
||||
// its scripts nor be grown by them; running it would only make an
|
||||
// unrelated worker quota decide whether these deploy. Whatever bounds
|
||||
// them has to be counted where they actually live, and belongs to the
|
||||
// caller that owns their lifecycle.
|
||||
const existingWorkers =
|
||||
skipAdmission || deployTarget
|
||||
? []
|
||||
: await this.stores.subdomain.listByUserIdAndPrefix(
|
||||
actor.user.id,
|
||||
WORKER_SUBDOMAIN_PREFIX,
|
||||
);
|
||||
if (existingWorkers.length >= MAX_WORKERS_PER_USER) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
`Worker limit reached (max ${MAX_WORKERS_PER_USER})`,
|
||||
{ legacyCode: 'forbidden' },
|
||||
// A new worker is one more rented unit. Deploy targets create no
|
||||
// worker row and are bounded by their own caller; redeploying an
|
||||
// existing worker is never refused.
|
||||
if (!skipAdmission && !deployTarget && !existingSub) {
|
||||
await assertRentedUnitRoom(
|
||||
{
|
||||
stores: this.stores,
|
||||
metering: this.services.metering,
|
||||
},
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -940,6 +936,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
compatibility_flags: WORKER_COMPATIBILITY_FLAGS,
|
||||
compatibility_date: WORKER_COMPATIBILITY_DATE,
|
||||
...(target?.tags ? { tags: target.tags } : {}),
|
||||
...(target?.cpuMs ? { limits: { cpu_ms: target.cpuMs } } : {}),
|
||||
bindings: [
|
||||
// A script deployed without a token gets no binding at all,
|
||||
// rather than an empty one for its code to find.
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
EVENTS_SUBSCRIBE_LIMIT,
|
||||
EVENTS_UNSUBSCRIBE_LIMIT,
|
||||
EVENTS_WORKER_INVOCATION_LIMIT,
|
||||
EVENTS_USER_SCOPE_SOURCE_MAX_BYTES,
|
||||
EVENTS_WORKER_SOURCE_MAX_BYTES,
|
||||
limitFor,
|
||||
SUSPENDED_ROW_TTL_DAYS,
|
||||
@@ -73,9 +74,15 @@ import {
|
||||
type DurableReap,
|
||||
} from '../../stores/events/DurableSubscriptionStore.js';
|
||||
import {
|
||||
appHandlerScope,
|
||||
EVENTS_WORKERS_LIST_DEFAULT_LIMIT,
|
||||
EVENTS_WORKERS_LIST_LIMIT_CAP,
|
||||
hashContent,
|
||||
type EventHandlerSummary,
|
||||
type EventsWorkerSummary,
|
||||
type HandlerScope,
|
||||
type PublishOutcome,
|
||||
type UserScopeSummary,
|
||||
} from '../../stores/events/EventHandlerStore.js';
|
||||
import {
|
||||
SESSION_SUBSCRIPTION_TTL_SECONDS,
|
||||
@@ -91,6 +98,7 @@ import type {
|
||||
import {
|
||||
isScheduleRow,
|
||||
runsInWorker,
|
||||
type HandlerScopeKind,
|
||||
type SubscriptionPermission,
|
||||
} from '../../stores/events/types.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
@@ -124,7 +132,11 @@ import {
|
||||
} from '../socket/SocketService.js';
|
||||
import { savedAfterId, type JobRunContext } from '../jobs/JobScheduler.js';
|
||||
import { PuterService } from '../types.js';
|
||||
import { DEFAULT_FREE_SUBSCRIPTION } from '../metering/consts.js';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
} from '../metering/consts.js';
|
||||
import { assertRentedUnitRoom } from '../../util/rentedUnits.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import {
|
||||
resolveFsAnchor,
|
||||
@@ -183,6 +195,8 @@ import {
|
||||
parseContext,
|
||||
parseHandlerHash,
|
||||
parseHandlerName,
|
||||
parseHandlerScopeKind,
|
||||
parseHandlerSource,
|
||||
projectContext,
|
||||
spendUserWindow,
|
||||
} from './requestParsing.js';
|
||||
@@ -354,6 +368,13 @@ export interface DurableSubscribeRequest extends SubscribeRequest {
|
||||
* it matches what the app actually published.
|
||||
*/
|
||||
handlerHash?: unknown;
|
||||
/**
|
||||
* Source of an inline handler, sent without a `handlerName`. Published into
|
||||
* the caller's user scope under a name derived from it.
|
||||
*/
|
||||
handlerSource?: unknown;
|
||||
/** `'user'` binds `handlerName` to the caller's user scope. */
|
||||
handlerScope?: unknown;
|
||||
context?: unknown;
|
||||
expiresAt?: unknown;
|
||||
}
|
||||
@@ -361,6 +382,8 @@ export interface DurableSubscribeRequest extends SubscribeRequest {
|
||||
/** Body of `POST /events/handlers/publish`. */
|
||||
export interface PublishHandlerRequest {
|
||||
appUid?: unknown;
|
||||
/** `'user'` publishes into the caller's user scope; absent means the app's. */
|
||||
scope?: unknown;
|
||||
name?: unknown;
|
||||
source?: unknown;
|
||||
/** The published hash this publish is an update to. */
|
||||
@@ -371,17 +394,20 @@ export interface PublishHandlerRequest {
|
||||
/** Body of `POST /events/handlers/publishAll`. */
|
||||
export interface PublishHandlersRequest {
|
||||
appUid?: unknown;
|
||||
scope?: unknown;
|
||||
handlers?: unknown;
|
||||
}
|
||||
|
||||
/** Body of `POST /events/handlers/remove`, and the shape `list` is scoped by. */
|
||||
export interface HandlerNameRequest {
|
||||
appUid?: unknown;
|
||||
scope?: unknown;
|
||||
name?: unknown;
|
||||
}
|
||||
|
||||
export interface HandlerScopeRequest {
|
||||
appUid?: unknown;
|
||||
scope?: unknown;
|
||||
}
|
||||
|
||||
/** What one publish reports back. Never carries source. */
|
||||
@@ -412,13 +438,21 @@ export interface ListEventsWorkersRequest {
|
||||
|
||||
/** One events worker as its owner's listing reports it. */
|
||||
export interface EventsWorkerView extends EventsWorkerSummary {
|
||||
scope: 'app';
|
||||
/** Deployed script name, for support/diagnosis. */
|
||||
script: string;
|
||||
}
|
||||
|
||||
/** The caller's own scope of one app, as the same listing reports it. */
|
||||
export interface UserScopeWorkerView extends UserScopeSummary {
|
||||
scope: 'user';
|
||||
}
|
||||
|
||||
/** Body of `POST /events/workers/destroy`. */
|
||||
export interface DestroyEventsWorkerRequest {
|
||||
appUid?: unknown;
|
||||
/** `'user'` destroys the caller's user scope of the app. */
|
||||
scope?: unknown;
|
||||
}
|
||||
|
||||
/** What destroying an app's events worker did. */
|
||||
@@ -426,6 +460,16 @@ export interface DestroyedEventsWorkerView {
|
||||
appUid: string;
|
||||
removed: number;
|
||||
suspended: number;
|
||||
/** Present for a user scope. */
|
||||
scope?: 'user';
|
||||
}
|
||||
|
||||
/** What `ensureInlineHandler` binds a row to. */
|
||||
export interface InlineHandlerView {
|
||||
name: string;
|
||||
/** The published source's hash; for an inline handler, the name without `@`. */
|
||||
hash: string;
|
||||
scope: 'user';
|
||||
}
|
||||
|
||||
export interface DurableListRequest {
|
||||
@@ -479,6 +523,8 @@ export interface DurableSubscriptionView extends SubscriptionView {
|
||||
expiresAt: number | null;
|
||||
suspendedAt: number | null;
|
||||
suspendedReason: string | null;
|
||||
/** Present for a row bound to a user-scope handler. */
|
||||
handlerScope?: 'user';
|
||||
}
|
||||
|
||||
export type VerbAck<T extends object> =
|
||||
@@ -852,9 +898,13 @@ const scheduleImmutable = (): HttpError =>
|
||||
);
|
||||
|
||||
const handlerRequired = (): HttpError =>
|
||||
new HttpError(400, 'A `queue` subscription needs a handlerName', {
|
||||
legacyCode: 'events_handler_required',
|
||||
});
|
||||
new HttpError(
|
||||
400,
|
||||
'A `queue` subscription needs a handlerName or handlerSource',
|
||||
{
|
||||
legacyCode: 'events_handler_required',
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* A temporary account gets session subscriptions and nothing else. A durable
|
||||
@@ -893,10 +943,12 @@ const noEventsWorker = (appUid: string): HttpError =>
|
||||
* into one file — would exceed what the runtime may load. Refused before the
|
||||
* write rather than left to fail at deploy time.
|
||||
*/
|
||||
const eventsWorkerTooLarge = (): HttpError =>
|
||||
const eventsWorkerTooLarge = (scope: HandlerScope): HttpError =>
|
||||
new HttpError(
|
||||
413,
|
||||
`This app's events worker would exceed ${EVENTS_WORKER_SOURCE_MAX_BYTES} bytes across its published handlers`,
|
||||
isUserScope(scope)
|
||||
? `A user scope's handlers may not exceed ${EVENTS_USER_SCOPE_SOURCE_MAX_BYTES} bytes combined`
|
||||
: `This app's events worker would exceed ${EVENTS_WORKER_SOURCE_MAX_BYTES} bytes across its published handlers`,
|
||||
{ legacyCode: 'events_worker_too_large' },
|
||||
);
|
||||
|
||||
@@ -939,6 +991,37 @@ const handlerAppForbidden = (): HttpError =>
|
||||
legacyCode: 'events_handler_forbidden',
|
||||
});
|
||||
|
||||
/**
|
||||
* User-scope handlers are published by an app acting for the signed-in user, so
|
||||
* a scoped token, a temporary account or an app other than the caller's has no
|
||||
* scope to publish into.
|
||||
*/
|
||||
const userScopeForbidden = (): HttpError =>
|
||||
new HttpError(
|
||||
403,
|
||||
'Only an app acting for a signed-in user may publish into a user scope',
|
||||
{ legacyCode: 'events_handler_forbidden' },
|
||||
);
|
||||
|
||||
const userHandlersDisabled = (): HttpError =>
|
||||
new HttpError(503, 'User-scope handlers are not enabled on this server', {
|
||||
legacyCode: 'events_user_handlers_disabled',
|
||||
});
|
||||
|
||||
const invalidInlineName = (): HttpError =>
|
||||
badRequest(
|
||||
'Names starting with `@` are reserved for inline handlers',
|
||||
'events_handler_name_invalid',
|
||||
);
|
||||
|
||||
const isUserScope = (scope: HandlerScope): boolean => scope.holder !== 0;
|
||||
|
||||
const kindOf = (scope: HandlerScope): HandlerScopeKind =>
|
||||
isUserScope(scope) ? 'user' : 'app';
|
||||
|
||||
/** The name an inline handler is published under: its source's hash. */
|
||||
const inlineHandlerName = (source: string): string => `@${hashContent(source)}`;
|
||||
|
||||
/**
|
||||
* A handler runs with nothing around it, so a name it neither binds nor gets
|
||||
* from the runtime fails on its first delivery. Refused at publish instead.
|
||||
@@ -1004,6 +1087,7 @@ const toDurableView = (sub: DurableSubscription): DurableSubscriptionView => ({
|
||||
...toView(sub),
|
||||
queue: sub.delivery === 'single',
|
||||
handlerName: sub.handlerName,
|
||||
...(sub.handlerScope === 'user' ? { handlerScope: 'user' as const } : {}),
|
||||
appUid: sub.appUid,
|
||||
...projectContext(sub.context),
|
||||
createdAt: sub.createdAt,
|
||||
@@ -1034,6 +1118,9 @@ const workerRuntimeReady = (config: IConfig): boolean =>
|
||||
|
||||
const nowSeconds = (): number => Math.floor(Date.now() / 1000);
|
||||
|
||||
/** Prefix of a worker-listing cursor that is past the apps' own workers. */
|
||||
const USER_PART_CURSOR = 'user:';
|
||||
|
||||
/** Past `expiresAt` a durable row is finished, sweep or no sweep. */
|
||||
const unexpired = (row: DispatchSubscription): boolean => {
|
||||
if (row.durable !== true) return true;
|
||||
@@ -1588,6 +1675,11 @@ export class EventsService extends PuterService {
|
||||
return this.config.events?.kvHandles === true;
|
||||
}
|
||||
|
||||
/** Whether an app may publish handlers into a user's own scope. */
|
||||
get userHandlersEnabled(): boolean {
|
||||
return this.config.events?.userHandlers === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether notification delivery runs through dispatch. Off, notifications
|
||||
* take the path they always have and nothing here sees them.
|
||||
@@ -1864,12 +1956,36 @@ export class EventsService extends PuterService {
|
||||
const delivery = parseDelivery(request ?? {});
|
||||
const appUid = actor.effectiveApp?.uid ?? null;
|
||||
const limits = await this.#subscriptionQuota(actor);
|
||||
const handlerName = parseHandlerName(request?.handlerName);
|
||||
const named = parseHandlerName(request?.handlerName);
|
||||
const handlerHash = parseHandlerHash(request?.handlerHash);
|
||||
const handlerSource = parseHandlerSource(request?.handlerSource);
|
||||
const handlerScope = parseHandlerScopeKind(
|
||||
request?.handlerScope,
|
||||
'handlerScope',
|
||||
);
|
||||
const context = parseContext(request?.context);
|
||||
const expiresAt = parseExpiresAt(request?.expiresAt);
|
||||
const includeValue = parseIncludeValue(request?.includeValue);
|
||||
|
||||
if (
|
||||
handlerSource !== null &&
|
||||
(named || request?.handlerScope === 'app')
|
||||
)
|
||||
throw badRequest(
|
||||
'handlerSource is its own handler: send it without handlerName or an app handlerScope',
|
||||
'bad_request',
|
||||
);
|
||||
if (handlerScope === 'user' && !named && handlerSource === null)
|
||||
throw badRequest(
|
||||
"handlerScope 'user' needs a handlerName",
|
||||
'bad_request',
|
||||
);
|
||||
// An inline handler is named by its source, so the name is known
|
||||
// before anything is published.
|
||||
const handlerName =
|
||||
handlerSource !== null ? inlineHandlerName(handlerSource) : named;
|
||||
const userScoped = handlerSource !== null || handlerScope === 'user';
|
||||
|
||||
// A queued event is owed to exactly one consumer, and the handler is
|
||||
// the only one that is always there to take it.
|
||||
if (delivery === 'single' && !handlerName) throw handlerRequired();
|
||||
@@ -1882,13 +1998,35 @@ export class EventsService extends PuterService {
|
||||
)
|
||||
throw backgroundConsentRequired();
|
||||
|
||||
if (handlerName)
|
||||
await this.assertHandlerBinding(appUid, handlerName, handlerHash);
|
||||
if (handlerName && handlerSource === null) {
|
||||
if (userScoped) {
|
||||
const scope = await this.#userScope(actor, appUid, {
|
||||
requireFlag: true,
|
||||
});
|
||||
await this.assertHandlerBinding(
|
||||
appUid,
|
||||
handlerName,
|
||||
handlerHash,
|
||||
{ holder: scope.holder },
|
||||
);
|
||||
} else {
|
||||
await this.assertHandlerBinding(
|
||||
appUid,
|
||||
handlerName,
|
||||
handlerHash,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rawSubject = String(request?.subject ?? '');
|
||||
if (includeValue) assertValueDeliverable(rawSubject);
|
||||
const anchor = await this.#resolveSubscribeAnchor(actor, rawSubject);
|
||||
|
||||
// After the anchor, so a subject that won't resolve publishes nothing.
|
||||
// A row create that fails past this leaves the handler for the sweep.
|
||||
if (handlerSource !== null)
|
||||
await this.ensureInlineHandler(actor, appUid, handlerSource);
|
||||
|
||||
let created: DurableCreated;
|
||||
try {
|
||||
created = await this.stores.durableSubscription.create({
|
||||
@@ -1903,6 +2041,7 @@ export class EventsService extends PuterService {
|
||||
op: anchor.op,
|
||||
delivery,
|
||||
handlerName,
|
||||
...(userScoped ? { handlerScope: 'user' as const } : {}),
|
||||
context,
|
||||
permission: anchor.permission,
|
||||
expiresAt,
|
||||
@@ -2344,10 +2483,17 @@ export class EventsService extends PuterService {
|
||||
request: PublishHandlerRequest,
|
||||
): Promise<PublishedHandlerView> {
|
||||
await this.#spendHandlerBudget(actor);
|
||||
const appUid = await this.#handlerApp(actor, request?.appUid);
|
||||
const before = awaitLater(this.stores.eventHandler.countForApp(appUid));
|
||||
const result = await this.#publishOne(appUid, request, before);
|
||||
await this.#maybeAnnounceWorkerCreate(appUid, await before);
|
||||
const scope = await this.#handlerApp(
|
||||
actor,
|
||||
request?.appUid,
|
||||
parseHandlerScopeKind(request?.scope, 'scope'),
|
||||
true,
|
||||
);
|
||||
const before = awaitLater(this.stores.eventHandler.countForApp(scope));
|
||||
const result = await this.#publishOne(actor, scope, request, before, {
|
||||
pending: true,
|
||||
});
|
||||
await this.#maybeAnnounceWorkerCreate(scope, await before);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2362,7 +2508,12 @@ export class EventsService extends PuterService {
|
||||
request: PublishHandlersRequest,
|
||||
): Promise<PublishedHandlerView[]> {
|
||||
await this.#spendHandlerBudget(actor);
|
||||
const appUid = await this.#handlerApp(actor, request?.appUid);
|
||||
const scope = await this.#handlerApp(
|
||||
actor,
|
||||
request?.appUid,
|
||||
parseHandlerScopeKind(request?.scope, 'scope'),
|
||||
true,
|
||||
);
|
||||
const handlers = request?.handlers;
|
||||
if (!Array.isArray(handlers) || handlers.length === 0)
|
||||
throw badRequest(
|
||||
@@ -2375,7 +2526,8 @@ export class EventsService extends PuterService {
|
||||
'bad_request',
|
||||
);
|
||||
|
||||
const before = awaitLater(this.stores.eventHandler.countForApp(appUid));
|
||||
const before = awaitLater(this.stores.eventHandler.countForApp(scope));
|
||||
const unitCheck = { pending: true };
|
||||
const published: PublishedHandlerView[] = [];
|
||||
try {
|
||||
for (const item of handlers) {
|
||||
@@ -2390,9 +2542,11 @@ export class EventsService extends PuterService {
|
||||
}
|
||||
published.push(
|
||||
await this.#publishOne(
|
||||
appUid,
|
||||
actor,
|
||||
scope,
|
||||
item as PublishHandlerRequest,
|
||||
before,
|
||||
unitCheck,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2400,7 +2554,7 @@ export class EventsService extends PuterService {
|
||||
// A refused item stops the pass but leaves the ones before it
|
||||
// published, and those are what stood the worker up.
|
||||
if (published.length > 0)
|
||||
await this.#maybeAnnounceWorkerCreate(appUid, await before);
|
||||
await this.#maybeAnnounceWorkerCreate(scope, await before);
|
||||
}
|
||||
return published;
|
||||
}
|
||||
@@ -2415,7 +2569,11 @@ export class EventsService extends PuterService {
|
||||
request: HandlerScopeRequest = {},
|
||||
): Promise<EventHandlerSummary[]> {
|
||||
return this.stores.eventHandler.listForApp(
|
||||
await this.#handlerApp(actor, request?.appUid),
|
||||
await this.#handlerApp(
|
||||
actor,
|
||||
request?.appUid,
|
||||
parseHandlerScopeKind(request?.scope, 'scope'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2430,13 +2588,17 @@ export class EventsService extends PuterService {
|
||||
request: HandlerNameRequest,
|
||||
): Promise<RemovedHandlerView> {
|
||||
await this.#spendHandlerBudget(actor);
|
||||
const appUid = await this.#handlerApp(actor, request?.appUid);
|
||||
const scope = await this.#handlerApp(
|
||||
actor,
|
||||
request?.appUid,
|
||||
parseHandlerScopeKind(request?.scope, 'scope'),
|
||||
);
|
||||
const name = parseHandlerName(request?.name);
|
||||
if (!name) throw badRequest('name is required', 'bad_request');
|
||||
|
||||
const removed = await this.stores.eventHandler.remove(appUid, name);
|
||||
const suspended = await this.#suspendHandlerDependents(appUid, name);
|
||||
if (removed) await this.#maybeAnnounceWorkerDestroy(appUid);
|
||||
const removed = await this.stores.eventHandler.remove(scope, name);
|
||||
const suspended = await this.#suspendHandlerDependents(scope, name);
|
||||
if (removed) await this.#maybeAnnounceWorkerDestroy(scope);
|
||||
return { name, removed: removed !== null, suspended };
|
||||
}
|
||||
|
||||
@@ -2456,7 +2618,7 @@ export class EventsService extends PuterService {
|
||||
actor: Actor,
|
||||
request: ListEventsWorkersRequest = {},
|
||||
): Promise<{
|
||||
items: EventsWorkerView[];
|
||||
items: Array<EventsWorkerView | UserScopeWorkerView>;
|
||||
cursor?: string;
|
||||
deployable: boolean;
|
||||
}> {
|
||||
@@ -2474,32 +2636,73 @@ export class EventsService extends PuterService {
|
||||
const ownerUserId = actor.user?.id;
|
||||
if (ownerUserId === undefined) throw disabled();
|
||||
|
||||
const page = await this.stores.eventHandler.listEventsWorkersForOwner(
|
||||
const limit = Math.min(
|
||||
Math.max(
|
||||
1,
|
||||
Math.floor(request.limit ?? EVENTS_WORKERS_LIST_DEFAULT_LIMIT),
|
||||
),
|
||||
EVENTS_WORKERS_LIST_LIMIT_CAP,
|
||||
);
|
||||
const deployable = this.config.events?.workerRuntime === true;
|
||||
const items: Array<EventsWorkerView | UserScopeWorkerView> = [];
|
||||
|
||||
// The apps' own workers come first, then the caller's user scopes; a
|
||||
// `user:` cursor marks the second part.
|
||||
const inUserPart =
|
||||
request.cursor?.startsWith(USER_PART_CURSOR) ?? false;
|
||||
if (!inUserPart) {
|
||||
const page =
|
||||
await this.stores.eventHandler.listEventsWorkersForOwner(
|
||||
ownerUserId,
|
||||
{
|
||||
limit,
|
||||
cursor: request.cursor,
|
||||
...(acting ? { appUid: acting.uid } : {}),
|
||||
},
|
||||
);
|
||||
for (const worker of page.items) {
|
||||
const handlers = await this.stores.eventHandler.setForApp(
|
||||
appHandlerScope(worker.appUid),
|
||||
);
|
||||
items.push({
|
||||
...worker,
|
||||
scope: 'app',
|
||||
script: eventsWorkerScript(
|
||||
handlerSetHash(handlers),
|
||||
eventsWorkerScope(this.config),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (page.cursor) return { items, cursor: page.cursor, deployable };
|
||||
}
|
||||
|
||||
const room = limit - items.length;
|
||||
const scopes = await this.stores.eventHandler.listUserScopesForUser(
|
||||
ownerUserId,
|
||||
{
|
||||
limit: request.limit,
|
||||
cursor: request.cursor,
|
||||
limit: Math.max(room, 1),
|
||||
cursor: inUserPart
|
||||
? request.cursor!.slice(USER_PART_CURSOR.length)
|
||||
: undefined,
|
||||
...(acting ? { appUid: acting.uid } : {}),
|
||||
},
|
||||
);
|
||||
const items: EventsWorkerView[] = [];
|
||||
for (const worker of page.items) {
|
||||
const handlers = await this.stores.eventHandler.setForApp(
|
||||
worker.appUid,
|
||||
);
|
||||
items.push({
|
||||
...worker,
|
||||
script: eventsWorkerScript(
|
||||
handlerSetHash(handlers),
|
||||
eventsWorkerScope(this.config),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (room <= 0)
|
||||
return {
|
||||
items,
|
||||
...((scopes.total ?? 0) > 0
|
||||
? { cursor: USER_PART_CURSOR }
|
||||
: {}),
|
||||
deployable,
|
||||
};
|
||||
for (const scope of scopes.items)
|
||||
items.push({ ...scope, scope: 'user' });
|
||||
return {
|
||||
items,
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
deployable: this.config.events?.workerRuntime === true,
|
||||
...(scopes.cursor
|
||||
? { cursor: `${USER_PART_CURSOR}${scopes.cursor}` }
|
||||
: {}),
|
||||
deployable,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2513,29 +2716,49 @@ export class EventsService extends PuterService {
|
||||
request: DestroyEventsWorkerRequest,
|
||||
): Promise<DestroyedEventsWorkerView> {
|
||||
await this.#spendHandlerBudget(actor);
|
||||
const appUid = await this.#handlerApp(actor, request?.appUid);
|
||||
const scope = await this.#handlerApp(
|
||||
actor,
|
||||
request?.appUid,
|
||||
parseHandlerScopeKind(request?.scope, 'scope'),
|
||||
);
|
||||
const { appUid } = scope;
|
||||
|
||||
const names = (await this.stores.eventHandler.listForApp(appUid)).map(
|
||||
const names = (await this.stores.eventHandler.listForApp(scope)).map(
|
||||
(handler) => handler.name,
|
||||
);
|
||||
if (names.length === 0) throw noEventsWorker(appUid);
|
||||
|
||||
if (isUserScope(scope)) {
|
||||
const removed = await this.stores.eventHandler.deleteUserScope(
|
||||
appUid,
|
||||
scope.holder,
|
||||
);
|
||||
let suspended = 0;
|
||||
for (const name of names)
|
||||
suspended += await this.#suspendHandlerDependents(scope, name);
|
||||
if (removed > 0) {
|
||||
await this.#maybeAnnounceWorkerDestroy(scope);
|
||||
await this.#revokeWorkerSession(scope.holder, appUid);
|
||||
}
|
||||
return { appUid, removed, suspended, scope: 'user' };
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
let suspended = 0;
|
||||
try {
|
||||
for (const name of names) {
|
||||
const droppedRow = await this.stores.eventHandler.remove(
|
||||
appUid,
|
||||
scope,
|
||||
name,
|
||||
);
|
||||
if (droppedRow) removed += 1;
|
||||
suspended += await this.#suspendHandlerDependents(appUid, name);
|
||||
suspended += await this.#suspendHandlerDependents(scope, name);
|
||||
}
|
||||
} finally {
|
||||
// A pass that stops partway may still have taken the last handler
|
||||
// with it, and the announce is what a rent listener stops on.
|
||||
if (removed > 0) {
|
||||
await this.#maybeAnnounceWorkerDestroy(appUid);
|
||||
await this.#maybeAnnounceWorkerDestroy(scope);
|
||||
// The worker is gone; nothing should still be able to invoke
|
||||
// under the session it was running deliveries with. Rows stay
|
||||
// suspended `handler_not_found` — publishing again mints a
|
||||
@@ -2998,11 +3221,26 @@ export class EventsService extends PuterService {
|
||||
*
|
||||
* An app that is not there answers the same as one the caller does not own
|
||||
* — which apps exist is not this surface's to disclose.
|
||||
*
|
||||
* `user` scope is the caller's own handlers in the app they are acting as,
|
||||
* which any signed-in session of that app may publish. `publishing` is what
|
||||
* creates handlers, so it alone needs the flag and some credit; a user out
|
||||
* of credit can still list and remove what they have.
|
||||
*/
|
||||
async #handlerApp(actor: Actor, requested: unknown): Promise<string> {
|
||||
async #handlerApp(
|
||||
actor: Actor,
|
||||
requested: unknown,
|
||||
kind: HandlerScopeKind = 'app',
|
||||
publishing = false,
|
||||
): Promise<HandlerScope> {
|
||||
if (!this.enabled) throw disabled();
|
||||
const userId = actor.user?.id;
|
||||
if (userId === undefined) throw disabled();
|
||||
if (kind === 'user')
|
||||
return this.#userScope(actor, requested, {
|
||||
requireFlag: publishing,
|
||||
requireCredit: publishing,
|
||||
});
|
||||
|
||||
const acting = actor.effectiveApp;
|
||||
// Unresolved is not "no app": reading it that way is what would let an
|
||||
@@ -3029,7 +3267,38 @@ export class EventsService extends PuterService {
|
||||
Number(userId)
|
||||
)
|
||||
throw handlerAppForbidden();
|
||||
return appUid;
|
||||
return appHandlerScope(appUid);
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's user scope of the app they are acting as. A scoped token, an
|
||||
* unresolved or absent app, another app than the caller's, and a temporary
|
||||
* account have no such scope.
|
||||
*/
|
||||
async #userScope(
|
||||
actor: Actor,
|
||||
requested: unknown,
|
||||
checks: { requireFlag?: boolean; requireCredit?: boolean } = {},
|
||||
): Promise<HandlerScope> {
|
||||
const userId = actor.user?.id;
|
||||
if (userId === undefined) throw disabled();
|
||||
if (checks.requireFlag && !this.userHandlersEnabled)
|
||||
throw userHandlersDisabled();
|
||||
|
||||
const acting = actor.effectiveApp;
|
||||
if (!acting || isAccessTokenActor(actor)) throw userScopeForbidden();
|
||||
const named = parseAppUid(requested);
|
||||
if (named !== null && named !== acting.uid) throw userScopeForbidden();
|
||||
if ((await this.#planId(actor)) === DEFAULT_TEMP_SUBSCRIPTION)
|
||||
throw userScopeForbidden();
|
||||
|
||||
if (checks.requireCredit)
|
||||
await assertActorHasCredits(
|
||||
this.services.metering,
|
||||
actor,
|
||||
this.config,
|
||||
);
|
||||
return { appUid: acting.uid, holder: userId };
|
||||
}
|
||||
|
||||
async #spendHandlerBudget(actor: Actor): Promise<void> {
|
||||
@@ -3044,21 +3313,36 @@ export class EventsService extends PuterService {
|
||||
* writes.
|
||||
*/
|
||||
async #publishOne(
|
||||
appUid: string,
|
||||
actor: Actor,
|
||||
scope: HandlerScope,
|
||||
item: PublishHandlerRequest,
|
||||
before: Promise<number>,
|
||||
unitCheck: { pending: boolean },
|
||||
): Promise<PublishedHandlerView> {
|
||||
const name = String(item?.name ?? '');
|
||||
const source = typeof item?.source === 'string' ? item.source : '';
|
||||
assertNoFreeVariables(source);
|
||||
// Minted names stay the server's, or a name could point at other code.
|
||||
if (
|
||||
isUserScope(scope) &&
|
||||
name.startsWith('@') &&
|
||||
name !== inlineHandlerName(source)
|
||||
)
|
||||
throw invalidInlineName();
|
||||
const sizeChecked = awaitLater(
|
||||
this.#assertHandlerSetSize(appUid, name, source),
|
||||
this.#assertHandlerSetSize(scope, name, source),
|
||||
);
|
||||
await before;
|
||||
const existing = await before;
|
||||
await sizeChecked;
|
||||
// The first handler of a scope is a new unit to rent; once per request,
|
||||
// since a batch's own earlier items would otherwise count against it.
|
||||
if (existing === 0 && unitCheck.pending) {
|
||||
await this.#assertUnitRoom(actor);
|
||||
unitCheck.pending = false;
|
||||
}
|
||||
|
||||
const { handler, outcome } = await this.stores.eventHandler.publish({
|
||||
appUid,
|
||||
scope,
|
||||
name,
|
||||
source,
|
||||
ifHash: typeof item?.ifHash === 'string' ? item.ifHash : null,
|
||||
@@ -3070,10 +3354,17 @@ export class EventsService extends PuterService {
|
||||
hash: handler.sourceHash,
|
||||
updatedAt: handler.updatedAt,
|
||||
outcome,
|
||||
resumed: await this.#resumeHandlerDependents(appUid, handler.name),
|
||||
resumed: await this.#resumeHandlerDependents(scope, handler.name),
|
||||
};
|
||||
}
|
||||
|
||||
#assertUnitRoom(actor: Actor): Promise<void> {
|
||||
return assertRentedUnitRoom(
|
||||
{ stores: this.stores, metering: this.services.metering },
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a publish that would push the app's events worker — every
|
||||
* handler's source, baked into one file — over the size cap. The name being
|
||||
@@ -3081,21 +3372,23 @@ export class EventsService extends PuterService {
|
||||
* total since this publish replaces them rather than adding to them.
|
||||
*/
|
||||
async #assertHandlerSetSize(
|
||||
appUid: string,
|
||||
scope: HandlerScope,
|
||||
name: string,
|
||||
source: string,
|
||||
): Promise<void> {
|
||||
const [totalBytes, existing] = await Promise.all([
|
||||
this.stores.eventHandler.totalSourceBytesForApp(appUid),
|
||||
this.stores.eventHandler.getByName(appUid, name),
|
||||
this.stores.eventHandler.totalSourceBytesForApp(scope),
|
||||
this.stores.eventHandler.getByName(scope, name),
|
||||
]);
|
||||
const existingBytes = existing
|
||||
? Buffer.byteLength(existing.source, 'utf8')
|
||||
: 0;
|
||||
const projected =
|
||||
totalBytes - existingBytes + Buffer.byteLength(source, 'utf8');
|
||||
if (projected > EVENTS_WORKER_SOURCE_MAX_BYTES)
|
||||
throw eventsWorkerTooLarge();
|
||||
const cap = isUserScope(scope)
|
||||
? EVENTS_USER_SCOPE_SOURCE_MAX_BYTES
|
||||
: EVENTS_WORKER_SOURCE_MAX_BYTES;
|
||||
if (projected > cap) throw eventsWorkerTooLarge(scope);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3107,11 +3400,11 @@ export class EventsService extends PuterService {
|
||||
* something a listener idempotently prices anyway.
|
||||
*/
|
||||
async #maybeAnnounceWorkerCreate(
|
||||
appUid: string,
|
||||
scope: HandlerScope,
|
||||
before: number,
|
||||
): Promise<void> {
|
||||
if (before > 0) return;
|
||||
await this.#emitWorkerLifecycle('events.worker.create', appUid);
|
||||
await this.#emitWorkerLifecycle('events.worker.create', scope);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3121,12 +3414,12 @@ export class EventsService extends PuterService {
|
||||
* `app.changed`'s `deleted` action reaches {@link settleDeletedApp}.
|
||||
*/
|
||||
async #maybeAnnounceWorkerDestroy(
|
||||
appUid: string,
|
||||
scope: HandlerScope,
|
||||
ownerUserId?: number,
|
||||
): Promise<void> {
|
||||
await this.#emitWorkerLifecycle(
|
||||
'events.worker.destroy',
|
||||
appUid,
|
||||
scope,
|
||||
ownerUserId,
|
||||
);
|
||||
}
|
||||
@@ -3134,27 +3427,31 @@ export class EventsService extends PuterService {
|
||||
/**
|
||||
* Emitted after the row write that triggered it has committed, and awaited
|
||||
* — so a rent listener has settled before the caller is told the publish or
|
||||
* removal succeeded. The actor carries the app's _owner_, not the caller: a
|
||||
* developer session publishing for an app it owns is the common case, but
|
||||
* billing follows ownership, resolved fresh through the user store rather
|
||||
* than assumed from the request.
|
||||
* removal succeeded. For the app's own scope the actor carries the app's
|
||||
* _owner_, not the caller: a developer session publishing for an app it
|
||||
* owns is the common case, but billing follows ownership, resolved fresh
|
||||
* through the user store rather than assumed from the request. For a user
|
||||
* scope it carries the holder, who is the one renting it.
|
||||
*
|
||||
* Never throws: this runs on the way out of a write, including one that is
|
||||
* itself failing, so nothing here may be what the caller sees.
|
||||
*/
|
||||
async #emitWorkerLifecycle(
|
||||
name: 'events.worker.create' | 'events.worker.destroy',
|
||||
appUid: string,
|
||||
scope: HandlerScope,
|
||||
knownOwnerUserId?: number,
|
||||
): Promise<void> {
|
||||
const { appUid } = scope;
|
||||
try {
|
||||
// Only a count now on the far side of the transition is one: a
|
||||
// `create` needs a handler standing, a `destroy` needs none.
|
||||
const count = await this.stores.eventHandler.countForApp(appUid);
|
||||
const count = await this.stores.eventHandler.countForApp(scope);
|
||||
if (name === 'events.worker.create' ? count === 0 : count > 0)
|
||||
return;
|
||||
|
||||
let ownerUserId = knownOwnerUserId;
|
||||
let ownerUserId = isUserScope(scope)
|
||||
? scope.holder
|
||||
: knownOwnerUserId;
|
||||
if (ownerUserId === undefined) {
|
||||
const app = await this.stores.app.getByUid(appUid);
|
||||
ownerUserId = Number(
|
||||
@@ -3168,7 +3465,11 @@ export class EventsService extends PuterService {
|
||||
|
||||
await this.clients.event.emitAndWait(
|
||||
name,
|
||||
{ actor: makeActor({ user: owner }), appUid },
|
||||
{
|
||||
actor: makeActor({ user: owner }),
|
||||
appUid,
|
||||
scope: kindOf(scope),
|
||||
},
|
||||
{},
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -3182,13 +3483,16 @@ export class EventsService extends PuterService {
|
||||
* set.
|
||||
*/
|
||||
async #suspendHandlerDependents(
|
||||
appUid: string,
|
||||
scope: HandlerScope,
|
||||
name: string,
|
||||
): Promise<number> {
|
||||
const { appUid } = scope;
|
||||
let suspended = 0;
|
||||
for (;;) {
|
||||
const batch = await this.stores.durableSubscription.listByHandler(
|
||||
appUid,
|
||||
kindOf(scope),
|
||||
scope.holder,
|
||||
name,
|
||||
);
|
||||
if (batch.length === 0) break;
|
||||
@@ -3198,7 +3502,9 @@ export class EventsService extends PuterService {
|
||||
);
|
||||
if (batch.length < HANDLER_SETTLE_BATCH) break;
|
||||
}
|
||||
if (suspended > 0) await this.#notifySuspended(appUid, name, suspended);
|
||||
// A user scope's code is the holder's, not the developer's to fix.
|
||||
if (suspended > 0 && !isUserScope(scope))
|
||||
await this.#notifySuspended(appUid, name, suspended);
|
||||
return suspended;
|
||||
}
|
||||
|
||||
@@ -3208,7 +3514,7 @@ export class EventsService extends PuterService {
|
||||
* the fix for one that could not take its deliveries.
|
||||
*/
|
||||
async #resumeHandlerDependents(
|
||||
appUid: string,
|
||||
scope: HandlerScope,
|
||||
name: string,
|
||||
): Promise<number> {
|
||||
let resumed = 0;
|
||||
@@ -3216,7 +3522,9 @@ export class EventsService extends PuterService {
|
||||
for (;;) {
|
||||
const batch =
|
||||
await this.stores.durableSubscription.listByHandler(
|
||||
appUid,
|
||||
scope.appUid,
|
||||
kindOf(scope),
|
||||
scope.holder,
|
||||
name,
|
||||
{ suspendedReason: reason },
|
||||
);
|
||||
@@ -3307,12 +3615,14 @@ export class EventsService extends PuterService {
|
||||
* Handlers are published per app, so an account-scoped row has no namespace
|
||||
* to bind in — the name is stored and binds nothing. An inline body is
|
||||
* different: sending a hash _is_ the binding claim, and there is nothing
|
||||
* for it to match. Called by extensions.
|
||||
* for it to match. `scope` names the holder whose user scope the name is
|
||||
* looked up in; absent means the app's own set. Called by extensions.
|
||||
*/
|
||||
async assertHandlerBinding(
|
||||
appUid: string | null,
|
||||
name: string,
|
||||
hash: string | null,
|
||||
scope?: { holder: number },
|
||||
): Promise<void> {
|
||||
if (appUid === null) {
|
||||
if (hash !== null) throw handlerNotFound(name);
|
||||
@@ -3320,7 +3630,7 @@ export class EventsService extends PuterService {
|
||||
}
|
||||
|
||||
const published = await this.stores.eventHandler.getByName(
|
||||
appUid,
|
||||
{ appUid, holder: scope?.holder ?? 0 },
|
||||
name,
|
||||
);
|
||||
if (!published) throw handlerNotFound(name);
|
||||
@@ -3328,6 +3638,74 @@ export class EventsService extends PuterService {
|
||||
throw handlerHashMismatch(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure the caller's user scope holds `source`, and say what to bind
|
||||
* to. The handler is named by its source's hash, so a scope that already
|
||||
* has it is answered from the cached set with no read or write on the
|
||||
* primary, no publish budget and no credit check. That path does not resume
|
||||
* rows suspended for a missing handler: a handler that stands again was
|
||||
* published again, which resumes them. A new handler is inserted without
|
||||
* telling peer nodes, which re-read a set missing a name they are asked
|
||||
* for. Called by extensions.
|
||||
*/
|
||||
async ensureInlineHandler(
|
||||
actor: Actor,
|
||||
appUid: string,
|
||||
source: unknown,
|
||||
): Promise<InlineHandlerView> {
|
||||
if (!this.enabled) throw disabled();
|
||||
const handlerSource = parseHandlerSource(source);
|
||||
if (handlerSource === null)
|
||||
throw badRequest('handlerSource is required', 'bad_request');
|
||||
const scope = await this.#userScope(actor, appUid, {
|
||||
requireFlag: true,
|
||||
});
|
||||
const hash = hashContent(handlerSource);
|
||||
const name = `@${hash}`;
|
||||
const view: InlineHandlerView = { name, hash, scope: 'user' };
|
||||
|
||||
const held = await this.stores.eventHandler.cachedSetForApp(scope, {
|
||||
handlerName: name,
|
||||
});
|
||||
if (held.some((h) => h.name === name && h.sourceHash === hash))
|
||||
return view;
|
||||
|
||||
await assertActorHasCredits(this.services.metering, actor, this.config);
|
||||
await this.#spendHandlerBudget(actor);
|
||||
assertNoFreeVariables(handlerSource);
|
||||
const [before] = await Promise.all([
|
||||
this.stores.eventHandler.countForApp(scope),
|
||||
this.#assertHandlerSetSize(scope, name, handlerSource),
|
||||
]);
|
||||
if (before === 0) await this.#assertUnitRoom(actor);
|
||||
|
||||
try {
|
||||
await this.stores.eventHandler.publish({
|
||||
scope,
|
||||
name,
|
||||
source: handlerSource,
|
||||
broadcast: false,
|
||||
});
|
||||
} catch (err) {
|
||||
// Two creates of one source race on the unique index; for a name
|
||||
// that is its own hash the loser has nothing to resolve.
|
||||
if (
|
||||
!isHttpError(err) ||
|
||||
err.legacyCode !== 'events_handler_conflict'
|
||||
)
|
||||
throw err;
|
||||
const winner = await this.stores.eventHandler.getByName(
|
||||
scope,
|
||||
name,
|
||||
);
|
||||
if (winner?.sourceHash !== hash) throw err;
|
||||
return view;
|
||||
}
|
||||
await this.#resumeHandlerDependents(scope, name);
|
||||
await this.#maybeAnnounceWorkerCreate(scope, before);
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve, authorize and compile one subscribe request. Shared so a durable
|
||||
* row cannot be created under a weaker check than a session one.
|
||||
@@ -4855,7 +5233,10 @@ export class EventsService extends PuterService {
|
||||
'app_deleted',
|
||||
);
|
||||
await this.stores.eventHandler.deleteForApp(appUid);
|
||||
await this.#maybeAnnounceWorkerDestroy(appUid, ownerUserId);
|
||||
await this.#maybeAnnounceWorkerDestroy(
|
||||
appHandlerScope(appUid),
|
||||
ownerUserId,
|
||||
);
|
||||
for (const userId of holders)
|
||||
await this.#revokeWorkerSession(userId, appUid);
|
||||
}
|
||||
@@ -5639,6 +6020,8 @@ export class EventsService extends PuterService {
|
||||
row: DispatchSubscription,
|
||||
claimed: ClaimedDelivery,
|
||||
): Promise<boolean> {
|
||||
// Not addressable yet: the lease paces the retry and nothing is failed.
|
||||
if (row.durable === true && row.handlerScope === 'user') return false;
|
||||
const meter = meterFor(row);
|
||||
const invocation = this.#workerInvocation(
|
||||
row,
|
||||
@@ -6013,7 +6396,7 @@ export class EventsService extends PuterService {
|
||||
const secret = this.config.events!.internalSecret!;
|
||||
|
||||
const set = await this.stores.eventHandler.cachedSetForApp(
|
||||
appUid,
|
||||
appHandlerScope(appUid),
|
||||
options,
|
||||
);
|
||||
if (set.length === 0) return null;
|
||||
|
||||
@@ -33,6 +33,7 @@ import { testJobContext } from '../jobs/jobsTestUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { EVENTS_BACKGROUND_PERMISSION } from './authorization.js';
|
||||
import { EVENTS_WORKER_SESSION_NAME } from './workerRuntime.js';
|
||||
import { appHandlerScope } from '../../stores/events/EventHandlerStore.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
|
||||
@@ -193,7 +194,7 @@ describe('an app deleted while it has events state', () => {
|
||||
);
|
||||
expect(listed.items).toEqual([]);
|
||||
expect(await env.server.stores.pendingDelivery.depth(subId)).toBe(0);
|
||||
expect(await env.server.stores.eventHandler.listForApp(app.uid)).toEqual(
|
||||
expect(await env.server.stores.eventHandler.listForApp(appHandlerScope(app.uid))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -223,7 +224,7 @@ describe('an app deleted while it has events state', () => {
|
||||
{ appUid: app.uid },
|
||||
);
|
||||
expect(listed.items).toEqual([]);
|
||||
expect(await env.server.stores.eventHandler.listForApp(app.uid)).toEqual(
|
||||
expect(await env.server.stores.eventHandler.listForApp(appHandlerScope(app.uid))).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
|
||||
@@ -45,7 +45,10 @@ import {
|
||||
EVENTS_WORKER_SOURCE_MAX_BYTES,
|
||||
} from '../../controllers/events/limits.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
import { hashContent } from '../../stores/events/EventHandlerStore.js';
|
||||
import {
|
||||
appHandlerScope,
|
||||
hashContent,
|
||||
} from '../../stores/events/EventHandlerStore.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { EVENTS_BACKGROUND_PERMISSION } from './authorization.js';
|
||||
import type { DurableSubscriptionView } from './EventsService.js';
|
||||
@@ -532,7 +535,7 @@ describe('what a publish reads', () => {
|
||||
if (sql.includes(BY_NAME_SQL) && !landed) {
|
||||
landed = true;
|
||||
await env.server.stores.eventHandler.publish({
|
||||
appUid,
|
||||
scope: appHandlerScope(appUid),
|
||||
name: 'ingestUpload',
|
||||
source: NEXT_SOURCE,
|
||||
replace: true,
|
||||
@@ -590,7 +593,7 @@ describe('what a publish reads', () => {
|
||||
pread.mockRestore();
|
||||
}
|
||||
const standing = await env.server.stores.eventHandler.getByName(
|
||||
appUid,
|
||||
appHandlerScope(appUid),
|
||||
'ingestUpload',
|
||||
);
|
||||
expect(standing?.sourceHash).toBe(hashContent(THIRD_SOURCE));
|
||||
@@ -603,7 +606,7 @@ describe('what a publish reads', () => {
|
||||
if (sql.includes(BY_NAME_SQL) && !beaten) {
|
||||
beaten = true;
|
||||
await env.server.stores.eventHandler.publish({
|
||||
appUid,
|
||||
scope: appHandlerScope(appUid),
|
||||
name: 'ingestUpload',
|
||||
source: THIRD_SOURCE,
|
||||
});
|
||||
@@ -659,7 +662,7 @@ describe('what a publish reads', () => {
|
||||
pread.mockRestore();
|
||||
}
|
||||
await expect(
|
||||
env.server.stores.eventHandler.countForApp(appUid),
|
||||
env.server.stores.eventHandler.countForApp(appHandlerScope(appUid)),
|
||||
).resolves.toBe(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
HANDLER_NAME_MAX_LENGTH,
|
||||
hashContent,
|
||||
} from '../../stores/events/EventHandlerStore.js';
|
||||
import type { HandlerScopeKind } from '../../stores/events/types.js';
|
||||
|
||||
/** Request and response pieces the subscription and schedule verbs share. */
|
||||
|
||||
@@ -53,6 +54,27 @@ export const parseHandlerHash = (value: unknown): string | null => {
|
||||
return value;
|
||||
};
|
||||
|
||||
/** Source of an inline handler; the store enforces the size and content rules. */
|
||||
export const parseHandlerSource = (value: unknown): string | null => {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value !== 'string' || value.trim().length === 0)
|
||||
throw badRequest(
|
||||
'handlerSource must be a non-empty string',
|
||||
'bad_request',
|
||||
);
|
||||
return value;
|
||||
};
|
||||
|
||||
/** Which handler set a request is about; absent means the app's. */
|
||||
export const parseHandlerScopeKind = (
|
||||
value: unknown,
|
||||
field: string,
|
||||
): HandlerScopeKind => {
|
||||
if (value === undefined || value === null || value === 'app') return 'app';
|
||||
if (value === 'user') return 'user';
|
||||
throw badRequest(`${field} must be 'app' or 'user'`, 'bad_request');
|
||||
};
|
||||
|
||||
/** Stored as JSON text; the byte cap is the store's to enforce. */
|
||||
export const parseContext = (value: unknown): string | null => {
|
||||
if (value === undefined || value === null) return null;
|
||||
|
||||
@@ -43,6 +43,7 @@ import type { AclMode } from '../acl/ACLService.js';
|
||||
import { EVENTS_BACKGROUND_PERMISSION } from './authorization.js';
|
||||
import type { DeliveryEnvelope } from './EventsService.js';
|
||||
import { fsAnchorToken } from './subjects.js';
|
||||
import { appHandlerScope } from '../../stores/events/EventHandlerStore.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
const TABLE = 'event_subscriptions';
|
||||
@@ -206,7 +207,7 @@ const makeApp = async (path: string): Promise<Actor> => {
|
||||
EVENTS_BACKGROUND_PERMISSION,
|
||||
);
|
||||
await env.server.stores.eventHandler.publish({
|
||||
appUid: uid,
|
||||
scope: appHandlerScope(uid),
|
||||
name: 'onChange',
|
||||
source: 'async () => {}',
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -60,6 +60,7 @@ import {
|
||||
eventsWorkerScript,
|
||||
} from './workerRuntime.js';
|
||||
import { handlerSetHash } from './workerSource.js';
|
||||
import { appHandlerScope } from '../../stores/events/EventHandlerStore.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
/** Short enough that a hung handler is a test case rather than a stall. */
|
||||
@@ -233,7 +234,7 @@ const makeWorkerApp = async (): Promise<{
|
||||
);
|
||||
const token = await env.server.services.auth.getUserAppToken(actor!, uid);
|
||||
await env.server.stores.eventHandler.publish({
|
||||
appUid: uid,
|
||||
scope: appHandlerScope(uid),
|
||||
name: HANDLER,
|
||||
source: SOURCE,
|
||||
});
|
||||
@@ -403,7 +404,7 @@ beforeAll(async () => {
|
||||
);
|
||||
appToken = await env.server.services.auth.getUserAppToken(actor!, appUid);
|
||||
await env.server.stores.eventHandler.publish({
|
||||
appUid,
|
||||
scope: appHandlerScope(appUid),
|
||||
name: HANDLER,
|
||||
source: SOURCE,
|
||||
});
|
||||
@@ -471,7 +472,9 @@ describe('the call an owed delivery makes', () => {
|
||||
|
||||
const script = eventsWorkerScript(
|
||||
handlerSetHash(
|
||||
await env.server.stores.eventHandler.setForApp(appUid),
|
||||
await env.server.stores.eventHandler.setForApp(
|
||||
appHandlerScope(appUid),
|
||||
),
|
||||
),
|
||||
// No `workers.internetExposedUrl` in this test's config.
|
||||
'',
|
||||
@@ -1014,7 +1017,10 @@ describe('with no events worker to address', () => {
|
||||
const subId = await subscribe();
|
||||
// Unpublished after the fact: the app's set now hashes to nothing, so
|
||||
// there is no script to name and nowhere to send the delivery.
|
||||
await env.server.stores.eventHandler.remove(appUid, HANDLER);
|
||||
await env.server.stores.eventHandler.remove(
|
||||
appHandlerScope(appUid),
|
||||
HANDLER,
|
||||
);
|
||||
|
||||
try {
|
||||
await touch('unresolved.txt');
|
||||
@@ -1037,7 +1043,7 @@ describe('with no events worker to address', () => {
|
||||
expect(calls).toEqual([]);
|
||||
} finally {
|
||||
await env.server.stores.eventHandler.publish({
|
||||
appUid,
|
||||
scope: appHandlerScope(appUid),
|
||||
name: HANDLER,
|
||||
source: SOURCE,
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
eventsWorkerScript,
|
||||
} from './workerRuntime.js';
|
||||
import { handlerSetHash } from './workerSource.js';
|
||||
import { appHandlerScope } from '../../stores/events/EventHandlerStore.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 180_000;
|
||||
const TEST_TIMEOUT_MS = 60_000;
|
||||
@@ -171,7 +172,7 @@ const localWorkers = () => env.server.services.localworkerservice;
|
||||
/** The script the app's published set currently deploys as. */
|
||||
const scriptName = async (): Promise<string> =>
|
||||
eventsWorkerScript(
|
||||
handlerSetHash(await env.server.stores.eventHandler.setForApp(appUid)),
|
||||
handlerSetHash(await env.server.stores.eventHandler.setForApp(appHandlerScope(appUid))),
|
||||
// No `workers.internetExposedUrl` in this test's config, so the scope
|
||||
// this backend binds is empty.
|
||||
'',
|
||||
@@ -886,7 +887,7 @@ describe('the worker follows the handler set', () => {
|
||||
// An empty set names no script, so nothing can be deployed for
|
||||
// this app again until something is published.
|
||||
expect(
|
||||
await env.server.stores.eventHandler.setForApp(appUid),
|
||||
await env.server.stores.eventHandler.setForApp(appHandlerScope(appUid)),
|
||||
).toEqual([]);
|
||||
expect((await durable().getBySubId(subId))?.suspendedReason).toBe(
|
||||
'handler_not_found',
|
||||
|
||||
@@ -175,7 +175,7 @@ let destroys: Array<{ appUid: string; ownerId: number | undefined }>;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
events: { enabled: true },
|
||||
events: { enabled: true, userHandlers: true },
|
||||
unlimitedMetering: true,
|
||||
} as IConfig);
|
||||
|
||||
@@ -204,6 +204,7 @@ afterAll(async () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await env.server.clients.db.write('DELETE FROM `event_handlers`', []);
|
||||
await env.server.clients.db.write('DELETE FROM `event_user_handlers`', []);
|
||||
await env.server.clients.db.write('DELETE FROM `event_subscriptions`', []);
|
||||
await env.server.clients.db.write('DELETE FROM `apps`', []);
|
||||
creates.length = 0;
|
||||
@@ -539,6 +540,8 @@ describe('POST /events/workers/destroy', () => {
|
||||
|
||||
const suspended = await env.server.stores.durableSubscription.listByHandler(
|
||||
app.uid,
|
||||
'app',
|
||||
0,
|
||||
'ingestUpload',
|
||||
{ suspendedReason: 'handler_not_found' },
|
||||
);
|
||||
@@ -554,6 +557,8 @@ describe('POST /events/workers/destroy', () => {
|
||||
const stillSuspended =
|
||||
await env.server.stores.durableSubscription.listByHandler(
|
||||
app.uid,
|
||||
'app',
|
||||
0,
|
||||
'ingestUpload',
|
||||
{ suspendedReason: 'handler_not_found' },
|
||||
);
|
||||
@@ -621,3 +626,234 @@ describe('POST /events/workers/destroy', () => {
|
||||
expect(destroys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// -- User scopes -----------------------------------------------------
|
||||
|
||||
/** `userId`'s own session of `app`, wherever the app's owner is. */
|
||||
const sessionIn = async (app: { uid: string }, asUser: 'user' | 'other') => {
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
asUser === 'user' ? env.users.user.token : env.users.other.token,
|
||||
);
|
||||
return env.server.services.auth.getUserAppToken(actor!, app.uid);
|
||||
};
|
||||
|
||||
const publishUserScope = (token: string, name: string, source: string) =>
|
||||
call('POST', '/events/handlers/publish', token, {
|
||||
scope: 'user',
|
||||
name,
|
||||
source,
|
||||
});
|
||||
|
||||
describe('GET /events/workers with user scopes', () => {
|
||||
it('adds the caller`s user scopes to their app workers outside an app', async () => {
|
||||
const mine = await makeApp(userId);
|
||||
await publish(mine.token, {
|
||||
appUid: mine.uid,
|
||||
name: 'a',
|
||||
source: SOURCE,
|
||||
});
|
||||
const theirs = await makeApp(otherUserId);
|
||||
const inTheirs = await sessionIn(theirs, 'user');
|
||||
await publishUserScope(inTheirs, 'x', SOURCE);
|
||||
await publishUserScope(inTheirs, 'y', OTHER_SOURCE);
|
||||
// Another holder's scope in the same app is not the caller's.
|
||||
await publishUserScope(await sessionIn(theirs, 'other'), 'z', SOURCE);
|
||||
|
||||
const listed = await call(
|
||||
'GET',
|
||||
'/events/workers',
|
||||
env.users.user.token,
|
||||
);
|
||||
|
||||
expect(listed.status).toBe(200);
|
||||
expect(listed.body.items).toEqual([
|
||||
expect.objectContaining({
|
||||
scope: 'app',
|
||||
appUid: mine.uid,
|
||||
handlerCount: 1,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: 'user',
|
||||
appUid: theirs.uid,
|
||||
appName: theirs.uid,
|
||||
handlerCount: 2,
|
||||
}),
|
||||
]);
|
||||
expect(listed.body.cursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pages through the app workers and then the user scopes', async () => {
|
||||
const mine = await makeApp(userId);
|
||||
await publish(mine.token, {
|
||||
appUid: mine.uid,
|
||||
name: 'a',
|
||||
source: SOURCE,
|
||||
});
|
||||
const first = await makeApp(otherUserId);
|
||||
const second = await makeApp(otherUserId);
|
||||
await publishUserScope(await sessionIn(first, 'user'), 'x', SOURCE);
|
||||
await publishUserScope(await sessionIn(second, 'user'), 'x', SOURCE);
|
||||
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
let cursor: string | undefined;
|
||||
let pages = 0;
|
||||
do {
|
||||
const page = await call(
|
||||
'GET',
|
||||
`/events/workers?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(page.status).toBe(200);
|
||||
seen.push(...(page.body.items as Array<Record<string, unknown>>));
|
||||
cursor = page.body.cursor as string | undefined;
|
||||
pages += 1;
|
||||
} while (cursor && pages < 10);
|
||||
|
||||
expect(seen.map((item) => [item.scope, item.appUid])).toEqual([
|
||||
['app', mine.uid],
|
||||
...[first.uid, second.uid].sort().map((uid) => ['user', uid]),
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists only that app`s scope for the caller inside an app', async () => {
|
||||
const theirs = await makeApp(otherUserId);
|
||||
const otherApp = await makeApp(otherUserId);
|
||||
const inTheirs = await sessionIn(theirs, 'user');
|
||||
await publishUserScope(inTheirs, 'x', SOURCE);
|
||||
await publishUserScope(await sessionIn(otherApp, 'user'), 'x', SOURCE);
|
||||
await publishUserScope(await sessionIn(theirs, 'other'), 'x', SOURCE);
|
||||
// The owner's own worker in that app is not the caller's to see.
|
||||
await publish(theirs.token, {
|
||||
appUid: theirs.uid,
|
||||
name: 'owner',
|
||||
source: SOURCE,
|
||||
});
|
||||
|
||||
const listed = await call('GET', '/events/workers', inTheirs);
|
||||
|
||||
expect(listed.status).toBe(200);
|
||||
expect(listed.body.items).toEqual([
|
||||
expect.objectContaining({
|
||||
scope: 'user',
|
||||
appUid: theirs.uid,
|
||||
handlerCount: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('still lists the owner`s worker beside their own user scope inside the app', async () => {
|
||||
const mine = await makeApp(userId);
|
||||
await publish(mine.token, {
|
||||
appUid: mine.uid,
|
||||
name: 'a',
|
||||
source: SOURCE,
|
||||
});
|
||||
await publishUserScope(mine.token, 'x', SOURCE);
|
||||
|
||||
const listed = await call('GET', '/events/workers', mine.token);
|
||||
|
||||
expect(
|
||||
(listed.body.items as Array<Record<string, unknown>>).map(
|
||||
(item) => item.scope,
|
||||
),
|
||||
).toEqual(['app', 'user']);
|
||||
});
|
||||
|
||||
it('refuses a scoped token, which has no scope of its own', async () => {
|
||||
const theirs = await makeApp(otherUserId);
|
||||
await publishUserScope(await sessionIn(theirs, 'user'), 'x', SOURCE);
|
||||
const scoped = await appIssuedScopedToken({
|
||||
uid: theirs.uid,
|
||||
token: await sessionIn(theirs, 'user'),
|
||||
});
|
||||
|
||||
const listed = await call('GET', '/events/workers', scoped);
|
||||
expect(listed.status).toBe(403);
|
||||
expect(listed.body.code).toBe('events_worker_owner_only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /events/workers/destroy with a user scope', () => {
|
||||
it('removes the caller`s scope only, suspends what was bound, and announces it', async () => {
|
||||
const theirs = await makeApp(otherUserId);
|
||||
const mineIn = await sessionIn(theirs, 'user');
|
||||
const otherIn = await sessionIn(theirs, 'other');
|
||||
await publish(theirs.token, {
|
||||
appUid: theirs.uid,
|
||||
name: 'owner',
|
||||
source: SOURCE,
|
||||
});
|
||||
await publishUserScope(mineIn, 'x', SOURCE);
|
||||
await publishUserScope(mineIn, 'y', OTHER_SOURCE);
|
||||
await publishUserScope(otherIn, 'x', SOURCE);
|
||||
destroys.length = 0;
|
||||
|
||||
const destroyed = await call(
|
||||
'POST',
|
||||
'/events/workers/destroy',
|
||||
mineIn,
|
||||
{
|
||||
scope: 'user',
|
||||
},
|
||||
);
|
||||
|
||||
expect(destroyed.status).toBe(200);
|
||||
expect(destroyed.body).toMatchObject({
|
||||
appUid: theirs.uid,
|
||||
removed: 2,
|
||||
suspended: 0,
|
||||
scope: 'user',
|
||||
});
|
||||
const store = env.server.stores.eventHandler;
|
||||
expect(
|
||||
await store.countForApp({ appUid: theirs.uid, holder: userId }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await store.countForApp({
|
||||
appUid: theirs.uid,
|
||||
holder: otherUserId,
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(await store.countForApp({ appUid: theirs.uid, holder: 0 })).toBe(
|
||||
1,
|
||||
);
|
||||
expect(destroys).toEqual([{ appUid: theirs.uid, ownerId: userId }]);
|
||||
|
||||
const again = await call('POST', '/events/workers/destroy', mineIn, {
|
||||
scope: 'user',
|
||||
});
|
||||
expect(again.status).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses a scoped token, a session with no app, and another app', async () => {
|
||||
const theirs = await makeApp(otherUserId);
|
||||
const mineIn = await sessionIn(theirs, 'user');
|
||||
await publishUserScope(mineIn, 'x', SOURCE);
|
||||
const scoped = await appIssuedScopedToken({
|
||||
uid: theirs.uid,
|
||||
token: mineIn,
|
||||
});
|
||||
const elsewhere = await makeApp(otherUserId);
|
||||
|
||||
for (const [token, body] of [
|
||||
[scoped, { scope: 'user' }],
|
||||
[env.users.user.token, { scope: 'user', appUid: theirs.uid }],
|
||||
[mineIn, { scope: 'user', appUid: elsewhere.uid }],
|
||||
] as const) {
|
||||
const refused = await call(
|
||||
'POST',
|
||||
'/events/workers/destroy',
|
||||
token,
|
||||
body,
|
||||
);
|
||||
expect(refused.status).toBe(403);
|
||||
expect(refused.body.code).toBe('events_handler_forbidden');
|
||||
}
|
||||
expect(
|
||||
await env.server.stores.eventHandler.countForApp({
|
||||
appUid: theirs.uid,
|
||||
holder: userId,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -265,6 +265,114 @@ describe('creating a subscription', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('handler scope', () => {
|
||||
const appUid = 'app-scoped';
|
||||
|
||||
it('round-trips through create and every select path', async () => {
|
||||
const { row: user } = await durable().create(
|
||||
input({ appUid, handlerName: 'h', handlerScope: 'user' }),
|
||||
);
|
||||
const { row: app } = await durable().create(
|
||||
input({ appUid, handlerName: 'h' }),
|
||||
);
|
||||
expect(user.handlerScope).toBe('user');
|
||||
expect(app).not.toHaveProperty('handlerScope');
|
||||
|
||||
await expect(durable().getBySubId(user.subId)).resolves.toMatchObject({
|
||||
handlerScope: 'user',
|
||||
});
|
||||
await expect(
|
||||
durable().getBySubId(app.subId),
|
||||
).resolves.not.toHaveProperty('handlerScope');
|
||||
const listed = await durable().listForHolder(userId, { appUid });
|
||||
expect(
|
||||
new Map(listed.items.map((r) => [r.subId, r.handlerScope])),
|
||||
).toEqual(
|
||||
new Map<string, string | undefined>([
|
||||
[user.subId, 'user'],
|
||||
[app.subId, undefined],
|
||||
]),
|
||||
);
|
||||
const stored = await env.server.clients.db.read(
|
||||
'SELECT `sub_id`, `handler_scope` FROM `event_subscriptions` WHERE `app_uid` = ?',
|
||||
[appUid],
|
||||
);
|
||||
expect(
|
||||
Object.fromEntries(stored.map((r) => [r.sub_id, r.handler_scope])),
|
||||
).toEqual({ [user.subId]: 1, [app.subId]: 0 });
|
||||
});
|
||||
|
||||
it('lists by scope and holder, never across scopes', async () => {
|
||||
const { row: appRow } = await durable().create(
|
||||
input({ appUid, handlerName: 'h' }),
|
||||
);
|
||||
const { row: mine } = await durable().create(
|
||||
input({ appUid, handlerName: 'h', handlerScope: 'user' }),
|
||||
);
|
||||
const { row: theirs } = await durable().create(
|
||||
input({
|
||||
appUid,
|
||||
handlerName: 'h',
|
||||
handlerScope: 'user',
|
||||
holderUserId: otherUserId,
|
||||
ownerUserId: otherUserId,
|
||||
token: `f#other-${uuidv4()}`,
|
||||
}),
|
||||
);
|
||||
const ids = async (scope: 'app' | 'user', holder: number, name = 'h') =>
|
||||
(await durable().listByHandler(appUid, scope, holder, name))
|
||||
.map((r) => r.subId)
|
||||
.sort();
|
||||
|
||||
expect(await ids('app', 0)).toEqual([appRow.subId]);
|
||||
// Holder is ignored for app scope.
|
||||
expect(await ids('app', userId)).toEqual([appRow.subId]);
|
||||
expect(await ids('user', userId)).toEqual([mine.subId]);
|
||||
expect(await ids('user', otherUserId)).toEqual([theirs.subId]);
|
||||
expect(await ids('user', userId, 'missing')).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists the suspended half of a user scope', async () => {
|
||||
const { row } = await durable().create(
|
||||
input({ appUid, handlerName: 'h', handlerScope: 'user' }),
|
||||
);
|
||||
await env.server.clients.db.write(
|
||||
'UPDATE `event_subscriptions` SET `suspended_at` = 1, `suspended_reason` = ? WHERE `sub_id` = ?',
|
||||
['handler_not_found', row.subId],
|
||||
);
|
||||
await expect(
|
||||
durable().listByHandler(appUid, 'user', userId, 'h'),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
durable().listByHandler(appUid, 'user', userId, 'h', {
|
||||
suspendedReason: 'handler_not_found',
|
||||
}),
|
||||
).resolves.toEqual([expect.objectContaining({ subId: row.subId })]);
|
||||
});
|
||||
|
||||
it('reads a user scope through the holder index', async () => {
|
||||
const pread = vi.spyOn(env.server.clients.db, 'pread');
|
||||
try {
|
||||
await durable().listByHandler(appUid, 'user', userId, 'h');
|
||||
const [statement, params] = pread.mock.calls[0]!;
|
||||
const plan = await env.server.clients.db.read(
|
||||
`EXPLAIN QUERY PLAN ${statement}`,
|
||||
params,
|
||||
);
|
||||
const detail = plan.map((r) => String(r.detail)).join('\n');
|
||||
expect(detail).toContain('idx_event_subscriptions_holder');
|
||||
expect(detail).not.toContain('idx_event_subscriptions_app_handler');
|
||||
} finally {
|
||||
pread.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the scope through the schedule join', async () => {
|
||||
const join = durable().scheduleSubscriptionJoin('s', 'x.`sub_id`');
|
||||
expect(join.columns).toContain('`sub_handler_scope`');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation at the row write', () => {
|
||||
it('refuses a context past the hard cap', async () => {
|
||||
await expect(
|
||||
@@ -1099,7 +1207,7 @@ describe('rows a schedule owns', () => {
|
||||
it('are found by handler and by app, like any other row', async () => {
|
||||
const { row } = await durable().create(scheduleInput());
|
||||
await expect(
|
||||
durable().listByHandler('app-scheduler', 'tick'),
|
||||
durable().listByHandler('app-scheduler', 'app', 0, 'tick'),
|
||||
).resolves.toEqual([expect.objectContaining({ subId: row.subId })]);
|
||||
await expect(
|
||||
durable().listHolderIdsForApp('app-scheduler'),
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
SCHEDULE_TOKEN_PREFIX,
|
||||
scheduleToken,
|
||||
type DurableSubscription,
|
||||
type HandlerScopeKind,
|
||||
type SubscriptionPermission,
|
||||
} from './types.js';
|
||||
|
||||
@@ -100,6 +101,8 @@ export interface DurableSubscriptionInput {
|
||||
op: FsOp | null;
|
||||
delivery: DeliveryClass;
|
||||
handlerName: string | null;
|
||||
/** Which set `handlerName` refers to. Omitted means the app's. */
|
||||
handlerScope?: HandlerScopeKind;
|
||||
context: string | null;
|
||||
permission: SubscriptionPermission;
|
||||
expiresAt: number | null;
|
||||
@@ -307,6 +310,9 @@ const toRow = (row: Record<string, unknown>): DurableSubscription => ({
|
||||
row.handler_name === null || row.handler_name === undefined
|
||||
? null
|
||||
: String(row.handler_name),
|
||||
...(Number(row.handler_scope) === 1
|
||||
? { handlerScope: 'user' as const }
|
||||
: {}),
|
||||
context:
|
||||
row.context === null || row.context === undefined
|
||||
? null
|
||||
@@ -324,7 +330,7 @@ const toRow = (row: Record<string, unknown>): DurableSubscription => ({
|
||||
const SELECT_COLUMNS =
|
||||
'`id`, `sub_id`, `token`, `owner_user_id`, `holder_user_id`, `app_uid`, ' +
|
||||
'`subject`, `anchor_uid`, `anchor_path`, `match`, `delivery`, `ops`, ' +
|
||||
'`handler_name`, `context`, `permission`, `include_value`, ' +
|
||||
'`handler_name`, `handler_scope`, `context`, `permission`, `include_value`, ' +
|
||||
'`suspended_at`, `suspended_reason`, `expires_at`, `created_at`';
|
||||
|
||||
export class DurableSubscriptionStore extends PuterStore {
|
||||
@@ -376,6 +382,9 @@ export class DurableSubscriptionStore extends PuterStore {
|
||||
permission: input.permission,
|
||||
delivery: input.delivery,
|
||||
handlerName: input.handlerName,
|
||||
...(input.handlerScope === 'user'
|
||||
? { handlerScope: 'user' as const }
|
||||
: {}),
|
||||
context: input.context,
|
||||
...(input.includeValue ? { includeValue: true } : {}),
|
||||
expiresAt: input.expiresAt,
|
||||
@@ -398,6 +407,7 @@ export class DurableSubscriptionStore extends PuterStore {
|
||||
delivery: row.delivery,
|
||||
ops: row.op,
|
||||
handler_name: row.handlerName,
|
||||
handler_scope: row.handlerScope === 'user' ? 1 : 0,
|
||||
targets: legacyTargets(row.appUid),
|
||||
context: row.context,
|
||||
permission: row.permission,
|
||||
@@ -811,8 +821,11 @@ export class DurableSubscriptionStore extends PuterStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows bound to one of an app's handler names. What a removal has to
|
||||
* The rows bound to one of a scope's handler names. What a removal has to
|
||||
* suspend, and — asking for the suspended half — what a republish resumes.
|
||||
* `holder` is read for user scope only. App scope never returns user-scope
|
||||
* rows; user scope is read through the holder index, so it never walks the
|
||||
* other holders of a popular app.
|
||||
*
|
||||
* Bounded by the per-account quota times nothing: a widely-used handler can
|
||||
* carry more rows than one read should return, so this is a page and the
|
||||
@@ -820,11 +833,28 @@ export class DurableSubscriptionStore extends PuterStore {
|
||||
*/
|
||||
async listByHandler(
|
||||
appUid: string,
|
||||
scope: HandlerScopeKind,
|
||||
holder: number,
|
||||
handlerName: string,
|
||||
options: { suspendedReason?: SuspendedReason; limit?: number } = {},
|
||||
): Promise<DurableSubscription[]> {
|
||||
const where = ['`app_uid` = ?', '`handler_name` = ?'];
|
||||
const params: unknown[] = [appUid, handlerName];
|
||||
const where =
|
||||
scope === 'user'
|
||||
? [
|
||||
'`holder_user_id` = ?',
|
||||
'`app_uid` = ?',
|
||||
'`handler_scope` = 1',
|
||||
'`handler_name` = ?',
|
||||
]
|
||||
: [
|
||||
'`app_uid` = ?',
|
||||
'`handler_scope` = 0',
|
||||
'`handler_name` = ?',
|
||||
];
|
||||
const params: unknown[] =
|
||||
scope === 'user'
|
||||
? [holder, appUid, handlerName]
|
||||
: [appUid, handlerName];
|
||||
if (options.suspendedReason === undefined) {
|
||||
where.push('`suspended_at` IS NULL');
|
||||
} else {
|
||||
@@ -965,7 +995,8 @@ export class DurableSubscriptionStore extends PuterStore {
|
||||
/**
|
||||
* A `LEFT JOIN` of the row a schedule owns, as `alias`, on `subIdColumn`,
|
||||
* and the columns it selects: `sub_row_id`, `sub_suspended_at`,
|
||||
* `sub_suspended_reason`, `sub_handler_name` and `sub_context`.
|
||||
* `sub_suspended_reason`, `sub_handler_name`, `sub_handler_scope` and
|
||||
* `sub_context`.
|
||||
*/
|
||||
scheduleSubscriptionJoin(
|
||||
alias: string,
|
||||
@@ -977,6 +1008,7 @@ export class DurableSubscriptionStore extends PuterStore {
|
||||
`${alias}.\`suspended_at\` AS \`sub_suspended_at\`, ` +
|
||||
`${alias}.\`suspended_reason\` AS \`sub_suspended_reason\`, ` +
|
||||
`${alias}.\`handler_name\` AS \`sub_handler_name\`, ` +
|
||||
`${alias}.\`handler_scope\` AS \`sub_handler_scope\`, ` +
|
||||
`${alias}.\`context\` AS \`sub_context\``,
|
||||
join: `LEFT JOIN \`${TABLE}\` ${alias} ON ${alias}.\`sub_id\` = ${subIdColumn}`,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,392 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The user-scope handler schema and queries against MySQL 8.4. Runs only when
|
||||
* `PUTER_TEST_MYSQL_PORT` points at a server (root / `puter`).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { splitMysqlStatements } from '../../clients/database/splitMysqlStatements.js';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { appHandlerScope, hashContent } from './EventHandlerStore.js';
|
||||
|
||||
const connection = {
|
||||
host: '127.0.0.1',
|
||||
port: Number(process.env.PUTER_TEST_MYSQL_PORT ?? 3307),
|
||||
user: 'root',
|
||||
password: 'puter',
|
||||
};
|
||||
const database = `event_user_handlers_${Date.now().toString(36)}`;
|
||||
const migrationDir = fileURLToPath(
|
||||
new URL('../../clients/database/migrations/mysql', import.meta.url),
|
||||
);
|
||||
const SOURCE = 'async ({ event }) => { console.log(event.path); }';
|
||||
|
||||
describe.skipIf(!process.env.PUTER_TEST_MYSQL_PORT)(
|
||||
'user-scoped handlers (MySQL)',
|
||||
() => {
|
||||
let server: PuterServer;
|
||||
let conn: mysql.Connection;
|
||||
const userIds: number[] = [];
|
||||
|
||||
const handlers = () => server.stores.eventHandler;
|
||||
const rows = async (sql: string, params: unknown[] = []) =>
|
||||
(await conn.query(sql, params))[0] as Record<string, unknown>[];
|
||||
|
||||
const applyMig55 = async () => {
|
||||
const sql = readFileSync(
|
||||
`${migrationDir}/mysql_mig_55.sql`,
|
||||
'utf8',
|
||||
);
|
||||
for (const statement of splitMysqlStatements(sql))
|
||||
await conn.query(statement);
|
||||
};
|
||||
|
||||
const bind = async (
|
||||
holder: number,
|
||||
app: string,
|
||||
name: string,
|
||||
handlerScope: number,
|
||||
suspended = false,
|
||||
) => {
|
||||
await conn.query(
|
||||
'INSERT INTO `event_subscriptions` (`sub_id`, `token`, `owner_user_id`, `holder_user_id`, `app_uid`, `subject`, `anchor_uid`, `anchor_path`, `delivery`, `handler_name`, `handler_scope`, `targets`, `permission`, `suspended_at`, `suspended_reason`, `created_at`) ' +
|
||||
"VALUES (?, ?, ?, ?, ?, 'fs:/x', 'a', '/x', 'single', ?, ?, '[]', 'list', ?, ?, 1)",
|
||||
[
|
||||
uuidv4(),
|
||||
`f#${uuidv4()}`,
|
||||
holder,
|
||||
holder,
|
||||
app,
|
||||
name,
|
||||
handlerScope,
|
||||
suspended ? 1 : null,
|
||||
suspended ? 'handler_not_found' : null,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The `key` column of every table in the plan of the last statement a
|
||||
* spy saw.
|
||||
*/
|
||||
const planKeys = async (spy: {
|
||||
mock: { calls: unknown[][] };
|
||||
}): Promise<Record<string, string | null>> => {
|
||||
const [statement, params] = spy.mock.calls.at(-1) as [
|
||||
string,
|
||||
unknown[],
|
||||
];
|
||||
const plan = await rows(`EXPLAIN ${statement}`, params);
|
||||
return Object.fromEntries(
|
||||
plan.map((row) => [
|
||||
String(row.table),
|
||||
(row.key as string | null) ?? null,
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const admin = await mysql.createConnection(connection);
|
||||
await admin.query(`CREATE DATABASE \`${database}\``);
|
||||
await admin.end();
|
||||
server = await setupTestServer({
|
||||
database: {
|
||||
engine: 'mysql',
|
||||
...connection,
|
||||
database,
|
||||
migrationPaths: [migrationDir],
|
||||
},
|
||||
events: { enabled: true },
|
||||
} as never);
|
||||
conn = await mysql.createConnection({ ...connection, database });
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const user = await server.stores.user.create({
|
||||
username: `ush-${uuidv4().slice(0, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: null,
|
||||
});
|
||||
userIds.push(user.id);
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await conn?.end();
|
||||
await server?.shutdown();
|
||||
const admin = await mysql.createConnection(connection);
|
||||
await admin.query(`DROP DATABASE IF EXISTS \`${database}\``);
|
||||
await admin.end();
|
||||
});
|
||||
|
||||
it('runs on MySQL 8.4', async () => {
|
||||
const [row] = await rows('SELECT VERSION() AS version');
|
||||
expect(String(row!.version)).toMatch(/^8\.4\./);
|
||||
});
|
||||
|
||||
it('applies over existing subscription rows, and a replay is a no-op', async () => {
|
||||
// Back to the state before the migration, with rows already in.
|
||||
await conn.query('DROP TABLE `event_user_handlers`');
|
||||
await conn.query(
|
||||
'ALTER TABLE `event_subscriptions` DROP COLUMN `handler_scope`',
|
||||
);
|
||||
await conn.query('DELETE FROM `event_subscriptions`');
|
||||
for (const name of ['a', 'b', 'c'])
|
||||
await conn.query(
|
||||
'INSERT INTO `event_subscriptions` (`sub_id`, `token`, `owner_user_id`, `holder_user_id`, `app_uid`, `subject`, `anchor_uid`, `anchor_path`, `delivery`, `handler_name`, `targets`, `permission`, `created_at`) ' +
|
||||
"VALUES (?, ?, ?, ?, 'app-old', 'fs:/x', 'a', '/x', 'single', ?, '[]', 'list', 1)",
|
||||
[uuidv4(), `f#${uuidv4()}`, userIds[0], userIds[0], name],
|
||||
);
|
||||
|
||||
await applyMig55();
|
||||
const scopes = async () =>
|
||||
(
|
||||
await rows(
|
||||
'SELECT `handler_scope` FROM `event_subscriptions`',
|
||||
)
|
||||
).map((row) => Number(row.handler_scope));
|
||||
expect(await scopes()).toEqual([0, 0, 0]);
|
||||
|
||||
await applyMig55();
|
||||
expect(await scopes()).toEqual([0, 0, 0]);
|
||||
|
||||
const [column] = await rows(
|
||||
"SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'event_subscriptions' AND COLUMN_NAME = 'handler_scope'",
|
||||
);
|
||||
expect(column).toMatchObject({
|
||||
COLUMN_TYPE: 'tinyint unsigned',
|
||||
IS_NULLABLE: 'NO',
|
||||
COLUMN_DEFAULT: '0',
|
||||
});
|
||||
|
||||
const indexes = await rows(
|
||||
"SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'event_user_handlers' ORDER BY INDEX_NAME, SEQ_IN_INDEX",
|
||||
);
|
||||
const byIndex = (name: string) =>
|
||||
indexes
|
||||
.filter((row) => row.INDEX_NAME === name)
|
||||
.map((row) => String(row.COLUMN_NAME));
|
||||
expect(byIndex('idx_event_user_handlers_app_holder_name')).toEqual([
|
||||
'app_uid',
|
||||
'holder_user_id',
|
||||
'name',
|
||||
]);
|
||||
expect(byIndex('idx_event_user_handlers_holder_app')).toEqual([
|
||||
'holder_user_id',
|
||||
'app_uid',
|
||||
]);
|
||||
expect(
|
||||
indexes.find(
|
||||
(row) =>
|
||||
row.INDEX_NAME ===
|
||||
'idx_event_user_handlers_app_holder_name',
|
||||
),
|
||||
).toMatchObject({ NON_UNIQUE: 0 });
|
||||
|
||||
const [appUidColumn] = await rows(
|
||||
"SELECT CHARACTER_SET_NAME, COLLATION_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'event_user_handlers' AND COLUMN_NAME = 'app_uid'",
|
||||
);
|
||||
expect(appUidColumn).toMatchObject({
|
||||
CHARACTER_SET_NAME: 'latin1',
|
||||
COLLATION_NAME: 'latin1_swedish_ci',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the same name once in the app scope and once per holder', async () => {
|
||||
const app = `app-${uuidv4()}`;
|
||||
const insert = (holder: number) =>
|
||||
conn.query(
|
||||
'INSERT INTO `event_user_handlers` (`app_uid`, `holder_user_id`, `name`, `source`, `source_hash`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, 1, 1)',
|
||||
[app, holder, 'daily', SOURCE, hashContent(SOURCE)],
|
||||
);
|
||||
await handlers().publish({
|
||||
scope: appHandlerScope(app),
|
||||
name: 'daily',
|
||||
source: SOURCE,
|
||||
});
|
||||
await insert(userIds[1]!);
|
||||
await insert(userIds[2]!);
|
||||
await expect(insert(userIds[1]!)).rejects.toMatchObject({
|
||||
code: 'ER_DUP_ENTRY',
|
||||
});
|
||||
});
|
||||
|
||||
it('runs the store against both tables', async () => {
|
||||
const app = `app-${uuidv4()}`;
|
||||
const [a, b] = [userIds[3]!, userIds[4]!];
|
||||
const scope = (holder: number) => ({ appUid: app, holder });
|
||||
await handlers().publish({
|
||||
scope: appHandlerScope(app),
|
||||
name: 'daily',
|
||||
source: SOURCE,
|
||||
});
|
||||
for (const holder of [a, b])
|
||||
await handlers().publish({
|
||||
scope: scope(holder),
|
||||
name: 'daily',
|
||||
source: `${SOURCE} // ${holder}`,
|
||||
});
|
||||
await handlers().publish({
|
||||
scope: scope(a),
|
||||
name: `@${hashContent(SOURCE)}`,
|
||||
source: SOURCE,
|
||||
});
|
||||
|
||||
expect(await handlers().countForApp(scope(a))).toBe(2);
|
||||
expect(await handlers().countForApp(appHandlerScope(app))).toBe(1);
|
||||
expect(await handlers().totalSourceBytesForApp(scope(b))).toBe(
|
||||
Buffer.byteLength(`${SOURCE} // ${b}`),
|
||||
);
|
||||
expect(
|
||||
(await handlers().setForApp(scope(a), { replica: true })).map(
|
||||
(h) => h.name,
|
||||
),
|
||||
).toEqual([`@${hashContent(SOURCE)}`, 'daily']);
|
||||
expect(await handlers().countUserScopesWithHandlersForUser(a)).toBe(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
await handlers().countUserScopesWithHandlersForUser(a, {
|
||||
createdBefore: 1_000,
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await handlers().countUserScopesWithHandlersForUser(a, {
|
||||
createdBefore: Date.now() + 60_000,
|
||||
}),
|
||||
).toBe(1);
|
||||
const page = await handlers().listUserScopesForUser(a);
|
||||
expect(page.total).toBe(1);
|
||||
expect(page.items).toEqual([
|
||||
expect.objectContaining({ appUid: app, handlerCount: 2 }),
|
||||
]);
|
||||
expect(
|
||||
(await handlers().listUserScopes(app)).items.map(
|
||||
(s) => s.holder,
|
||||
),
|
||||
).toEqual([a, b].sort((x, y) => x - y));
|
||||
|
||||
await bind(a, app, 'daily', 1);
|
||||
await bind(b, app, 'daily', 1, true);
|
||||
await bind(a, app, 'daily', 0);
|
||||
expect(
|
||||
await handlers().countSubscriptionsByHandler(scope(a)),
|
||||
).toEqual(new Map([['daily', 1]]));
|
||||
expect(
|
||||
await handlers().countSubscriptionsByHandler(
|
||||
appHandlerScope(app),
|
||||
),
|
||||
).toEqual(new Map([['daily', 1]]));
|
||||
|
||||
await conn.query(
|
||||
'UPDATE `event_user_handlers` SET `updated_at` = 1 WHERE `app_uid` = ?',
|
||||
[app],
|
||||
);
|
||||
const found = await handlers().listUnboundUserHandlers(1_000);
|
||||
expect(
|
||||
found.items
|
||||
.filter((item) => item.appUid === app)
|
||||
.map((item) => [item.holder, item.name]),
|
||||
).toEqual([[a, `@${hashContent(SOURCE)}`]]);
|
||||
await expect(
|
||||
handlers().deleteUserHandlerIfUnbound(scope(a), 'daily', 1_000),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
handlers().deleteUserHandlerIfUnbound(
|
||||
scope(a),
|
||||
`@${hashContent(SOURCE)}`,
|
||||
1_000,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
await expect(handlers().deleteUserScopesForHolder(b)).resolves.toBe(
|
||||
1,
|
||||
);
|
||||
await expect(handlers().deleteForApp(app)).resolves.toBe(2);
|
||||
expect(await handlers().countForApp(scope(a))).toBe(0);
|
||||
});
|
||||
|
||||
it('serves the user-scope reads from the holder-leading indexes', async () => {
|
||||
const app = `app-${uuidv4()}`;
|
||||
// A popular handler: many holders on one (app, name), so the
|
||||
// (app_uid, handler_name) index is the wrong one to pick.
|
||||
for (const holder of userIds)
|
||||
for (let i = 0; i < 80; i++)
|
||||
await bind(holder, app, 'popular', 1);
|
||||
await handlers().publish({
|
||||
scope: { appUid: app, holder: userIds[0]! },
|
||||
name: 'popular',
|
||||
source: SOURCE,
|
||||
});
|
||||
await conn.query('ANALYZE TABLE `event_subscriptions`');
|
||||
await conn.query('ANALYZE TABLE `event_user_handlers`');
|
||||
|
||||
const pread = vi.spyOn(server.clients.db, 'pread');
|
||||
const read = vi.spyOn(server.clients.db, 'read');
|
||||
try {
|
||||
await server.stores.durableSubscription.listByHandler(
|
||||
app,
|
||||
'user',
|
||||
userIds[0]!,
|
||||
'popular',
|
||||
);
|
||||
expect(await planKeys(pread)).toEqual({
|
||||
event_subscriptions: 'idx_event_subscriptions_holder',
|
||||
});
|
||||
|
||||
await handlers().countSubscriptionsByHandler({
|
||||
appUid: app,
|
||||
holder: userIds[0]!,
|
||||
});
|
||||
expect(await planKeys(read)).toEqual({
|
||||
event_subscriptions: 'idx_event_subscriptions_holder',
|
||||
});
|
||||
|
||||
await handlers().countUserScopesWithHandlersForUser(
|
||||
userIds[0]!,
|
||||
{
|
||||
createdBefore: Date.now(),
|
||||
},
|
||||
);
|
||||
expect((await planKeys(pread)).event_user_handlers).toBe(
|
||||
'idx_event_user_handlers_holder_app',
|
||||
);
|
||||
|
||||
await handlers().listUserScopesForUser(userIds[0]!);
|
||||
expect((await planKeys(read)).event_user_handlers).toBe(
|
||||
'idx_event_user_handlers_holder_app',
|
||||
);
|
||||
|
||||
await handlers().listUnboundUserHandlers(1_000);
|
||||
expect((await planKeys(read)).s).toBe(
|
||||
'idx_event_subscriptions_holder',
|
||||
);
|
||||
} finally {
|
||||
pread.mockRestore();
|
||||
read.mockRestore();
|
||||
}
|
||||
}, 120_000);
|
||||
},
|
||||
);
|
||||
@@ -21,6 +21,7 @@ import { createHash } from 'node:crypto';
|
||||
import {
|
||||
EVENTS_HANDLERS_PER_APP,
|
||||
EVENTS_HANDLER_SOURCE_MAX_BYTES,
|
||||
EVENTS_USER_HANDLERS_PER_SCOPE,
|
||||
} from '../../controllers/events/limits.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
@@ -47,6 +48,7 @@ import { PuterStore } from '../types.js';
|
||||
*/
|
||||
|
||||
const TABLE = 'event_handlers';
|
||||
const USER_TABLE = 'event_user_handlers';
|
||||
const SUBSCRIPTION_TABLE = 'event_subscriptions';
|
||||
const APP_TABLE = 'apps';
|
||||
|
||||
@@ -57,12 +59,24 @@ const APP_TABLE = 'apps';
|
||||
export const EVENTS_WORKERS_LIST_DEFAULT_LIMIT = 50;
|
||||
export const EVENTS_WORKERS_LIST_LIMIT_CAP = 200;
|
||||
|
||||
/** Default page of `listUserScopes`, and of one GC read. */
|
||||
const USER_SCOPES_PAGE_DEFAULT = 500;
|
||||
const USER_SCOPES_PAGE_CAP = 1000;
|
||||
const UNBOUND_HANDLERS_BATCH = 200;
|
||||
|
||||
/** Longest a handler name may be, matching the column that holds it. */
|
||||
export const HANDLER_NAME_MAX_LENGTH = 128;
|
||||
|
||||
/** Names are addressable identifiers, not free text. */
|
||||
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/;
|
||||
|
||||
/**
|
||||
* A server-minted name for an inline handler: `@` and the source hash. `@` is
|
||||
* outside {@link NAME_PATTERN}, so a minted name never collides with a chosen
|
||||
* one, and only a user scope accepts it.
|
||||
*/
|
||||
const INLINE_NAME_PATTERN = /^@[0-9a-f]{64}$/;
|
||||
|
||||
/**
|
||||
* Longest a delivery may address an app's handler set from cache. Every write
|
||||
* here drops the entry on every node, so this only bounds a read that raced a
|
||||
@@ -70,15 +84,66 @@ const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/;
|
||||
*/
|
||||
export const HANDLER_SET_CACHE_TTL_SECONDS = 30;
|
||||
|
||||
const handlerSetCacheKey = (appUid: string): string =>
|
||||
`eventHandlers:set:${appUid}`;
|
||||
// -- Scopes -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whose handlers a call is about: an app's own set (`holder` 0), or the
|
||||
* handlers one user published into their scope of that app. The two live in
|
||||
* separate tables, so code that predates user scopes never reads one.
|
||||
*/
|
||||
export interface HandlerScope {
|
||||
appUid: string;
|
||||
/** `users.id` of the scope's holder, or 0 for the app's own set. */
|
||||
holder: number;
|
||||
}
|
||||
|
||||
/** The app's own scope. */
|
||||
export const appHandlerScope = (appUid: string): HandlerScope => ({
|
||||
appUid,
|
||||
holder: 0,
|
||||
});
|
||||
|
||||
const isUserScope = (scope: HandlerScope): boolean => scope.holder !== 0;
|
||||
|
||||
const assertScope = (scope: HandlerScope): void => {
|
||||
if (!Number.isInteger(scope.holder) || scope.holder < 0)
|
||||
throw new TypeError('handler scope holder must be a user id or 0');
|
||||
};
|
||||
|
||||
const tableOf = (scope: HandlerScope): string =>
|
||||
isUserScope(scope) ? USER_TABLE : TABLE;
|
||||
|
||||
const handlerCapOf = (scope: HandlerScope): number =>
|
||||
isUserScope(scope)
|
||||
? EVENTS_USER_HANDLERS_PER_SCOPE
|
||||
: EVENTS_HANDLERS_PER_APP;
|
||||
|
||||
/** The WHERE clause for a scope's rows and its parameters. */
|
||||
const scopeFilter = (
|
||||
scope: HandlerScope,
|
||||
): { where: string; params: unknown[] } => {
|
||||
assertScope(scope);
|
||||
return isUserScope(scope)
|
||||
? {
|
||||
where: '`app_uid` = ? AND `holder_user_id` = ?',
|
||||
params: [scope.appUid, scope.holder],
|
||||
}
|
||||
: { where: '`app_uid` = ?', params: [scope.appUid] };
|
||||
};
|
||||
|
||||
// App scope keeps the key it always had, so nodes on either side of a rolling
|
||||
// deploy share it.
|
||||
const handlerSetCacheKey = (scope: HandlerScope): string =>
|
||||
isUserScope(scope)
|
||||
? `eventHandlers:uset:${scope.appUid}:${scope.holder}`
|
||||
: `eventHandlers:set:${scope.appUid}`;
|
||||
|
||||
/**
|
||||
* The same set as read from the replica, kept apart so a lagging read can never
|
||||
* stand in for the primary's answer on the delivery path.
|
||||
*/
|
||||
const replicaSetCacheKey = (appUid: string): string =>
|
||||
`${handlerSetCacheKey(appUid)}:r`;
|
||||
const replicaSetCacheKey = (scope: HandlerScope): string =>
|
||||
`${handlerSetCacheKey(scope)}:r`;
|
||||
|
||||
// -- Shapes -----------------------------------------------------------
|
||||
|
||||
@@ -126,8 +191,37 @@ export interface ListEventsWorkersOptions {
|
||||
appUid?: string;
|
||||
}
|
||||
|
||||
export interface PublishHandlerInput {
|
||||
/** One user scope: the handlers a user keeps in one app. */
|
||||
export interface UserScopeSummary {
|
||||
appUid: string;
|
||||
/** Empty when the app row is gone. */
|
||||
appName: string;
|
||||
appTitle: string;
|
||||
handlerCount: number;
|
||||
/** Earliest handler `created_at` in the scope. */
|
||||
createdAt: number;
|
||||
/** Latest handler `updated_at` in the scope. */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** A user scope as an app-wide listing reports it. */
|
||||
export interface AppUserScope {
|
||||
holder: number;
|
||||
handlerCount: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** One user-scope handler a GC pass may delete. */
|
||||
export interface UnboundUserHandler {
|
||||
id: number;
|
||||
appUid: string;
|
||||
holder: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PublishHandlerInput {
|
||||
scope: HandlerScope;
|
||||
name: string;
|
||||
source: string;
|
||||
/**
|
||||
@@ -137,6 +231,11 @@ export interface PublishHandlerInput {
|
||||
ifHash?: string | null;
|
||||
/** Take the name whatever is published under it. */
|
||||
replace?: boolean;
|
||||
/**
|
||||
* A create leaves peer nodes' cached sets alone: one missing the new name
|
||||
* is re-read when asked for it. Updates always broadcast.
|
||||
*/
|
||||
broadcast?: boolean;
|
||||
}
|
||||
|
||||
/** What one publish did, which is what the caller reports back. */
|
||||
@@ -169,10 +268,12 @@ const invalidSource = (): HttpError =>
|
||||
legacyCode: 'events_handler_source_invalid',
|
||||
});
|
||||
|
||||
const tooManyHandlers = (): HttpError =>
|
||||
const tooManyHandlers = (scope: HandlerScope): HttpError =>
|
||||
new HttpError(
|
||||
429,
|
||||
`An app may publish ${EVENTS_HANDLERS_PER_APP} handlers`,
|
||||
isUserScope(scope)
|
||||
? `A user scope may hold ${EVENTS_USER_HANDLERS_PER_SCOPE} handlers`
|
||||
: `An app may publish ${EVENTS_HANDLERS_PER_APP} handlers`,
|
||||
{ legacyCode: 'events_handler_limit' },
|
||||
);
|
||||
|
||||
@@ -211,10 +312,11 @@ const SELECT_COLUMNS =
|
||||
export const hashContent = (content: string): string =>
|
||||
createHash('sha256').update(content, 'utf8').digest('hex');
|
||||
|
||||
export const isValidHandlerName = (name: string): boolean =>
|
||||
/** `userScope` also admits the `@<sha256>` names minted for inline handlers. */
|
||||
export const isValidHandlerName = (name: string, userScope = false): boolean =>
|
||||
name.length > 0 &&
|
||||
name.length <= HANDLER_NAME_MAX_LENGTH &&
|
||||
NAME_PATTERN.test(name);
|
||||
(NAME_PATTERN.test(name) || (userScope && INLINE_NAME_PATTERN.test(name)));
|
||||
|
||||
export class EventHandlerStore extends PuterStore {
|
||||
// -- Writes ------------------------------------------------------
|
||||
@@ -227,11 +329,12 @@ export class EventHandlerStore extends PuterStore {
|
||||
* with a row that no longer exists.
|
||||
*/
|
||||
async publish(input: PublishHandlerInput): Promise<PublishResult> {
|
||||
const name = this.#assertName(input.name);
|
||||
const { scope } = input;
|
||||
const name = this.#assertName(input.name, scope);
|
||||
const source = this.#assertSource(input.source);
|
||||
const sourceHash = hashContent(source);
|
||||
|
||||
const existing = await this.getByName(input.appUid, name);
|
||||
const existing = await this.getByName(scope, name);
|
||||
if (!existing) {
|
||||
// `ifHash` naming a source that is not there is the same race from
|
||||
// the other side: something removed the row this publish was
|
||||
@@ -239,10 +342,11 @@ export class EventHandlerStore extends PuterStore {
|
||||
if (input.ifHash && !input.replace) throw publishConflict(name);
|
||||
return {
|
||||
handler: await this.#insert(
|
||||
input.appUid,
|
||||
scope,
|
||||
name,
|
||||
source,
|
||||
sourceHash,
|
||||
input.broadcast !== false,
|
||||
),
|
||||
outcome: 'created',
|
||||
};
|
||||
@@ -260,34 +364,131 @@ export class EventHandlerStore extends PuterStore {
|
||||
if (!input.replace && !basedOnPublished) throw publishConflict(name);
|
||||
|
||||
return {
|
||||
handler: await this.#update(existing, source, sourceHash),
|
||||
handler: await this.#update(scope, existing, source, sourceHash),
|
||||
outcome: 'updated',
|
||||
};
|
||||
}
|
||||
|
||||
/** Drop one handler. Null when the app had nothing published by that name. */
|
||||
async remove(appUid: string, name: string): Promise<EventHandler | null> {
|
||||
const existing = await this.getByName(appUid, name);
|
||||
/** Drop one handler. Null when the scope had nothing published by that name. */
|
||||
async remove(
|
||||
scope: HandlerScope,
|
||||
name: string,
|
||||
): Promise<EventHandler | null> {
|
||||
const existing = await this.getByName(scope, name);
|
||||
if (!existing) return null;
|
||||
const { where, params } = scopeFilter(scope);
|
||||
await this.clients.db.write(
|
||||
`DELETE FROM \`${TABLE}\` WHERE \`app_uid\` = ? AND \`name\` = ?`,
|
||||
[appUid, name],
|
||||
`DELETE FROM \`${tableOf(scope)}\` WHERE ${where} AND \`name\` = ?`,
|
||||
[...params, name],
|
||||
);
|
||||
await this.#forgetSet(appUid);
|
||||
await this.#forgetSet(scope);
|
||||
return existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every handler an app has published — what a deleted app leaves
|
||||
* behind.
|
||||
* Drop every handler an app has published, in its own set and in every
|
||||
* user's scope — what a deleted app leaves behind. Returns the rows
|
||||
* removed. User scopes go a batch of holders at a time, each batch's cached
|
||||
* sets dropped here and on every peer.
|
||||
*/
|
||||
async deleteForApp(appUid: string): Promise<number> {
|
||||
const result = await this.clients.db.write(
|
||||
`DELETE FROM \`${TABLE}\` WHERE \`app_uid\` = ?`,
|
||||
[appUid],
|
||||
);
|
||||
await this.#forgetSet(appUid);
|
||||
return result.affectedRows;
|
||||
let removed = result.affectedRows ?? 0;
|
||||
for (;;) {
|
||||
const holders = (
|
||||
await this.clients.db.pread(
|
||||
`SELECT DISTINCT \`holder_user_id\` FROM \`${USER_TABLE}\` ` +
|
||||
'WHERE `app_uid` = ? ORDER BY `holder_user_id` LIMIT ?',
|
||||
[appUid, USER_SCOPES_PAGE_DEFAULT],
|
||||
)
|
||||
).map((row) => Number(row.holder_user_id));
|
||||
if (holders.length === 0) break;
|
||||
const batch = await this.clients.db.write(
|
||||
`DELETE FROM \`${USER_TABLE}\` WHERE \`app_uid\` = ? ` +
|
||||
`AND \`holder_user_id\` IN (${holders.map(() => '?').join(', ')})`,
|
||||
[appUid, ...holders],
|
||||
);
|
||||
await this.#forgetSets(
|
||||
holders.map((holder) => ({ appUid, holder })),
|
||||
);
|
||||
const affected = batch.affectedRows ?? 0;
|
||||
removed += affected;
|
||||
if (affected === 0) break;
|
||||
}
|
||||
await this.#forgetSet(appHandlerScope(appUid));
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Drop one user's scope of one app. Returns the handlers removed. */
|
||||
async deleteUserScope(appUid: string, holder: number): Promise<number> {
|
||||
const scope = { appUid, holder };
|
||||
if (!isUserScope(scope)) return 0;
|
||||
assertScope(scope);
|
||||
const result = await this.clients.db.write(
|
||||
`DELETE FROM \`${USER_TABLE}\` WHERE \`app_uid\` = ? AND \`holder_user_id\` = ?`,
|
||||
[appUid, holder],
|
||||
);
|
||||
await this.#forgetSet(scope);
|
||||
return result.affectedRows ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every scope a user holds, across apps — what an account deletion
|
||||
* leaves behind. Returns the handlers removed.
|
||||
*/
|
||||
async deleteUserScopesForHolder(userId: number): Promise<number> {
|
||||
if (!Number.isInteger(userId) || userId <= 0) return 0;
|
||||
const apps = await this.clients.db.pread(
|
||||
`SELECT DISTINCT \`app_uid\` FROM \`${USER_TABLE}\` WHERE \`holder_user_id\` = ?`,
|
||||
[userId],
|
||||
);
|
||||
const result = await this.clients.db.write(
|
||||
`DELETE FROM \`${USER_TABLE}\` WHERE \`holder_user_id\` = ?`,
|
||||
[userId],
|
||||
);
|
||||
for (const row of apps)
|
||||
await this.#forgetSet({
|
||||
appUid: String(row.app_uid),
|
||||
holder: userId,
|
||||
});
|
||||
return result.affectedRows ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one user-scope handler if no subscription row of any state is
|
||||
* bound to it and it was last touched before `olderThan` (unix seconds).
|
||||
* The check and the delete are one statement, so a row bound in between
|
||||
* keeps the handler. True when it deleted.
|
||||
*/
|
||||
async deleteUserHandlerIfUnbound(
|
||||
scope: HandlerScope,
|
||||
name: string,
|
||||
olderThan: number,
|
||||
): Promise<boolean> {
|
||||
if (!isUserScope(scope)) return false;
|
||||
assertScope(scope);
|
||||
const result = await this.clients.db.write(
|
||||
`DELETE FROM \`${USER_TABLE}\` WHERE \`app_uid\` = ? AND \`holder_user_id\` = ? ` +
|
||||
'AND `name` = ? AND `updated_at` < ? ' +
|
||||
`AND NOT EXISTS (SELECT 1 FROM \`${SUBSCRIPTION_TABLE}\` ` +
|
||||
'WHERE `holder_user_id` = ? AND `app_uid` = ? ' +
|
||||
'AND `handler_scope` = 1 AND `handler_name` = ?)',
|
||||
[
|
||||
scope.appUid,
|
||||
scope.holder,
|
||||
name,
|
||||
olderThan,
|
||||
scope.holder,
|
||||
scope.appUid,
|
||||
name,
|
||||
],
|
||||
);
|
||||
if (!result.anyRowsAffected) return false;
|
||||
await this.#forgetSet(scope);
|
||||
return true;
|
||||
}
|
||||
|
||||
// -- Reads -------------------------------------------------------
|
||||
@@ -299,34 +500,36 @@ export class EventHandlerStore extends PuterStore {
|
||||
* absent.
|
||||
*/
|
||||
async getByName(
|
||||
appUid: string,
|
||||
scope: HandlerScope,
|
||||
name: string,
|
||||
): Promise<EventHandler | null> {
|
||||
const { where, params } = scopeFilter(scope);
|
||||
const rows = await this.clients.db.pread(
|
||||
`SELECT ${SELECT_COLUMNS} FROM \`${TABLE}\` ` +
|
||||
'WHERE `app_uid` = ? AND `name` = ?',
|
||||
[appUid, name],
|
||||
`SELECT ${SELECT_COLUMNS} FROM \`${tableOf(scope)}\` ` +
|
||||
`WHERE ${where} AND \`name\` = ?`,
|
||||
[...params, name],
|
||||
);
|
||||
return rows.length > 0 ? toRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an app has published, with how many subscriptions each name is
|
||||
* carrying. Bounded by the per-app cap, so the whole set is one page.
|
||||
* What a scope has published, with how many subscriptions each name is
|
||||
* carrying. Bounded by the scope's cap, so the whole set is one page.
|
||||
*
|
||||
* The counts come from one grouped read rather than a join, because the
|
||||
* subscription side has to count names an app never published — a handler
|
||||
* subscription side has to count names a scope never published — a handler
|
||||
* removed while subscriptions still pointed at it leaves exactly that.
|
||||
*/
|
||||
async listForApp(appUid: string): Promise<EventHandlerSummary[]> {
|
||||
async listForApp(scope: HandlerScope): Promise<EventHandlerSummary[]> {
|
||||
const { where, params } = scopeFilter(scope);
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT `name`, `source_hash`, `updated_at` ' +
|
||||
`FROM \`${TABLE}\` WHERE \`app_uid\` = ? ` +
|
||||
`FROM \`${tableOf(scope)}\` WHERE ${where} ` +
|
||||
'ORDER BY `name` LIMIT ?',
|
||||
[appUid, EVENTS_HANDLERS_PER_APP],
|
||||
[...params, handlerCapOf(scope)],
|
||||
);
|
||||
|
||||
const counts = await this.countSubscriptionsByHandler(appUid);
|
||||
const counts = await this.countSubscriptionsByHandler(scope);
|
||||
return rows.map((row) => ({
|
||||
name: String(row.name),
|
||||
hash: String(row.source_hash),
|
||||
@@ -336,21 +539,22 @@ export class EventHandlerStore extends PuterStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Name and source hash for every handler an app has published: the identity
|
||||
* of its set, without reading a byte of source. What a deploy checks the
|
||||
* script it was asked for against, and what the delivery path's cache is
|
||||
* filled from. Primary, so neither reads a set a replica has not caught up
|
||||
* to.
|
||||
* Name and source hash for every handler a scope has published: the
|
||||
* identity of its set, without reading a byte of source. What a deploy
|
||||
* checks the script it was asked for against, and what the delivery path's
|
||||
* cache is filled from. Primary, so neither reads a set a replica has not
|
||||
* caught up to.
|
||||
*/
|
||||
async setForApp(
|
||||
appUid: string,
|
||||
scope: HandlerScope,
|
||||
options: { replica?: boolean } = {},
|
||||
): Promise<Pick<EventHandler, 'name' | 'sourceHash'>[]> {
|
||||
const { where, params: whereParams } = scopeFilter(scope);
|
||||
const sql =
|
||||
'SELECT `name`, `source_hash` ' +
|
||||
`FROM \`${TABLE}\` WHERE \`app_uid\` = ? ` +
|
||||
`FROM \`${tableOf(scope)}\` WHERE ${where} ` +
|
||||
'ORDER BY `name` LIMIT ?';
|
||||
const params = [appUid, EVENTS_HANDLERS_PER_APP];
|
||||
const params = [...whereParams, handlerCapOf(scope)];
|
||||
const rows = options.replica
|
||||
? await this.clients.db.read(sql, params)
|
||||
: await this.clients.db.pread(sql, params);
|
||||
@@ -371,7 +575,7 @@ export class EventHandlerStore extends PuterStore {
|
||||
* right after a publish would fail the run. Called by extensions.
|
||||
*/
|
||||
async cachedSetForApp(
|
||||
appUid: string,
|
||||
scope: HandlerScope,
|
||||
options: {
|
||||
missRead?: 'primary' | 'replica';
|
||||
handlerName?: string;
|
||||
@@ -384,8 +588,8 @@ export class EventHandlerStore extends PuterStore {
|
||||
(options.handlerName === undefined ||
|
||||
set.some((handler) => handler.name === options.handlerName));
|
||||
const key = replica
|
||||
? replicaSetCacheKey(appUid)
|
||||
: handlerSetCacheKey(appUid);
|
||||
? replicaSetCacheKey(scope)
|
||||
: handlerSetCacheKey(scope);
|
||||
try {
|
||||
const cached = JSON.parse(
|
||||
(await this.clients.redis.get(key)) ?? 'null',
|
||||
@@ -399,8 +603,8 @@ export class EventHandlerStore extends PuterStore {
|
||||
// Read the table instead.
|
||||
}
|
||||
|
||||
let set = await this.setForApp(appUid, { replica });
|
||||
if (replica && !usable(set)) set = await this.setForApp(appUid);
|
||||
let set = await this.setForApp(scope, { replica });
|
||||
if (replica && !usable(set)) set = await this.setForApp(scope);
|
||||
if (set.length > 0) {
|
||||
try {
|
||||
await this.clients.redis.set(
|
||||
@@ -417,57 +621,70 @@ export class EventHandlerStore extends PuterStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every handler the app has published, sources included — what the events
|
||||
* Every handler the scope has published, sources included — what the events
|
||||
* worker is generated from. Primary, for the same reason `getByName` is:
|
||||
* the bake follows the publish that triggered it.
|
||||
*/
|
||||
async allForApp(appUid: string): Promise<EventHandler[]> {
|
||||
async allForApp(scope: HandlerScope): Promise<EventHandler[]> {
|
||||
const { where, params } = scopeFilter(scope);
|
||||
const rows = await this.clients.db.pread(
|
||||
`SELECT ${SELECT_COLUMNS} FROM \`${TABLE}\` ` +
|
||||
'WHERE `app_uid` = ? ORDER BY `name` LIMIT ?',
|
||||
[appUid, EVENTS_HANDLERS_PER_APP],
|
||||
`SELECT ${SELECT_COLUMNS} FROM \`${tableOf(scope)}\` ` +
|
||||
`WHERE ${where} ORDER BY \`name\` LIMIT ?`,
|
||||
[...params, handlerCapOf(scope)],
|
||||
);
|
||||
return rows.map(toRow);
|
||||
}
|
||||
|
||||
async countForApp(appUid: string): Promise<number> {
|
||||
async countForApp(scope: HandlerScope): Promise<number> {
|
||||
const { where, params } = scopeFilter(scope);
|
||||
const [row] = await this.clients.db.pread(
|
||||
`SELECT COUNT(*) AS \`total\` FROM \`${TABLE}\` WHERE \`app_uid\` = ?`,
|
||||
[appUid],
|
||||
`SELECT COUNT(*) AS \`total\` FROM \`${tableOf(scope)}\` WHERE ${where}`,
|
||||
params,
|
||||
);
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes of source an app has published, across every handler. What a
|
||||
* publish is checked against before it lands, so the app's generated events
|
||||
* Bytes of source a scope has published, across every handler. What a
|
||||
* publish is checked against before it lands, so the generated events
|
||||
* worker never grows past what the runtime may load.
|
||||
*
|
||||
* `OCTET_LENGTH`, not `LENGTH`: only MySQL measures the latter in bytes,
|
||||
* and the caller compares this against `Buffer.byteLength`.
|
||||
*/
|
||||
async totalSourceBytesForApp(appUid: string): Promise<number> {
|
||||
async totalSourceBytesForApp(scope: HandlerScope): Promise<number> {
|
||||
const { where, params } = scopeFilter(scope);
|
||||
const [row] = await this.clients.db.pread(
|
||||
`SELECT SUM(OCTET_LENGTH(\`source\`)) AS \`total\` FROM \`${TABLE}\` WHERE \`app_uid\` = ?`,
|
||||
[appUid],
|
||||
`SELECT SUM(OCTET_LENGTH(\`source\`)) AS \`total\` FROM \`${tableOf(scope)}\` WHERE ${where}`,
|
||||
params,
|
||||
);
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many subscriptions each of an app's handler names is carrying,
|
||||
* How many subscriptions each of a scope's handler names is carrying,
|
||||
* suspended rows included — a suspended subscription is still a dependent,
|
||||
* and it is the reason a removal is not a delete.
|
||||
*
|
||||
* A user scope reads by holder, so it never scans the other holders of a
|
||||
* popular app.
|
||||
*/
|
||||
async countSubscriptionsByHandler(
|
||||
appUid: string,
|
||||
scope: HandlerScope,
|
||||
): Promise<Map<string, number>> {
|
||||
assertScope(scope);
|
||||
const filter = isUserScope(scope)
|
||||
? '`holder_user_id` = ? AND `app_uid` = ? AND `handler_scope` = 1'
|
||||
: '`app_uid` = ? AND `handler_scope` = 0';
|
||||
const params = isUserScope(scope)
|
||||
? [scope.holder, scope.appUid]
|
||||
: [scope.appUid];
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT `handler_name`, COUNT(*) AS `total` ' +
|
||||
`FROM \`${SUBSCRIPTION_TABLE}\` ` +
|
||||
'WHERE `app_uid` = ? AND `handler_name` IS NOT NULL ' +
|
||||
`WHERE ${filter} AND \`handler_name\` IS NOT NULL ` +
|
||||
'GROUP BY `handler_name`',
|
||||
[appUid],
|
||||
params,
|
||||
);
|
||||
return new Map(
|
||||
rows.map((row) => [
|
||||
@@ -480,7 +697,8 @@ export class EventHandlerStore extends PuterStore {
|
||||
/**
|
||||
* How many of an owner's apps have at least one published handler — what a
|
||||
* rent listener counts to tell a first publish (0→1) from a redeploy, and a
|
||||
* last removal (1→0) from one of several.
|
||||
* last removal (1→0) from one of several. Reads the apps' own sets only:
|
||||
* user scopes are never the owner's.
|
||||
*
|
||||
* `createdBefore` (epoch ms) restricts to apps whose events worker existed
|
||||
* before that time, by its earliest handler; used to bill only what was
|
||||
@@ -512,10 +730,36 @@ export class EventHandlerStore extends PuterStore {
|
||||
return Number(rows[0]?.total ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many apps a user holds a scope in — the rent count for user scopes,
|
||||
* with `createdBefore` (epoch ms) meaning what it does for
|
||||
* {@link countAppsWithHandlersForOwner}.
|
||||
*/
|
||||
async countUserScopesWithHandlersForUser(
|
||||
userId: number,
|
||||
opts: { createdBefore?: number } = {},
|
||||
): Promise<number> {
|
||||
const params: unknown[] = [userId];
|
||||
let having = '';
|
||||
if (opts.createdBefore !== undefined) {
|
||||
having = ' HAVING MIN(`created_at`) < ?';
|
||||
params.push(Math.ceil(opts.createdBefore / 1000));
|
||||
}
|
||||
const rows = await this.clients.db.pread(
|
||||
'SELECT COUNT(*) AS `total` FROM (' +
|
||||
`SELECT \`app_uid\` FROM \`${USER_TABLE}\` ` +
|
||||
`WHERE \`holder_user_id\` = ? GROUP BY \`app_uid\`${having}` +
|
||||
') AS `counted`',
|
||||
params,
|
||||
);
|
||||
return Number(rows[0]?.total ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The events workers an owner is running, one row per app with at least one
|
||||
* published handler. Keyset-paginated on `app_uid`, since the grouping key
|
||||
* is the only stable order a set this shape can offer.
|
||||
* is the only stable order a set this shape can offer. Reads the apps' own
|
||||
* sets only.
|
||||
*/
|
||||
async listEventsWorkersForOwner(
|
||||
ownerUserId: number,
|
||||
@@ -574,21 +818,167 @@ export class EventHandlerStore extends PuterStore {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The scopes one user holds, one row per app, keyset-paginated on
|
||||
* `app_uid`. `total` counts every scope the user holds (or the one app's,
|
||||
* when `appUid` narrows it).
|
||||
*/
|
||||
async listUserScopesForUser(
|
||||
userId: number,
|
||||
opts: ListEventsWorkersOptions = {},
|
||||
): Promise<PageResult<UserScopeSummary>> {
|
||||
const limit = Math.min(
|
||||
Math.max(
|
||||
1,
|
||||
Math.floor(opts.limit ?? EVENTS_WORKERS_LIST_DEFAULT_LIMIT),
|
||||
),
|
||||
EVENTS_WORKERS_LIST_LIMIT_CAP,
|
||||
);
|
||||
const after = decodeCursor(opts.cursor)?.appUid;
|
||||
|
||||
const where = [`\`${USER_TABLE}\`.\`holder_user_id\` = ?`];
|
||||
const params: unknown[] = [userId];
|
||||
if (typeof opts.appUid === 'string') {
|
||||
where.push(`\`${USER_TABLE}\`.\`app_uid\` = ?`);
|
||||
params.push(opts.appUid);
|
||||
}
|
||||
const totalRows = await this.clients.db.read(
|
||||
'SELECT COUNT(*) AS `total` FROM (' +
|
||||
`SELECT \`${USER_TABLE}\`.\`app_uid\` FROM \`${USER_TABLE}\` ` +
|
||||
`WHERE ${where.join(' AND ')} GROUP BY \`${USER_TABLE}\`.\`app_uid\`` +
|
||||
') AS `counted`',
|
||||
params,
|
||||
);
|
||||
if (typeof after === 'string') {
|
||||
where.push(`\`${USER_TABLE}\`.\`app_uid\` > ?`);
|
||||
params.push(after);
|
||||
}
|
||||
|
||||
const rows = await this.clients.db.read(
|
||||
`SELECT \`${USER_TABLE}\`.\`app_uid\` AS \`app_uid\`, ` +
|
||||
`\`${APP_TABLE}\`.\`name\` AS \`app_name\`, ` +
|
||||
`\`${APP_TABLE}\`.\`title\` AS \`app_title\`, ` +
|
||||
'COUNT(*) AS `handler_count`, ' +
|
||||
`MIN(\`${USER_TABLE}\`.\`created_at\`) AS \`created_at\`, ` +
|
||||
`MAX(\`${USER_TABLE}\`.\`updated_at\`) AS \`updated_at\` ` +
|
||||
`FROM \`${USER_TABLE}\` ` +
|
||||
`LEFT JOIN \`${APP_TABLE}\` ON \`${APP_TABLE}\`.\`uid\` = \`${USER_TABLE}\`.\`app_uid\` ` +
|
||||
`WHERE ${where.join(' AND ')} ` +
|
||||
`GROUP BY \`${USER_TABLE}\`.\`app_uid\`, \`${APP_TABLE}\`.\`name\`, \`${APP_TABLE}\`.\`title\` ` +
|
||||
`ORDER BY \`${USER_TABLE}\`.\`app_uid\` LIMIT ?`,
|
||||
[...params, limit + 1],
|
||||
);
|
||||
|
||||
const page = keysetPage(rows, limit, undefined, (last) => ({
|
||||
appUid: last.app_uid,
|
||||
}));
|
||||
const result: PageResult<UserScopeSummary> = {
|
||||
items: page.rows.map((row) => ({
|
||||
appUid: String(row.app_uid),
|
||||
appName: row.app_name == null ? '' : String(row.app_name),
|
||||
appTitle: row.app_title == null ? '' : String(row.app_title),
|
||||
handlerCount: Number(row.handler_count) || 0,
|
||||
createdAt: Number(row.created_at) || 0,
|
||||
updatedAt: Number(row.updated_at) || 0,
|
||||
})),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
if (page.cursor) result.cursor = page.cursor;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user scopes one app holds, ordered by holder. A batch scan: it
|
||||
* carries no `total`, to spare a COUNT per page. `cursor` is null at the
|
||||
* end.
|
||||
*/
|
||||
async listUserScopes(
|
||||
appUid: string,
|
||||
opts: { limit?: number; cursor?: string } = {},
|
||||
): Promise<{ items: AppUserScope[]; cursor: string | null }> {
|
||||
const limit = Math.min(
|
||||
Math.max(1, Math.floor(opts.limit ?? USER_SCOPES_PAGE_DEFAULT)),
|
||||
USER_SCOPES_PAGE_CAP,
|
||||
);
|
||||
const after = Number(decodeCursor(opts.cursor)?.holder);
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT `holder_user_id`, COUNT(*) AS `handler_count`, ' +
|
||||
'MIN(`created_at`) AS `created_at`, ' +
|
||||
'MAX(`updated_at`) AS `updated_at` ' +
|
||||
`FROM \`${USER_TABLE}\` WHERE \`app_uid\` = ? AND \`holder_user_id\` > ? ` +
|
||||
'GROUP BY `holder_user_id` ORDER BY `holder_user_id` LIMIT ?',
|
||||
[appUid, Number.isFinite(after) ? after : 0, limit + 1],
|
||||
);
|
||||
const page = keysetPage(rows, limit, undefined, (last) => ({
|
||||
holder: Number(last.holder_user_id),
|
||||
}));
|
||||
return {
|
||||
items: page.rows.map((row) => ({
|
||||
holder: Number(row.holder_user_id),
|
||||
handlerCount: Number(row.handler_count) || 0,
|
||||
createdAt: Number(row.created_at) || 0,
|
||||
updatedAt: Number(row.updated_at) || 0,
|
||||
})),
|
||||
cursor: page.cursor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One keyset page of user-scope handlers last touched before `olderThan`
|
||||
* (unix seconds) that no subscription row of any state is bound to. A batch
|
||||
* scan: it carries no `total`, to spare a COUNT per page. `cursor` is null
|
||||
* at the end. A candidate may be bound before it is deleted, so delete
|
||||
* through {@link deleteUserHandlerIfUnbound}.
|
||||
*/
|
||||
async listUnboundUserHandlers(
|
||||
olderThan: number,
|
||||
opts: { limit?: number; cursor?: string } = {},
|
||||
): Promise<{ items: UnboundUserHandler[]; cursor: string | null }> {
|
||||
const limit = Math.max(
|
||||
1,
|
||||
Math.floor(opts.limit ?? UNBOUND_HANDLERS_BATCH),
|
||||
);
|
||||
const after = Number(decodeCursor(opts.cursor)?.id);
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT h.`id`, h.`app_uid`, h.`holder_user_id`, h.`name` ' +
|
||||
`FROM \`${USER_TABLE}\` h ` +
|
||||
'WHERE h.`id` > ? AND h.`updated_at` < ? ' +
|
||||
`AND NOT EXISTS (SELECT 1 FROM \`${SUBSCRIPTION_TABLE}\` s ` +
|
||||
'WHERE s.`holder_user_id` = h.`holder_user_id` ' +
|
||||
'AND s.`app_uid` = h.`app_uid` AND s.`handler_scope` = 1 ' +
|
||||
'AND s.`handler_name` = h.`name`) ' +
|
||||
'ORDER BY h.`id` LIMIT ?',
|
||||
[Number.isFinite(after) ? after : 0, olderThan, limit + 1],
|
||||
);
|
||||
const page = keysetPage(rows, limit, undefined);
|
||||
return {
|
||||
items: page.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
appUid: String(row.app_uid),
|
||||
holder: Number(row.holder_user_id),
|
||||
name: String(row.name),
|
||||
})),
|
||||
cursor: page.cursor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Internals ---------------------------------------------------
|
||||
|
||||
async #insert(
|
||||
appUid: string,
|
||||
scope: HandlerScope,
|
||||
name: string,
|
||||
source: string,
|
||||
sourceHash: string,
|
||||
broadcast: boolean,
|
||||
): Promise<EventHandler> {
|
||||
if ((await this.countForApp(appUid)) >= EVENTS_HANDLERS_PER_APP)
|
||||
throw tooManyHandlers();
|
||||
if ((await this.countForApp(scope)) >= handlerCapOf(scope))
|
||||
throw tooManyHandlers(scope);
|
||||
|
||||
const at = nowSeconds();
|
||||
try {
|
||||
await this.clients.db.insert(TABLE, {
|
||||
app_uid: appUid,
|
||||
await this.clients.db.insert(tableOf(scope), {
|
||||
app_uid: scope.appUid,
|
||||
...(isUserScope(scope) ? { holder_user_id: scope.holder } : {}),
|
||||
name,
|
||||
source,
|
||||
source_hash: sourceHash,
|
||||
@@ -603,9 +993,25 @@ export class EventHandlerStore extends PuterStore {
|
||||
if (isUniqueViolation(err)) throw publishConflict(name);
|
||||
throw err;
|
||||
}
|
||||
await this.#forgetSet(appUid);
|
||||
// Concurrent creates can all pass the count above. Counted again with
|
||||
// this row in it, each one past the cap takes itself back out — at the
|
||||
// last slot that may refuse more than it had to, never fewer. User
|
||||
// scopes only: reads stop at the cap, so a row past it would be unseen.
|
||||
if (
|
||||
isUserScope(scope) &&
|
||||
(await this.countForApp(scope)) > handlerCapOf(scope)
|
||||
) {
|
||||
const { where, params } = scopeFilter(scope);
|
||||
await this.clients.db.write(
|
||||
`DELETE FROM \`${tableOf(scope)}\` WHERE ${where} AND \`name\` = ?`,
|
||||
[...params, name],
|
||||
);
|
||||
await this.#forgetSet(scope);
|
||||
throw tooManyHandlers(scope);
|
||||
}
|
||||
await this.#forgetSet(scope, broadcast);
|
||||
return {
|
||||
appUid,
|
||||
appUid: scope.appUid,
|
||||
name,
|
||||
source,
|
||||
sourceHash,
|
||||
@@ -615,23 +1021,25 @@ export class EventHandlerStore extends PuterStore {
|
||||
}
|
||||
|
||||
async #update(
|
||||
scope: HandlerScope,
|
||||
existing: EventHandler,
|
||||
source: string,
|
||||
sourceHash: string,
|
||||
): Promise<EventHandler> {
|
||||
const at = nowSeconds();
|
||||
const { where, params } = scopeFilter(scope);
|
||||
// The `source_hash` predicate is what makes the check-then-write a
|
||||
// compare-and-set: a publish that raced past the read above updates
|
||||
// nothing here, and its caller is told.
|
||||
const updated = await this.clients.db.write(
|
||||
`UPDATE \`${TABLE}\` SET \`source\` = ?, \`source_hash\` = ?, ` +
|
||||
'`updated_at` = ? WHERE `app_uid` = ? AND `name` = ? ' +
|
||||
`UPDATE \`${tableOf(scope)}\` SET \`source\` = ?, \`source_hash\` = ?, ` +
|
||||
`\`updated_at\` = ? WHERE ${where} AND \`name\` = ? ` +
|
||||
'AND `source_hash` = ?',
|
||||
[
|
||||
source,
|
||||
sourceHash,
|
||||
at,
|
||||
existing.appUid,
|
||||
...params,
|
||||
existing.name,
|
||||
existing.sourceHash,
|
||||
],
|
||||
@@ -639,20 +1047,37 @@ export class EventHandlerStore extends PuterStore {
|
||||
if (updated?.anyRowsAffected === false)
|
||||
throw publishConflict(existing.name);
|
||||
|
||||
await this.#forgetSet(existing.appUid);
|
||||
await this.#forgetSet(scope);
|
||||
return { ...existing, source, sourceHash, updatedAt: at };
|
||||
}
|
||||
|
||||
/** Drop the delivery path's cached set here and on every peer. */
|
||||
async #forgetSet(appUid: string): Promise<void> {
|
||||
/**
|
||||
* Drop the delivery path's cached set here, and on every peer if
|
||||
* `broadcast`.
|
||||
*/
|
||||
async #forgetSet(scope: HandlerScope, broadcast = true): Promise<void> {
|
||||
await this.publishCacheKeys({
|
||||
keys: [handlerSetCacheKey(appUid), replicaSetCacheKey(appUid)],
|
||||
keys: [handlerSetCacheKey(scope), replicaSetCacheKey(scope)],
|
||||
broadcast,
|
||||
});
|
||||
}
|
||||
|
||||
/** {@link #forgetSet} for many scopes, as one peer message. */
|
||||
async #forgetSets(scopes: HandlerScope[]): Promise<void> {
|
||||
await this.publishCacheKeys({
|
||||
keys: scopes.flatMap((scope) => [
|
||||
handlerSetCacheKey(scope),
|
||||
replicaSetCacheKey(scope),
|
||||
]),
|
||||
broadcast: true,
|
||||
});
|
||||
}
|
||||
|
||||
#assertName(name: unknown): string {
|
||||
if (typeof name !== 'string' || !isValidHandlerName(name))
|
||||
#assertName(name: unknown, scope: HandlerScope): string {
|
||||
if (
|
||||
typeof name !== 'string' ||
|
||||
!isValidHandlerName(name, isUserScope(scope))
|
||||
)
|
||||
throw invalidName();
|
||||
return name;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { EVENTS_INVOKE_TIMEOUT_MS } from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import {
|
||||
EVENTS_INVOKE_TIMEOUT_MS,
|
||||
EVENTS_WORKER_LEASE_FACTOR,
|
||||
} from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import {
|
||||
EVENTS_FAILURE_COUNTER_TTL_MS,
|
||||
EVENTS_PENDING_DELIVERIES_PER_SUBSCRIPTION,
|
||||
@@ -316,7 +319,7 @@ export class PendingDeliveryStore extends PuterStore {
|
||||
typeof configured === 'number' && configured > 0
|
||||
? configured
|
||||
: EVENTS_INVOKE_TIMEOUT_MS;
|
||||
return invokeTimeoutMs * 2;
|
||||
return invokeTimeoutMs * EVENTS_WORKER_LEASE_FACTOR;
|
||||
}
|
||||
|
||||
// -- Writes ------------------------------------------------------
|
||||
|
||||
@@ -29,6 +29,12 @@ import type { FsOp } from '../../services/events/subjects.js';
|
||||
* is declared once here rather than per store.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Which handler set a subscription's `handlerName` refers to: the app's own
|
||||
* (`'app'`) or the holder's scope of that app (`'user'`). Stored as 0 and 1.
|
||||
*/
|
||||
export type HandlerScopeKind = 'app' | 'user';
|
||||
|
||||
/**
|
||||
* Whether a row's events also run the app's published handler in its events
|
||||
* worker. Only an app has a worker, and only a named handler can be run.
|
||||
@@ -97,6 +103,8 @@ export interface DispatchSubscription {
|
||||
durable?: true;
|
||||
delivery?: DeliveryClass;
|
||||
handlerName?: string | null;
|
||||
/** Durable rows only: absent means `'app'`. */
|
||||
handlerScope?: HandlerScopeKind;
|
||||
/** Durable rows only: handed to the handler, and read nowhere else. */
|
||||
context?: string | null;
|
||||
}
|
||||
|
||||
@@ -1270,6 +1270,11 @@ interface IConfigOptional {
|
||||
* through a handle both reject with `events_kv_handles_disabled`, and a
|
||||
* handle row already made stops delivering. Nothing on the write path
|
||||
* reads it.
|
||||
* - `userHandlers` — whether an app may publish handlers into a signed-in
|
||||
* user's own scope, including inline handlers created with a
|
||||
* subscription. Absent means off: user-scope publishing and inline
|
||||
* creates reject with `events_user_handlers_disabled`; app-scope handlers
|
||||
* are unaffected.
|
||||
* - `workerRuntime` — whether publishing handlers deploys a per-app events
|
||||
* worker and delivery invokes it. Absent means off: publish stores rows
|
||||
* and nothing is deployed, and the invoker keeps its null resolver, so
|
||||
@@ -1286,6 +1291,7 @@ interface IConfigOptional {
|
||||
crossAppKv?: boolean;
|
||||
notificationsFoldIn?: boolean;
|
||||
kvHandles?: boolean;
|
||||
userHandlers?: boolean;
|
||||
workerRuntime?: boolean;
|
||||
forwardSession?: boolean;
|
||||
/** How long a handler has to answer an invocation. Default 30 s. */
|
||||
@@ -1307,6 +1313,12 @@ interface IConfigOptional {
|
||||
* default: deploys refuse rather than land somewhere public.
|
||||
*/
|
||||
workerNamespace?: string;
|
||||
/**
|
||||
* Per-invocation CPU limit, in milliseconds, set on every events worker
|
||||
* at deploy. Unset means no limit. Scripts already deployed pick it up
|
||||
* on their next deploy.
|
||||
*/
|
||||
workerCpuMs?: number;
|
||||
/**
|
||||
* Where the events dispatcher answers `POST /invoke` — its own
|
||||
* hostname, since it carries no zone route. Unset means invocations
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Actor } from '../core/actor.js';
|
||||
import { HttpError } from '../core/http/HttpError.js';
|
||||
import {
|
||||
RENTED_UNITS_PER_USER,
|
||||
limitFor,
|
||||
} from '../controllers/events/limits.js';
|
||||
import {
|
||||
actorPlanId,
|
||||
type SubscriptionMetering,
|
||||
} from '../services/metering/enforcement.js';
|
||||
import type { EventHandlerStore } from '../stores/events/EventHandlerStore.js';
|
||||
import {
|
||||
WORKER_SUBDOMAIN_PREFIX,
|
||||
type SubdomainStore,
|
||||
} from '../stores/subdomain/SubdomainStore.js';
|
||||
|
||||
export interface RentedUnitDeps {
|
||||
stores: {
|
||||
subdomain: Pick<SubdomainStore, 'countByUserIdAndPrefix'>;
|
||||
eventHandler: Pick<
|
||||
EventHandlerStore,
|
||||
| 'countUserScopesWithHandlersForUser'
|
||||
| 'countAppsWithHandlersForOwner'
|
||||
>;
|
||||
};
|
||||
metering: SubscriptionMetering | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse creating one more rented unit for a user already holding their plan's
|
||||
* cap of them. Call only where a unit is created: a redeploy or a publish into
|
||||
* an existing unit must never reach it.
|
||||
*/
|
||||
export const assertRentedUnitRoom = async (
|
||||
deps: RentedUnitDeps,
|
||||
actor: Actor,
|
||||
): Promise<void> => {
|
||||
const userId = actor.user?.id;
|
||||
if (userId === undefined) return;
|
||||
const cap = limitFor(
|
||||
RENTED_UNITS_PER_USER,
|
||||
await actorPlanId(deps.metering, actor),
|
||||
);
|
||||
const [workers, scopes, owned] = await Promise.all([
|
||||
deps.stores.subdomain.countByUserIdAndPrefix(
|
||||
userId,
|
||||
WORKER_SUBDOMAIN_PREFIX,
|
||||
),
|
||||
deps.stores.eventHandler.countUserScopesWithHandlersForUser(userId),
|
||||
deps.stores.eventHandler.countAppsWithHandlersForOwner(userId),
|
||||
]);
|
||||
if (Number(workers) + scopes + owned >= cap)
|
||||
throw new HttpError(403, `Worker limit reached (max ${cap})`, {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
};
|
||||
+24
-14
@@ -11,7 +11,7 @@ The Events API tells your app when a user's data changes. Subscribe to a file, a
|
||||
There are two kinds of subscription:
|
||||
|
||||
- **Session subscriptions** ([`onLocal()`](/Events/onLocal/)) belong to the open page and end when it closes. Use them to keep a UI in sync.
|
||||
- **Persistent subscriptions** ([`onPersistent()`](/Events/onPersistent/)) are stored on the account and keep running while your app is closed. Each change runs a [handler](/Events/handlers/) your app published. Use them for background work, such as processing uploads as they arrive.
|
||||
- **Persistent subscriptions** ([`onPersistent()`](/Events/onPersistent/)) are stored on the account and keep running while your app is closed. Each change runs a [handler](/Events/handlers/), either inline code you pass in the call or one your app published. Use them for background work, such as processing uploads as they arrive.
|
||||
|
||||
What you watch is written as a subject string:
|
||||
|
||||
@@ -25,16 +25,21 @@ What you watch is written as a subject string:
|
||||
|
||||
[Subjects and events](/Events/subjects/) has the full subject syntax and the shape of each event.
|
||||
|
||||
To run a handler on a timer instead of when data changes, create a schedule with [`onSchedule()`](/Events/onSchedule/). It runs your handler at an interval, on a cron expression in the user's time zone, or once at a set time, even while your app is closed:
|
||||
To run a handler on a timer instead of when data changes, create a schedule with [`onSchedule()`](/Events/onSchedule/). It runs your handler at an interval, on a cron expression in the user's time zone, or once at a set time, even while your app is closed. Pass the handler inline, and Puter stores it in the signed-in user's own [user scope](/Events/handlers/#user-scope) of your app:
|
||||
|
||||
```js
|
||||
await puter.events.onSchedule({
|
||||
cron: '0 9 * * 1-5',
|
||||
tz: 'America/New_York',
|
||||
handlerName: 'sendDigest',
|
||||
handler: async ({ ctx, user }) => {
|
||||
await user.notifications.notify({ title: `Good morning, ${ctx.name}` });
|
||||
},
|
||||
context: { name: 'Ana' },
|
||||
});
|
||||
```
|
||||
|
||||
To ship one handler to every user, the app's owner can publish it under a name and each schedule or subscription binds to it with `handlerName`.
|
||||
|
||||
With the [User-Pays Model](/user-pays-model/), deliveries and schedule runs are billed to the user holding the subscription or schedule, not to you. See [Rate Limits and Quotas](/rate-limits-and-quotas/#events) for limits and costs.
|
||||
|
||||
## Features
|
||||
@@ -161,26 +166,25 @@ With the [User-Pays Model](/user-pays-model/), deliveries and schedule runs are
|
||||
|
||||
#### Keep watching with a handler after the page closes
|
||||
|
||||
Publishing a handler needs an app you own, so this runs in your own app rather than in the playground.
|
||||
The handler goes in with the subscription and runs in the events worker. It's stored in the signed-in user's own [user scope](/Events/handlers/#user-scope) of your app. The playground examples below run this form.
|
||||
|
||||
```js
|
||||
// Once, as the app's developer
|
||||
await puter.events.handlers.publish('ingestUpload', async ({ event, ctx }) => {
|
||||
await fetch(ctx.endpoint, { method: 'POST', body: event.path });
|
||||
});
|
||||
|
||||
// In your app, for each user
|
||||
await puter.perms.request('backgroundEvents');
|
||||
const sub = await puter.events.onPersistent({
|
||||
subject: 'fs:inbox',
|
||||
handlerName: 'ingestUpload',
|
||||
handler: async ({ event, ctx }) => {
|
||||
await fetch(ctx.endpoint, { method: 'POST', body: event.path });
|
||||
},
|
||||
context: { endpoint: 'https://example.com/ingest' },
|
||||
});
|
||||
|
||||
// It outlives the page. End it when it's no longer needed.
|
||||
// It outlives the page. End it, and remove its handler, when it's no longer needed.
|
||||
await puter.events.unsubscribe(sub.subId);
|
||||
await puter.events.handlers.remove(sub.handlerName, { scope: 'user' });
|
||||
```
|
||||
|
||||
To share one handler between all your users, the app's owner publishes it once under a name and each subscription binds to it with `handlerName`. See [`puter.events.handlers`](/Events/handlers/).
|
||||
|
||||
</div>
|
||||
|
||||
<div class="example-content" data-section="list">
|
||||
@@ -262,8 +266,8 @@ These Events features are supported out of the box when using Puter.js:
|
||||
- **[`puter.events.getSchedule()`](/Events/getSchedule/)** - Read one schedule
|
||||
- **[`puter.events.removeSchedule()`](/Events/removeSchedule/)** - End a schedule
|
||||
- **[`puter.events.fetch()`](/Events/fetch/)** - Read the notifications you missed while nothing was listening
|
||||
- **[`puter.events.handlers`](/Events/handlers/)** - Publish, list and remove the named handlers persistent subscriptions run
|
||||
- **[`puter.events.workers`](/Events/workers/)** - List and destroy an app's events worker
|
||||
- **[`puter.events.handlers`](/Events/handlers/)** - Publish, list and remove the named handlers persistent subscriptions and schedules run, for the app or for one user
|
||||
- **[`puter.events.workers`](/Events/workers/)** - List and destroy an app's events worker, or a user's own
|
||||
- **[Subjects and events](/Events/subjects/)** - The subject syntax and the shape of every event
|
||||
|
||||
## Examples
|
||||
@@ -280,10 +284,16 @@ You can see various Puter.js Events features in action from the following exampl
|
||||
- [Find Suspended Subscriptions](/playground/events-list-suspended/)
|
||||
- [Change a Subscription's Context](/playground/events-update/)
|
||||
- [End a Persistent Subscription](/playground/events-unsubscribe/)
|
||||
- [Run an Inline Handler on a Folder](/playground/events-persistent-inline/)
|
||||
- [Queue Events for an Inline Handler](/playground/events-persistent-queue-inline/)
|
||||
- Schedules
|
||||
- [List Schedules](/playground/events-list-schedules/)
|
||||
- [Read a Schedule](/playground/events-get-schedule/)
|
||||
- [Remove a Schedule](/playground/events-remove-schedule/)
|
||||
- [Run Code Once at a Set Time](/playground/events-schedule-inline/)
|
||||
- [Run Code on Weekday Mornings](/playground/events-schedule-cron-tz/)
|
||||
- [Run Code Every 15 Minutes](/playground/events-schedule-every/)
|
||||
- [Share a Handler Across Schedules](/playground/events-handlers-user-scope/)
|
||||
- Catching up
|
||||
- [Catch Up on Notifications](/playground/events-fetch/)
|
||||
- [Catch Up, Then Keep Listening](/playground/events-fetch-then-listen/)
|
||||
|
||||
@@ -60,6 +60,7 @@ Besides `type` and `title`, `notification` carries these fields for each type:
|
||||
- `share.claimed` (`account`): `fields.count`.
|
||||
- `app.worker.deployed`, `app.worker.deployFailed` (`developer`): nothing else.
|
||||
- `app.events.suspended` (`developer`): `handler` and `subscriptions`, when removing a handler or repeated failures [suspended](/Events/onPersistent/#suspended-subscriptions) subscriptions bound to it.
|
||||
- `app.events.handler_suspended` (`app-user`): `handler` and `subscriptions`, when removing a handler or repeated failures [suspended](/Events/onPersistent/#suspended-subscriptions) subscriptions or schedules bound to a handler in the user's own [user scope](/Events/handlers/#user-scope). It's the holder's counterpart to `app.events.suspended`, and arrives on `notif:app-user` of the app.
|
||||
- `app.events.ended` (`app-user`): `reason` (`anchor_deleted`, `permission_revoked`, or `no_credit`), sent to the holder when persistent subscriptions stopped without being unsubscribed. With `anchor_deleted` or `permission_revoked`, it also has `subject`, `subjects` (up to 20), and `count`. It arrives on `notif:app-user` of the app that made the subscriptions.
|
||||
- `app.message` (`app-user`): A notification your app sent with [`notify()`](/Notifications/notify/). It carries `priority`, `ttl`, and `app` (`{ uid, name, icon }`, the app that sent it), plus `text`, `icon`, `target`, `data`, `group`, `channel`, and `sensitive` when they were set.
|
||||
- `kv.share.received` (`app-user`): The `handle`, `access`, `label`, and `createdAt` (in unix seconds) of a key-value share another user made with you with `notify` on, or turned `notify` on for (see [`puter.kv.share()`](/KV/share/#what-the-holder-sees)).
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
---
|
||||
title: puter.events.handlers
|
||||
description: Publish, list and remove the named handlers that persistent subscriptions and schedules run.
|
||||
description: Publish, list and remove the named handlers that persistent subscriptions and schedules run, for an app or for one user.
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
---
|
||||
|
||||
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
A **handler** is a function your app publishes once, under a name, for [persistent subscriptions](/Events/onPersistent/) and [schedules](/Events/onSchedule/) to run. A handler runs only when a subscription bound to its name has a delivery, or a schedule bound to it is due. Nothing else can trigger it.
|
||||
A **handler** is a function that [persistent subscriptions](/Events/onPersistent/) and [schedules](/Events/onSchedule/) run in the background. A handler runs only when a subscription bound to it has a delivery, or a schedule bound to it is due. Nothing else can trigger it.
|
||||
|
||||
Only the app's owner can publish, list, and remove its handlers. Inside an app, including a website using Puter.js, these calls act on that app, and work only while the signed-in user owns it. Outside an app, such as in Node.js with your account token, name the app with `appUid`.
|
||||
A handler lives in one of two scopes:
|
||||
|
||||
- **App scope.** The handler belongs to the app, and every user's subscriptions and schedules can bind to it by name. Only the app's owner can publish, list, and remove these. Inside an app, including a website using Puter.js, these calls act on that app, and work only while the signed-in user owns it. Outside an app, such as in Node.js with your account token, name the app with `appUid`.
|
||||
- **User scope.** The handler belongs to one user in one app, and only that user's subscriptions and schedules can bind to it. Any signed-in user's session of the app can publish, list, and remove these, with `scope: 'user'`. See [User scope](#user-scope).
|
||||
|
||||
You don't have to publish a handler to use one. Pass it inline as `handler` to [`onSchedule()`](/Events/onSchedule/) or [`onPersistent()`](/Events/onPersistent/) without a `handlerName`, and it's published into the signed-in user's scope for you.
|
||||
|
||||
On a website with nobody signed in, each method asks the user to sign in first, as other Puter.js calls do. An app running on Puter is always signed in.
|
||||
|
||||
@@ -21,6 +26,37 @@ await puter.events.handlers.list({ appUid }); // [{ name, hash, updatedAt, s
|
||||
await puter.events.handlers.remove('ingestUpload', { appUid });
|
||||
```
|
||||
|
||||
## User scope
|
||||
|
||||
A user scope holds the handlers one user has published in one app. Nobody else can publish into it, list it, or remove from it, including the app's owner.
|
||||
|
||||
An inline `handler` without a `handlerName` is published into the scope for you, under a name Puter chooses that starts with `@`, which is reserved for these. The name is derived from the code, so the same code is one handler, and changing the code makes a new one. Subscriptions and schedules keep running the code they were created with.
|
||||
|
||||
To ship a new version to existing subscriptions and schedules, publish a named handler into the scope and bind to it with `handlerName` and `handlerScope: 'user'`. Publishing the name again moves every one of them to the new code:
|
||||
|
||||
```js
|
||||
await puter.events.handlers.publish('digest', async ({ ctx, user }) => {
|
||||
await user.notifications.notify({ title: ctx.title });
|
||||
}, { scope: 'user' });
|
||||
|
||||
await puter.events.onSchedule({
|
||||
cron: '0 8 * * 1-5',
|
||||
handlerName: 'digest',
|
||||
handlerScope: 'user',
|
||||
context: { title: 'Good morning' },
|
||||
});
|
||||
```
|
||||
|
||||
Handlers in a user scope:
|
||||
|
||||
- Are published only from an app acting for a signed-in user, never from a scoped access token or a temporary (guest) account, and only into that app's scope.
|
||||
- Are billed to the user like their own workers. A hosted Puter server may bill each user scope as a monthly cost, and publishing into one needs credit. Listing and removing don't.
|
||||
- Count toward the user's [worker units](/rate-limits-and-quotas/#worker-units), as one unit per app. A user over their plan's limit keeps what they hold, and can't create a new scope.
|
||||
- Are limited to 20 handlers and 1 MB of source combined per scope, and 10 deploys an hour. See [Rate Limits and Quotas](/rate-limits-and-quotas/#handlers).
|
||||
- Suspend like any other handler. When removing one or repeated failures suspend a subscription or schedule bound to it, the user gets an `app.events.handler_suspended` notification, and the developer isn't told.
|
||||
|
||||
The server may turn user-scope handlers off, and then publishing into one fails with `events_user_handlers_disabled`. To remove a whole scope, see [`workers.destroy()`](/Events/workers/).
|
||||
|
||||
## Handlers can't use outside variables
|
||||
|
||||
A handler is serialized with `Function.prototype.toString()` and run later, somewhere else, so nothing around the function comes with it.
|
||||
@@ -61,7 +97,8 @@ puter.events.handlers.publish(name, handler, options)
|
||||
- `name` (String) (required): The name subscriptions bind to. Letters, digits, and `_ . : -`, starting with a letter or digit, up to 128 characters. Unique within the app.
|
||||
- `handler` (Function | String | Object) (required): A function (serialized with `toString()`), a source string, or `{ file: '~/AppData/…/handler.js' }`. A file is read at this call, so later edits to it take effect only when you publish again.
|
||||
- `options.replace` (Boolean): Overwrite whatever is published under the name.
|
||||
- `options.appUid` (String): The app to publish into. Required outside an app. Inside an app, it can only name that app.
|
||||
- `options.scope` (String): `'app'` or `'user'`. Defaults to `'app'`. `'user'` publishes into the signed-in user's own [user scope](#user-scope) of the app.
|
||||
- `options.appUid` (String): The app to publish into. Required outside an app. Inside an app, it can only name that app. With `scope: 'user'` it can only name the app making the call, and needs no value.
|
||||
|
||||
Resolves to `{ name, hash, updatedAt, outcome, resumed }`. Its `outcome` is `'created'`, `'updated'`, or `'unchanged'` (the same source was already published), and `resumed` counts the suspended subscriptions this publish brought back.
|
||||
|
||||
@@ -78,7 +115,7 @@ puter.events.handlers.publishAll(handlers)
|
||||
puter.events.handlers.publishAll(handlers, options)
|
||||
```
|
||||
|
||||
Publishes a set in one call, for build steps. Pass `handlers` as an array of up to 50 `{ name, handler, replace? }`, published in order. The `options.appUid` option works as it does for `publish()`. If one is refused, publishing stops there. Earlier items stay published, and the error says where it stopped.
|
||||
Publishes a set in one call, for build steps. Pass `handlers` as an array of up to 50 `{ name, handler, replace? }`, published in order. The `options.appUid` and `options.scope` options work as they do for `publish()`. If one is refused, publishing stops there. Earlier items stay published, and the error says where it stopped.
|
||||
|
||||
Resolves to an array of `publish()` results.
|
||||
|
||||
@@ -89,7 +126,7 @@ puter.events.handlers.list()
|
||||
puter.events.handlers.list(options)
|
||||
```
|
||||
|
||||
Resolves to `[{ name, hash, updatedAt, subscriptions }]` for every handler the app has published, sorted by name. Each `subscriptions` count includes the subscriptions and schedules bound to that name, suspended ones included. The `options.appUid` option works as it does for `publish()`.
|
||||
Resolves to `[{ name, hash, updatedAt, subscriptions }]` for every handler the app, or the user's scope with `scope: 'user'`, has published, sorted by name. Each `subscriptions` count includes the subscriptions and schedules bound to that name, suspended ones included. The `options.appUid` and `options.scope` options work as they do for `publish()`.
|
||||
|
||||
The source is never returned.
|
||||
|
||||
@@ -100,18 +137,18 @@ puter.events.handlers.remove(name)
|
||||
puter.events.handlers.remove(name, options)
|
||||
```
|
||||
|
||||
Resolves to `{ name, removed, suspended }`, where `removed` is `false` when nothing was published under the name. The `options.appUid` option works as it does for `publish()`.
|
||||
Resolves to `{ name, removed, suspended }`, where `removed` is `false` when nothing was published under the name. The `options.appUid` and `options.scope` options work as they do for `publish()`.
|
||||
|
||||
| Situation | What happens |
|
||||
| --- | --- |
|
||||
| No subscriptions or schedules use the name | The handler is deleted. |
|
||||
| Subscriptions or schedules use it | The handler is deleted, and its subscriptions and schedules are suspended with `suspendedReason: 'handler_not_found'`, not deleted. The app's developer gets an `app.events.suspended` notification. |
|
||||
| Subscriptions or schedules use it | The handler is deleted, and its subscriptions and schedules are suspended with `suspendedReason: 'handler_not_found'`, not deleted. The app's developer gets an `app.events.suspended` notification. For a handler in a user scope, the user gets an `app.events.handler_suspended` notification instead. |
|
||||
|
||||
Publishing the name again resumes them, with the same ids and context. This is how you recover from a bad deploy. A resumed schedule runs at its next slot, and doesn't make up the runs it missed.
|
||||
|
||||
To rename a handler, publish the new name and remove the old one. Subscriptions and schedules don't follow the rename. Users have to subscribe again, so their subscriptions never switch to different code without them knowing.
|
||||
|
||||
An app's first published handler creates its [events worker](/Events/workers/), and removing the last one takes it down.
|
||||
An app's first published handler creates its [events worker](/Events/workers/), and removing the last one takes it down. A user scope works the same way: its first handler creates it, and removing its last one removes the scope.
|
||||
|
||||
## Failed runs
|
||||
|
||||
@@ -126,14 +163,17 @@ All four methods reject with `{ message, code }`:
|
||||
| `auth_canceled` | Nobody was signed in, and the user closed the sign-in without finishing it. |
|
||||
| `events_handler_free_variable` | The handler uses an outside variable. The message names it. |
|
||||
| `events_handler_invalid` | `handler` isn't a function, a source string, or `{ file }`. |
|
||||
| `events_handler_name_invalid` | The name is empty, too long, or has characters that aren't allowed. |
|
||||
| `events_handler_name_invalid` | The name is empty, too long, or has characters that aren't allowed. Names starting with `@` are reserved for inline handlers. |
|
||||
| `events_handler_conflict` | Someone else published different source under this name since you last saw it. Pass `replace: true` to overwrite. |
|
||||
| `events_handler_app_required` | The call was made outside an app without `appUid`. |
|
||||
| `events_handler_forbidden` | The signed-in user doesn't own the app, the app doesn't exist, `appUid` names a different app than the one making the call, or the caller is a scoped access token. |
|
||||
| `events_handler_forbidden` | For app scope, the signed-in user doesn't own the app, the app doesn't exist, `appUid` names a different app than the one making the call, or the caller is a scoped access token. For `scope: 'user'`, the caller isn't an app acting for a signed-in user: a scoped access token, a temporary account, or a different app. |
|
||||
| `events_user_handlers_disabled` | `scope: 'user'` was used to publish, and this server doesn't have user-scope handlers turned on. |
|
||||
| `insufficient_funds` | Publishing into a user scope needs credit on a hosted Puter server, and the user has none. |
|
||||
| `forbidden` | The user already holds as many [worker units](/rate-limits-and-quotas/#worker-units) as their plan allows, so a new user scope can't be created. |
|
||||
| `events_handler_too_large` | The handler is over 64 KB. |
|
||||
| `events_worker_too_large` | The app's handlers would exceed 5 MB combined. |
|
||||
| `events_worker_too_large` | The app's handlers would exceed 5 MB combined, or a user scope's would exceed 1 MB. |
|
||||
| `events_handler_source_invalid` | The handler source is empty. |
|
||||
| `events_handler_limit` | The app already has the [maximum number of handlers](/rate-limits-and-quotas/#handlers). |
|
||||
| `events_handler_limit` | The app, or the user scope, already has the [maximum number of handlers](/rate-limits-and-quotas/#handlers). |
|
||||
| `invalid_request` | `publishAll()` got something other than a non-empty array. |
|
||||
| `too_many_requests` | Over the publish and remove rate limit. |
|
||||
| `events_disabled` | Events aren't enabled on this server. |
|
||||
@@ -141,7 +181,26 @@ All four methods reject with `{ message, code }`:
|
||||
|
||||
## Examples
|
||||
|
||||
These examples run in Node.js signed in with your account token, and `appUid` names an app you own. Inside your own app, leave out `appUid`.
|
||||
The first example publishes into a user scope from inside an app, for the signed-in user. The others run in Node.js signed in with your account token, and `appUid` names an app you own. Inside your own app, leave out `appUid`.
|
||||
|
||||
<strong class="example-title">Publish into the user's scope, list it, then remove it</strong>
|
||||
|
||||
```js
|
||||
const published = await puter.events.handlers.publish(
|
||||
'digest',
|
||||
async ({ ctx, user }) => {
|
||||
await user.notifications.notify({ title: ctx.title });
|
||||
},
|
||||
{ scope: 'user' },
|
||||
);
|
||||
console.log(published.outcome);
|
||||
|
||||
for (const handler of await puter.events.handlers.list({ scope: 'user' })) {
|
||||
console.log(`${handler.name}: ${handler.subscriptions} bound`);
|
||||
}
|
||||
|
||||
await puter.events.handlers.remove('digest', { scope: 'user' });
|
||||
```
|
||||
|
||||
<strong class="example-title">Publish a handler, list it, then remove it</strong>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ platforms: [websites, apps, nodejs, workers]
|
||||
|
||||
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Creates a subscription that outlives this connection. It's stored on the account, keeps matching while your app is closed, and runs a handler your app published with [`puter.events.handlers.publish()`](/Events/handlers/). Each event also reaches every connected client. Compare [`puter.events.onLocal()`](/Events/onLocal/), which ends with the page.
|
||||
Creates a subscription that outlives this connection. It's stored on the account, keeps matching while your app is closed, and runs a handler in an [events worker](/Events/workers/). The handler is either inline code you pass as `handler`, which is published into the signed-in user's own [user scope](/Events/handlers/#user-scope) of your app, or a handler published under a name with [`puter.events.handlers.publish()`](/Events/handlers/). Each event also reaches every connected client. Compare [`puter.events.onLocal()`](/Events/onLocal/), which ends with the page.
|
||||
|
||||
The subscription is live right away in the region where it was created. Right after this call resolves, changes made in other regions can take a moment longer to reach it, usually well under a second.
|
||||
|
||||
@@ -24,9 +24,10 @@ puter.events.onPersistent(options)
|
||||
#### `options` (Object) (required)
|
||||
|
||||
- `subject` (String) (required): What to watch, in the same form [`onLocal()`](/Events/onLocal/) takes, such as `fs:~/Documents` or `kv:<handle>:*`.
|
||||
- `handlerName` (String): The published handler to run in your app's [events worker](/Events/workers/). Up to 128 characters. A subscription made outside an app has no events worker, so its handler runs only in connected clients.
|
||||
- `queue` (Boolean): Handles events like a work queue. Each event goes to one consumer at a time, and it's offered again if a run never finishes or hits a temporary error. Requires `handlerName`. Defaults to `false`. See [Choosing a mode](#choosing-a-mode).
|
||||
- `handler` (Function | String | Object): The handler source you wrote this subscription against, as a function, a source string, or `{ file: '~/AppData/…/handler.js' }`. Only its hash is sent, and the subscription is created only if it matches what's published under `handlerName`, which is required with it. A function also runs in this client for each delivery it receives (see [Where the handler runs](#where-the-handler-runs)).
|
||||
- `handlerName` (String): The published handler to run in an [events worker](/Events/workers/). Up to 128 characters. A subscription made outside an app has no events worker, so its handler runs only in connected clients.
|
||||
- `handlerScope` (String): Where `handlerName` is looked up, `'app'` or `'user'`. Defaults to `'app'`, the handlers the app's owner published. `'user'` binds to a handler in the signed-in user's own scope, published with `handlers.publish(name, handler, { scope: 'user' })`.
|
||||
- `queue` (Boolean): Handles events like a work queue. Each event goes to one consumer at a time, and it's offered again if a run never finishes or hits a temporary error. Requires `handlerName` or an inline `handler`. Defaults to `false`. See [Choosing a mode](#choosing-a-mode).
|
||||
- `handler` (Function | String | Object): The handler code, as a function, a source string, or `{ file: '~/AppData/…/handler.js' }`. Without `handlerName`, the source is sent and published into the signed-in user's own user scope, and it runs for this user's events only. With `handlerName`, only its hash is sent, and the subscription is created only if it matches what's published under that name. A function also runs in this client for each delivery it receives (see [Where the handler runs](#where-the-handler-runs)).
|
||||
- `context` (Object): Values the handler needs, passed to it as a frozen `ctx`. Up to 4 KB serialized. See [Context is captured once](#context-is-captured-once-up-to-4-kb).
|
||||
- `expiresAt` (Number | String): When the subscription ends on its own, as unix seconds or an ISO-8601 string. Must be in the future.
|
||||
- `includeValue` (Boolean): For a `kv:` subject, include the key's new value as `event.value`. See [Getting the new value](/Events/subjects/#getting-the-new-value) for when it's left out. Other subjects are refused with `invalid_include_value`.
|
||||
@@ -36,19 +37,19 @@ puter.events.onPersistent(options)
|
||||
|
||||
## Background delivery needs consent
|
||||
|
||||
Running your handler while the user isn't there needs its own per-app permission, `events:background`. When an app subscribes with a `handlerName` without it, the call fails with `events_background_consent_required`. To ask for it, call [`puter.perms.request()`](/Perms/request/) with the `'backgroundEvents'` resource:
|
||||
Running your handler while the user isn't there needs its own per-app permission, `events:background`. When an app subscribes with a `handlerName` or an inline `handler` without it, the call fails with `events_background_consent_required`. To ask for it, call [`puter.perms.request()`](/Perms/request/) with the `'backgroundEvents'` resource:
|
||||
|
||||
```js
|
||||
await puter.perms.request('backgroundEvents');
|
||||
```
|
||||
|
||||
A subscription without a `handlerName` runs nothing in the background and needs no consent.
|
||||
A subscription with neither a `handlerName` nor an inline `handler` runs nothing in the background and needs no consent.
|
||||
|
||||
Background handlers run as a worker session for your app, which appears in the user's sessions list. Revoking that session invalidates the `user` token a run holds, and the next delivery starts a new session. Withdrawing `events:background`, uninstalling the app, destroying its events worker, or deleting the app also stops background deliveries. What happens to the subscriptions depends on which one it was:
|
||||
|
||||
- **Withdrawing `events:background`** suspends every subscription with a `handlerName` the app holds for that user, with `permission_revoked`.
|
||||
- **Withdrawing `events:background`** suspends every subscription with a handler the app holds for that user, with `permission_revoked`.
|
||||
- **Uninstalling the app** suspends every subscription the app holds for that user, with `permission_revoked`.
|
||||
- **Destroying the events worker** suspends its subscriptions with `handler_not_found`. Publishing the handlers again resumes them.
|
||||
- **Destroying the events worker** suspends its subscriptions with `handler_not_found`. Publishing the handlers again resumes them. Destroying a user scope does the same for the handlers in it, including inline ones, which resume when you publish the same code again.
|
||||
- **Deleting the app** deletes its subscriptions and handlers.
|
||||
|
||||
A subscription suspended with `permission_revoked` doesn't resume when access is granted again. Subscribe again instead.
|
||||
@@ -71,12 +72,12 @@ A queued event comes back only on a retry or a lost answer, with the same `event
|
||||
|
||||
Without `queue`, every event runs the handler in each place that's listening:
|
||||
|
||||
- The app's [events worker](/Events/workers/) runs the handler named by `handlerName`, whether or not a client is open. It skips a run when the server has no worker runtime, or when the [handler-run limit](/rate-limits-and-quotas/#events) for this user and app is used up for the minute.
|
||||
- An [events worker](/Events/workers/) runs the handler named by `handlerName`, or the inline `handler`, whether or not a client is open. It skips a run when the server has no worker runtime, or when the [handler-run limit](/rate-limits-and-quotas/#events) for this user and app is used up for the minute.
|
||||
- Every connected client that passed a function `handler` runs it too. Every [`onLocal()`](/Events/onLocal/) subscription that matches the change gets the event as well.
|
||||
|
||||
With `queue: true`, a connected client that passed a function `handler` gets the first try. If no client has one, or its run fails, the events worker takes the event right away. A client that doesn't answer within 15 seconds loses the event to the events worker, so keep client handlers short. If the user has your app open in several tabs, each tab that passed a function `handler` gets the event, so keep side effects that must happen once in the worker handler.
|
||||
|
||||
Only a function `handler` runs in this client. A source string or `{ file }` is only used for its hash. The connection reconnects on its own when it drops, so this client keeps taking deliveries unless `onError` is called.
|
||||
Only a function `handler` runs in this client. A source string or `{ file }` is only published, or hashed to check it against `handlerName`. The connection reconnects on its own when it drops, so this client keeps taking deliveries unless `onError` is called.
|
||||
|
||||
Wherever it runs, the handler is the same code, called with:
|
||||
|
||||
@@ -116,7 +117,7 @@ throw Object.assign(new Error('Service busy, try later'), { retry: true });
|
||||
|
||||
In the events worker, a run that fails because of Puter doesn't count as a failure. That covers a run Puter couldn't start or finish, and a handler that throws after a call it made through `user` got a `5xx`, a `429`, or a network error from Puter. It's retried on the same schedule, and waits longer if Puter's answer asked for a longer wait, up to 5 minutes. To drop the event in that case anyway, throw an error with `terminal: true`.
|
||||
|
||||
Five failures in a row suspend the subscription with `failures`. The developer is notified, and publishing the handler again resumes it.
|
||||
Five failures in a row suspend the subscription with `failures`, and publishing the handler again resumes it.
|
||||
|
||||
Without `queue`, a failed run is never retried.
|
||||
|
||||
@@ -149,7 +150,7 @@ A persistent subscription can stop without being unsubscribed. It's then suspend
|
||||
|
||||
A suspended subscription stops delivering and isn't billed. Its backlog is cut to 100 deliveries and kept for 24 hours (`handler_not_found`, `failures`) or 1 hour (`no_credit`), then replaced by one gap marker. A `permission_revoked` backlog is dropped right away. Suspended subscriptions are deleted after 30 days.
|
||||
|
||||
The app's developer gets an `app.events.suspended` notification for `handler_not_found` and `failures`. The holder gets an `app.events.ended` notification on `notif:app-user` for `no_credit` and `permission_revoked`. Both can be read with [`fetch()`](/Events/fetch/).
|
||||
The app's developer gets an `app.events.suspended` notification for `handler_not_found` and `failures` on a handler in the app's scope. The holder gets an `app.events.handler_suspended` notification for the same two reasons on a handler in their own user scope, and the developer isn't told. The holder also gets an `app.events.ended` notification on `notif:app-user` for `no_credit` and `permission_revoked`. Both can be read with [`fetch()`](/Events/fetch/).
|
||||
|
||||
A subscription suspended for a reason that can resume still counts toward your [persistent subscription limit](/rate-limits-and-quotas/#what-an-account-can-hold) until you unsubscribe it. One suspended with `permission_revoked` doesn't count. An account keeps at most 500 of those, and past that, subscribing again deletes the oldest ones.
|
||||
|
||||
@@ -169,7 +170,8 @@ A `Promise` that resolves to the subscription:
|
||||
- `subId` (String): Its id, which [`puter.events.update()`](/Events/update/) and [`puter.events.unsubscribe()`](/Events/unsubscribe/) take. It never changes.
|
||||
- `subject` (String): For `kv:` and `notif:`, the subject in full form, so `kv:cart` comes back as `kv:<appId>:cart`. For `fs:`, as you passed it, except a relative path comes back resolved the same way [`onLocal()`](/Events/onLocal/) returns it.
|
||||
- `anchor`, `match`, `op`: As [`onLocal()`](/Events/onLocal/#return-value) returns them.
|
||||
- `queue` (Boolean), `handlerName` (String | null), `includeValue` (Boolean).
|
||||
- `queue` (Boolean), `handlerName` (String | null), `includeValue` (Boolean). An inline handler comes back as its server-assigned `handlerName`, which starts with `@`.
|
||||
- `handlerScope` (String): `'user'` when the subscription is bound to a handler in the user's own scope. Absent otherwise.
|
||||
- `appUid` (String | null): The app that created it, or `null` if it was created outside an app.
|
||||
- `contextKeys` (Array | null), `contextHash` (String | null): The key names and a hash of the stored context, never its values.
|
||||
- `createdAt`, `expiresAt` (Number | null): Unix seconds.
|
||||
@@ -182,14 +184,21 @@ The promise rejects with `{ message, code }`:
|
||||
| --- | --- |
|
||||
| `invalid_subject` | The subject is empty, not a string, or can't be parsed, or it's a `notif:` subject and this server has notification events turned off. |
|
||||
| `auth_canceled` | Nobody was signed in, and the user closed the sign-in without finishing it. |
|
||||
| `events_handler_name_required` | `handler` was given without `handlerName`. |
|
||||
| `events_user_handlers_disabled` | Publishing into a user scope isn't turned on for this server. |
|
||||
| `events_handler_forbidden` | The call can't publish into a user scope: it came from a scoped access token, a temporary account, or on behalf of a different app. |
|
||||
| `insufficient_funds` | Publishing into a user scope needs credit on a hosted Puter server, and the user has none. |
|
||||
| `forbidden` | The user already holds as many [worker units](/rate-limits-and-quotas/#worker-units) as their plan allows, so a new user scope can't be created. |
|
||||
| `events_handler_limit` | The user scope already holds the [maximum number of handlers](/rate-limits-and-quotas/#handlers). |
|
||||
| `events_handler_too_large` | The handler is over 64 KB. |
|
||||
| `events_worker_too_large` | The user scope's handlers would exceed 1 MB combined. |
|
||||
| `events_handler_name_invalid` | `handlerName` has characters that aren't allowed, or starts with `@`, which is reserved for inline handlers. |
|
||||
| `events_handler_free_variable` | The handler uses an outside variable. The message names it. |
|
||||
| `events_handler_invalid` | `handler` isn't a function, a source string, or `{ file }`. |
|
||||
| `events_handler_hash_unavailable` | This environment has no `crypto.subtle`, so `handler` can't be hashed. Pass `handlerName` alone. |
|
||||
| `events_handler_not_found` | Nothing is published under `handlerName` in this app. |
|
||||
| `events_handler_not_found` | Nothing is published under `handlerName` in that scope of this app. |
|
||||
| `events_handler_hash_mismatch` | The published handler isn't the source you passed as `handler`. |
|
||||
| `events_handler_required` | `queue: true` without a `handlerName`. |
|
||||
| `events_background_consent_required` | An app subscribed with a `handlerName`, and the user hasn't granted `events:background`. |
|
||||
| `events_handler_required` | `queue: true` without a `handlerName` or an inline `handler`. |
|
||||
| `events_background_consent_required` | An app subscribed with a handler, and the user hasn't granted `events:background`. |
|
||||
| `events_context_too_large` | The serialized `context` is over 4 KB. |
|
||||
| `events_context_invalid` | `context` isn't JSON-serializable. |
|
||||
| `bad_request` | `queue` isn't a boolean, `delivery` isn't `'broadcast'` or `'single'`, the two disagree, or `handlerName` isn't a string or is over 128 characters. |
|
||||
@@ -206,9 +215,29 @@ The promise rejects with `{ message, code }`:
|
||||
|
||||
## Examples
|
||||
|
||||
Publishing a handler needs an app you own, so these examples run in your own app or from Node.js signed in as its developer, not in the playground.
|
||||
The first example is the inline form, which any app can use for the signed-in user. The others bind to a handler the app's owner published, so they run in your own app or from Node.js signed in as its developer.
|
||||
|
||||
<strong class="example-title">Watch a folder with a handler that keeps running</strong>
|
||||
<strong class="example-title">Watch a folder with an inline handler</strong>
|
||||
|
||||
```js
|
||||
await puter.perms.request('backgroundEvents');
|
||||
const sub = await puter.events.onPersistent({
|
||||
subject: 'fs:~/inbox',
|
||||
handler: async ({ event, ctx }) => {
|
||||
await fetch(ctx.webhook, { method: 'POST', body: event.path });
|
||||
},
|
||||
context: { webhook: 'https://example.com/ingest' },
|
||||
});
|
||||
|
||||
// It outlives the page. End it when it's no longer needed.
|
||||
await puter.events.unsubscribe(sub.subId);
|
||||
```
|
||||
|
||||
The handler is stored in the user's scope under `sub.handlerName`, and keeps running for this user until the subscription ends. Unsubscribing leaves the handler in the scope, where it counts toward the scope's limits until you remove it. [Clean Up Background Work](/recipes/clean-up-background-work/) covers removing it.
|
||||
|
||||
<strong class="example-title">Watch a folder with a handler the app's owner published</strong>
|
||||
|
||||
Publish once as the developer to ship one shared handler to every user.
|
||||
|
||||
```js
|
||||
// Once, as the app's developer
|
||||
|
||||
@@ -8,7 +8,9 @@ platforms: [websites, apps, nodejs, workers]
|
||||
|
||||
<div class="info">Schedules are available on puter.com. A self-hosted Puter server answers <code>events_disabled</code>.</div>
|
||||
|
||||
Runs a handler your app published with [`puter.events.handlers.publish()`](/Events/handlers/) on a timer. A schedule can run at an interval, on a cron expression in a time zone, or once at a set time. It's stored on the user's account and runs in your app's [events worker](/Events/workers/), whether or not your app is open. It never runs in the client that created it.
|
||||
Runs a handler on a timer. A schedule can run at an interval, on a cron expression in a time zone, or once at a set time. It's stored on the user's account and runs in an [events worker](/Events/workers/), whether or not your app is open. It never runs in the client that created it.
|
||||
|
||||
The handler is either inline code you pass as `handler`, or a handler published under a name with [`puter.events.handlers.publish()`](/Events/handlers/). Inline code is published into the signed-in user's own [user scope](/Events/handlers/#user-scope) of your app and runs for that user only. A named handler published by the app's owner is shared by every user of the app.
|
||||
|
||||
To run a handler when the user's data changes instead, use [`puter.events.onPersistent()`](/Events/onPersistent/).
|
||||
|
||||
@@ -34,9 +36,10 @@ Give exactly one of `every`, `cron` or `at`.
|
||||
- `startAt` (Number | String | Date): For `every`, the first run. Without it, the first run is one interval after this call. A `startAt` in the past sets where the intervals line up, and the first run is the next one after this call. For example, `every: '1d'` with `startAt: '2026-01-01'` runs at midnight UTC every day. For `cron`, the earliest time the schedule may run. At most 366 days ahead.
|
||||
- `expiresAt` (Number | String | Date): The last time the schedule may run. A run due at exactly this time still runs.
|
||||
- `maxRuns` (Number): How many slots the schedule has, from 1 to 100,000. It ends at its `maxRuns`-th slot counted from the first. It counts slots, not runs that happened, so slots skipped while the schedule was behind or suspended (see [When a schedule runs](#when-a-schedule-runs)) still count, and the retries of one slot count once. With `expiresAt` too, the one that comes first ends the schedule.
|
||||
- `handlerName` (String) (required): The published handler each run calls.
|
||||
- `handler` (Function | String | Object): The handler source you wrote this schedule against, as a function, a source string, or `{ file: '~/AppData/…/handler.js' }`. Only its hash is sent. The schedule is created only if it matches what's published under `handlerName`. It never runs in this client.
|
||||
- `handlerHash` (String): The SHA-256 hex digest of the handler source, in place of `handler`.
|
||||
- `handler` (Function | String | Object): The handler code, as a function, a source string, or `{ file: '~/AppData/…/handler.js' }`. Required unless you pass `handlerName`. Without `handlerName`, the source is sent and published into the signed-in user's own user scope, and every run calls it. With `handlerName`, only its hash is sent, and the schedule is created only if it matches what's published under that name. It never runs in this client.
|
||||
- `handlerName` (String): The published handler each run calls. Required unless you pass `handler`.
|
||||
- `handlerScope` (String): Where `handlerName` is looked up, `'app'` or `'user'`. Defaults to `'app'`, the handlers the app's owner published. `'user'` binds to a handler the signed-in user's scope holds, published with `handlers.publish(name, handler, { scope: 'user' })`.
|
||||
- `handlerHash` (String): The SHA-256 hex digest of the handler source, in place of `handler`, to check it against `handlerName`.
|
||||
- `context` (Object): Values the handler needs, passed to every run as a frozen `ctx`. Up to 4 KB serialized. It's read once, at this call, and can't be changed later. To change it, remove the schedule and create a new one.
|
||||
|
||||
### Times
|
||||
@@ -164,7 +167,7 @@ The `user` binding is valid for 15 minutes, so use it only during the run. Write
|
||||
|
||||
A run that fails because of Puter doesn't count as a failure. It's retried on the same schedule, and waits longer if Puter's answer asked for a longer wait, up to 5 minutes. To drop the run in that case anyway, throw an error with `terminal: true`. The rules are the same as for a [`queue` subscription](/Events/onPersistent/#what-happens-when-a-handler-fails), except that the retries stop once the schedule's next run is due, or after 24 hours for an `at` schedule or a last run. The run is then dropped without billing.
|
||||
|
||||
Five failures in a row suspend the schedule with `failures`. The developer is notified, and publishing the handler again resumes it.
|
||||
Five failures in a row suspend the schedule with `failures`, and publishing the handler again resumes it. For an app handler the developer gets an `app.events.suspended` notification. For a handler in a user scope the user gets an `app.events.handler_suspended` notification instead, and the developer isn't told.
|
||||
|
||||
## Background runs need consent
|
||||
|
||||
@@ -229,8 +232,15 @@ The promise rejects with `{ message, code }`:
|
||||
| `code` | Meaning |
|
||||
| --- | --- |
|
||||
| `invalid_request` | `options` isn't an object. |
|
||||
| `events_handler_required` | There's no `handlerName`. |
|
||||
| `events_handler_name_required` | `handler` was given without `handlerName`. |
|
||||
| `events_handler_required` | There's neither a `handler` nor a `handlerName`. |
|
||||
| `events_user_handlers_disabled` | Publishing into a user scope isn't turned on for this server. |
|
||||
| `events_handler_forbidden` | The call can't publish into a user scope: it came from a scoped access token, a temporary account, or on behalf of a different app. |
|
||||
| `insufficient_funds` | Publishing into a user scope needs credit on a hosted Puter server, and the user has none. |
|
||||
| `forbidden` | The user already holds as many [worker units](/rate-limits-and-quotas/#worker-units) as their plan allows, so a new user scope can't be created. |
|
||||
| `events_handler_limit` | The user scope already holds the [maximum number of handlers](/rate-limits-and-quotas/#handlers). |
|
||||
| `events_handler_too_large` | The handler is over 64 KB. |
|
||||
| `events_worker_too_large` | The user scope's handlers would exceed 1 MB combined. |
|
||||
| `events_handler_name_invalid` | `handlerName` is empty, too long, has characters that aren't allowed, or starts with `@`, which is reserved for inline handlers. |
|
||||
| `events_handler_free_variable` | The handler uses an outside variable. The message names it. |
|
||||
| `events_handler_invalid` | `handler` isn't a function, a source string, or `{ file }`. |
|
||||
| `events_handler_hash_unavailable` | This environment has no `crypto.subtle`, so `handler` can't be hashed. Pass `handlerName` alone. |
|
||||
@@ -246,7 +256,7 @@ The promise rejects with `{ message, code }`:
|
||||
| `events_schedule_too_frequent` | It would run more often than your plan's [minimum interval](#how-often-a-schedule-can-run). |
|
||||
| `events_schedule_too_deep` | The call was made from a handler at the end of a [handler chain](/Events/onPersistent/#handlers-that-trigger-handlers), so the schedule could never run. |
|
||||
| `bad_request` | `handlerName` is empty or over 128 characters, or `handlerHash` isn't a SHA-256 hex digest. |
|
||||
| `events_handler_not_found` | Nothing is published under `handlerName`. The schedule isn't created. |
|
||||
| `events_handler_not_found` | Nothing is published under `handlerName` in that scope. The schedule isn't created. |
|
||||
| `events_handler_hash_mismatch` | The published handler isn't the source you passed as `handler`. |
|
||||
| `events_schedule_limit` | The user holds as many schedules as their plan allows, for your app or in total. |
|
||||
| `events_schedule_budget` | The schedule would make your app's schedules, or all of the user's schedules, run more times an hour than their plan allows. |
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
---
|
||||
title: puter.events.workers
|
||||
description: List and destroy the events worker a published handler set stands up.
|
||||
description: List and destroy the events worker a published handler set stands up, for an app or for one user.
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
---
|
||||
|
||||
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
An **events worker** runs an app's published [handlers](/Events/handlers/). It runs them for every event on a subscription with a `handlerName`, whether or not a client is connected, for a [`queue`](/Events/onPersistent/#choosing-a-mode) delivery once no connected client takes it, and for every run of a [schedule](/Events/onSchedule/). Each app has at most one, created when it publishes its first handler, however many handlers it publishes.
|
||||
An **events worker** runs published [handlers](/Events/handlers/), the app's own or one user's in a [user scope](/Events/handlers/#user-scope). It runs them for every event on a subscription with a `handlerName`, whether or not a client is connected, for a [`queue`](/Events/onPersistent/#choosing-a-mode) delivery once no connected client takes it, and for every run of a [schedule](/Events/onSchedule/). Each app has at most one for its own handlers, created when it publishes its first handler. Each user scope is a unit of its own, created when the user's first handler in an app is published, whether by publishing it or by passing an inline `handler`.
|
||||
|
||||
A hosted Puter server may bill each app's events worker as a monthly cost, even if nothing ever delivers to it. Use this API to see which apps have one and remove the ones you don't need.
|
||||
A hosted Puter server may bill each app's events worker as a monthly cost, even if nothing ever delivers to it, and bills a user scope to the user the same way. Use this API to see which apps and scopes have one and remove the ones you don't need.
|
||||
|
||||
```js
|
||||
const { items } = await puter.events.workers.list(); // [{ appUid, appName, handlerCount, ... }]
|
||||
await puter.events.workers.destroy(items[0].appUid); // removes every handler that app published
|
||||
```
|
||||
|
||||
Outside an app, such as in Node.js with your account token, you see the workers of every app you own. Inside an app, you see only that app's own worker (0 or 1 item), and only if the signed-in user owns the app.
|
||||
Outside an app, such as in Node.js with your account token, you see the workers of every app you own, and the user scopes you hold in any app. Inside an app, you see only that app's own worker, if the signed-in user owns the app, and the signed-in user's scope of that app.
|
||||
|
||||
On a website with nobody signed in, each method asks the user to sign in first, as other Puter.js calls do. An app running on Puter is always signed in.
|
||||
|
||||
@@ -33,7 +33,7 @@ puter.events.workers.list(options)
|
||||
|
||||
Resolves to `{ items, cursor, deployable }`:
|
||||
|
||||
- `items`: One `{ appUid, appName, appTitle, handlerCount, createdAt, updatedAt, script }` per app. In each item, `createdAt` is when the app's first handler was published, `updatedAt` is its latest publish, and `script` names the deployed script, which is useful when reporting an issue.
|
||||
- `items`: One `{ scope, appUid, appName, appTitle, handlerCount, createdAt, updatedAt, script }` per app worker, then one per user scope you hold. `scope` is `'app'` for an app's own worker and `'user'` for your scope of that app. In each item, `createdAt` is when the first handler was published, `updatedAt` is the latest publish, and `script` names the deployed script, which is useful when reporting an issue. A `'user'` item has no `script`.
|
||||
- `cursor`: Present while more pages exist.
|
||||
- `deployable`: `false` when this server doesn't run events workers, such as a self-hosted install without the runtime turned on. Handlers can still be published there, but background deliveries never run.
|
||||
|
||||
@@ -41,13 +41,16 @@ Resolves to `{ items, cursor, deployable }`:
|
||||
|
||||
```js
|
||||
puter.events.workers.destroy(appUid)
|
||||
puter.events.workers.destroy(appUid, options)
|
||||
```
|
||||
|
||||
Removes every handler the app has published, the same as calling [`puter.events.handlers.remove()`](/Events/handlers/) on each. Their subscriptions and schedules are suspended with `handler_not_found`, not deleted, and publishing the handlers again resumes them.
|
||||
- `options.scope` (String): `'app'` or `'user'`. Defaults to `'app'`. `'user'` removes the signed-in user's own scope of the app, and `appUid` can only name the app making the call.
|
||||
|
||||
It also ends the worker session background deliveries ran under, for every user. The next delivery after a new publish starts a new one.
|
||||
Removes every handler the app has published, or every handler in the user's scope with `scope: 'user'`, the same as calling [`puter.events.handlers.remove()`](/Events/handlers/) on each. Their subscriptions and schedules are suspended with `handler_not_found`, not deleted, and publishing the handlers again resumes them.
|
||||
|
||||
Resolves to `{ appUid, removed, suspended }`, the number of handlers deleted and the number of subscriptions and schedules suspended. An app with nothing published rejects with `events_handler_not_found`.
|
||||
It also ends the worker session background deliveries ran under, for every user, or for the one user with `scope: 'user'`. The next delivery after a new publish starts a new one.
|
||||
|
||||
Resolves to `{ appUid, removed, suspended }`, plus `scope: 'user'` for a user scope, the number of handlers deleted and the number of subscriptions and schedules suspended. An app with nothing published rejects with `events_handler_not_found`.
|
||||
|
||||
## Errors
|
||||
|
||||
@@ -58,7 +61,7 @@ Both methods reject with `{ message, code }`:
|
||||
| `auth_canceled` | Nobody was signed in, and the user closed the sign-in without finishing it. |
|
||||
| `events_worker_owner_only` | `list()` was called with a scoped access token, such as the one in a [`getReadURL()`](/FS/getReadURL/) URL. |
|
||||
| `events_handler_not_found` | `destroy()` named an app with no published handlers. |
|
||||
| `events_handler_forbidden` | The signed-in user doesn't own the app, the app doesn't exist, the call came from a different app, or the caller is a scoped access token. |
|
||||
| `events_handler_forbidden` | The signed-in user doesn't own the app, the app doesn't exist, the call came from a different app, or the caller is a scoped access token. For `scope: 'user'`, the caller isn't an app acting for a signed-in user. |
|
||||
| `too_many_requests` | Over the publish and remove rate limit, or the listing rate limit. |
|
||||
| `events_disabled` | Events aren't enabled on this server. |
|
||||
| `events_failed` | The server sent a response the SDK couldn't read. |
|
||||
@@ -79,3 +82,12 @@ for (const worker of items) {
|
||||
const destroyed = await puter.events.workers.destroy(appUid); // an app you own
|
||||
console.log(`removed ${destroyed.removed}, suspended ${destroyed.suspended}`);
|
||||
```
|
||||
|
||||
<strong class="example-title">Remove the signed-in user's scope of your app</strong>
|
||||
|
||||
This runs in your app. The user scope belongs to the user, so it needs no owner step.
|
||||
|
||||
```js
|
||||
const destroyed = await puter.events.workers.destroy(puter.appID, { scope: 'user' });
|
||||
console.log(`removed ${destroyed.removed}, suspended ${destroyed.suspended}`);
|
||||
```
|
||||
@@ -676,6 +676,18 @@ const examples = [
|
||||
slug: 'events-unsubscribe',
|
||||
source: '/playground/examples/events-unsubscribe.html',
|
||||
},
|
||||
{
|
||||
title: 'Run an Inline Handler on a Folder',
|
||||
description: 'Watch a folder in the background with a handler you pass inline, using the Puter.js Events API. Run it in the playground.',
|
||||
slug: 'events-persistent-inline',
|
||||
source: '/playground/examples/events-persistent-inline.html',
|
||||
},
|
||||
{
|
||||
title: 'Queue Events for an Inline Handler',
|
||||
description: 'Send each change to one inline handler that acknowledges it, using the Puter.js Events API. Run it in the playground.',
|
||||
slug: 'events-persistent-queue-inline',
|
||||
source: '/playground/examples/events-persistent-queue-inline.html',
|
||||
},
|
||||
{
|
||||
title: 'List Schedules',
|
||||
description: 'List the schedules and stored notifications an app holds for a user with the Puter.js Events API. Run and experiment with this example in the playground.',
|
||||
@@ -694,6 +706,30 @@ const examples = [
|
||||
slug: 'events-remove-schedule',
|
||||
source: '/playground/examples/events-remove-schedule.html',
|
||||
},
|
||||
{
|
||||
title: 'Run Code Once at a Set Time',
|
||||
description: 'Schedule an inline handler to run once in two minutes with the Puter.js Events API, then remove it. Run it in the playground.',
|
||||
slug: 'events-schedule-inline',
|
||||
source: '/playground/examples/events-schedule-inline.html',
|
||||
},
|
||||
{
|
||||
title: 'Run Code on Weekday Mornings',
|
||||
description: 'Schedule an inline handler on a cron expression in the browser time zone with the Puter.js Events API. Run it in the playground.',
|
||||
slug: 'events-schedule-cron-tz',
|
||||
source: '/playground/examples/events-schedule-cron-tz.html',
|
||||
},
|
||||
{
|
||||
title: 'Run Code Every 15 Minutes',
|
||||
description: 'Schedule an inline handler at an interval with a run limit, and read what it wrote, using the Puter.js Events API. Run it in the playground.',
|
||||
slug: 'events-schedule-every',
|
||||
source: '/playground/examples/events-schedule-every.html',
|
||||
},
|
||||
{
|
||||
title: 'Share a Handler Across Schedules',
|
||||
description: 'Publish a named handler into your own user scope, bind two schedules to it, and update it, using the Puter.js Events API. Run it in the playground.',
|
||||
slug: 'events-handlers-user-scope',
|
||||
source: '/playground/examples/events-handlers-user-scope.html',
|
||||
},
|
||||
{
|
||||
title: 'Catch Up on Notifications',
|
||||
description: 'Page through notifications missed while your app was closed with the Puter.js Events API. Run it in the playground.',
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
if (!await puter.perms.request('backgroundEvents')) {
|
||||
puter.print('background events were not allowed<br>');
|
||||
return;
|
||||
}
|
||||
|
||||
// (1) Publish a named handler into this user's own scope of the app
|
||||
const name = `reminder-${Date.now()}`;
|
||||
const v1 = await puter.events.handlers.publish(
|
||||
name,
|
||||
async ({ ctx, user }) => {
|
||||
await user.notifications.notify({ title: `${ctx.label} (v1)` });
|
||||
},
|
||||
{ scope: 'user' },
|
||||
);
|
||||
puter.print(`published ${v1.name}: ${v1.outcome}<br>`);
|
||||
|
||||
// (2) Bind two schedules to it
|
||||
const hour = 60 * 60 * 1000;
|
||||
const schedules = [
|
||||
await puter.events.onSchedule({
|
||||
every: '1h',
|
||||
handlerName: name,
|
||||
handlerScope: 'user',
|
||||
context: { label: 'Stretch' },
|
||||
}),
|
||||
await puter.events.onSchedule({
|
||||
at: Date.now() + 2 * hour,
|
||||
handlerName: name,
|
||||
handlerScope: 'user',
|
||||
context: { label: 'Lunch' },
|
||||
}),
|
||||
];
|
||||
|
||||
// (3) Publishing again moves both schedules to the new code
|
||||
const v2 = await puter.events.handlers.publish(
|
||||
name,
|
||||
async ({ ctx, user }) => {
|
||||
await user.notifications.notify({ title: `${ctx.label} (v2)` });
|
||||
},
|
||||
{ scope: 'user' },
|
||||
);
|
||||
puter.print(`republished: ${v2.outcome}<br>`);
|
||||
|
||||
// (4) The scope's handlers, and how many schedules each one carries
|
||||
for (const handler of await puter.events.handlers.list({ scope: 'user' })) {
|
||||
puter.print(`${handler.name}: ${handler.subscriptions} bound<br>`);
|
||||
}
|
||||
|
||||
// (5) Cleanup: end the schedules, then remove the handler
|
||||
for (const schedule of schedules) await schedule.off();
|
||||
await puter.events.handlers.remove(name, { scope: 'user' });
|
||||
puter.print('removed<br>');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
if (!await puter.perms.request('backgroundEvents')) {
|
||||
puter.print('background events were not allowed<br>');
|
||||
return;
|
||||
}
|
||||
|
||||
// (1) A folder to watch
|
||||
const dir = puter.randName();
|
||||
await puter.fs.mkdir(dir);
|
||||
|
||||
// (2) The handler runs in the events worker for every change, even with this page closed
|
||||
const sub = await puter.events.onPersistent({
|
||||
subject: `fs:${dir}`,
|
||||
handler: async ({ event, ctx, fetch }) => {
|
||||
await fetch(ctx.webhook, { method: 'POST', body: event.path });
|
||||
},
|
||||
context: { webhook: 'https://example.com/ingest' },
|
||||
});
|
||||
puter.print(`watching ${dir} with ${sub.handlerName}<br>`);
|
||||
|
||||
// (3) A change to deliver
|
||||
await puter.fs.write(`${dir}/hello.txt`, 'Hello!');
|
||||
|
||||
// (4) Cleanup: end the subscription, remove its handler, delete the folder
|
||||
await puter.events.unsubscribe(sub.subId);
|
||||
await puter.events.handlers.remove(sub.handlerName, { scope: 'user' });
|
||||
await puter.fs.delete(dir);
|
||||
puter.print('removed<br>');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
if (!await puter.perms.request('backgroundEvents')) {
|
||||
puter.print('background events were not allowed<br>');
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = puter.randName();
|
||||
await puter.fs.mkdir(dir);
|
||||
|
||||
// (1) A queue: each event goes to one consumer and is offered again until it is acknowledged
|
||||
const sub = await puter.events.onPersistent({
|
||||
subject: `fs:${dir}`,
|
||||
queue: true,
|
||||
handler: async ({ event, ctx, fetch, ack }) => {
|
||||
await ack();
|
||||
await fetch(ctx.webhook, { method: 'POST', body: event.path });
|
||||
},
|
||||
context: { webhook: 'https://example.com/ingest' },
|
||||
});
|
||||
puter.print(`queue ${sub.queue}, handler ${sub.handlerName}<br>`);
|
||||
|
||||
await puter.fs.write(`${dir}/hello.txt`, 'Hello!');
|
||||
|
||||
// (2) Cleanup: end the subscription, remove its handler, delete the folder
|
||||
await puter.events.unsubscribe(sub.subId);
|
||||
await puter.events.handlers.remove(sub.handlerName, { scope: 'user' });
|
||||
await puter.fs.delete(dir);
|
||||
puter.print('removed<br>');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
if (!await puter.perms.request('backgroundEvents')) {
|
||||
puter.print('background events were not allowed<br>');
|
||||
return;
|
||||
}
|
||||
|
||||
// (1) Weekdays at 08:00 in this browser's time zone
|
||||
const schedule = await puter.events.onSchedule({
|
||||
cron: '0 8 * * 1-5',
|
||||
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
handler: async ({ ctx, user, fetch }) => {
|
||||
const response = await fetch(ctx.url);
|
||||
await user.notifications.notify({
|
||||
title: ctx.title,
|
||||
text: await response.text(),
|
||||
});
|
||||
},
|
||||
context: { url: 'https://api.github.com/zen', title: 'Good morning' },
|
||||
});
|
||||
puter.print(`${schedule.cron} in ${schedule.tz}<br>`);
|
||||
puter.print(`next run: ${new Date(schedule.nextRunAt).toLocaleString()}<br>`);
|
||||
|
||||
// (2) Cleanup: end the schedule, then remove the handler it created
|
||||
await schedule.off();
|
||||
await puter.events.handlers.remove(schedule.handlerName, { scope: 'user' });
|
||||
puter.print('removed<br>');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
if (!await puter.perms.request('backgroundEvents')) {
|
||||
puter.print('background events were not allowed<br>');
|
||||
return;
|
||||
}
|
||||
|
||||
// (1) Every 15 minutes, 3 times. Each run adds one to a counter in the key-value store
|
||||
const key = `runs-${puter.randName()}`;
|
||||
const schedule = await puter.events.onSchedule({
|
||||
every: '15m',
|
||||
maxRuns: 3,
|
||||
handler: async ({ ctx, user }) => {
|
||||
const runs = (await user.kv.get(ctx.key)) ?? 0;
|
||||
await user.kv.set(ctx.key, runs + 1);
|
||||
},
|
||||
context: { key },
|
||||
});
|
||||
puter.print(`first run at ${new Date(schedule.nextRunAt).toLocaleTimeString()}<br>`);
|
||||
|
||||
// (2) The page reads what the handler wrote. Nothing has run yet, so this is 0
|
||||
puter.print(`runs so far: ${(await puter.kv.get(key)) ?? 0}<br>`);
|
||||
|
||||
// (3) Cleanup: end the schedule, remove its handler, delete the counter
|
||||
await schedule.off();
|
||||
await puter.events.handlers.remove(schedule.handlerName, { scope: 'user' });
|
||||
await puter.kv.del(key);
|
||||
puter.print('removed<br>');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
if (!await puter.perms.request('backgroundEvents')) {
|
||||
puter.print('background events were not allowed<br>');
|
||||
return;
|
||||
}
|
||||
|
||||
// (1) The code goes in with the schedule, and runs in the events worker
|
||||
const schedule = await puter.events.onSchedule({
|
||||
at: Date.now() + 2 * 60 * 1000,
|
||||
handler: async ({ ctx, user }) => {
|
||||
await user.notifications.notify({ title: ctx.message });
|
||||
},
|
||||
context: { message: 'Two minutes have passed' },
|
||||
});
|
||||
puter.print(`runs at ${new Date(schedule.nextRunAt).toLocaleTimeString()}<br>`);
|
||||
puter.print(`handler stored as ${schedule.handlerName}<br>`);
|
||||
|
||||
// (2) Cleanup: end the schedule, then remove the handler it created
|
||||
await schedule.off();
|
||||
await puter.events.handlers.remove(schedule.handlerName, { scope: 'user' });
|
||||
puter.print('removed<br>');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -23,7 +23,7 @@ Under the [User-Pays Model](/user-pays-model), limits are counted against each u
|
||||
| ---------------------- | ---------------------- |
|
||||
| **Per user, per app** | One user in one app. **This is the default**: a limit that names no scope uses it. The same user in another app has a separate budget. The Puter desktop and API tokens count as one more app, and each [worker](/Workers/) gets its own budget too. |
|
||||
| **Per user, all apps** | One user, summed across every app they use. Used for quotas on things an account keeps (subscriptions, share handles, shares per day, uploads in progress) and a few call budgets. |
|
||||
| **Per app, all users** | One app, summed across all of its users. Used for things a developer publishes, such as event handlers. |
|
||||
| **Per app, all users** | One app, summed across all of its users. Used for things a developer publishes, such as event handlers. A user's own handlers in an app are counted separately, per user and per app. |
|
||||
| **Per network** | One IP address or device. Used by routes with no signed-in user, such as signed URLs and WebDAV. |
|
||||
|
||||
When a quota has both a per-app and an all-apps number, both apply: one app can use up to its per-app share, and all apps together can use up to the all-apps total.
|
||||
@@ -579,7 +579,7 @@ Counted per event, per region:
|
||||
| New recipients of `kv.share()` | 300/min, 2,000/hour | Per user, all apps |
|
||||
| Recipients named in `kv.share()` | 1,200/min | Per user, all apps |
|
||||
| `kv.share.received` notifications | 5/hour | Per owner and holder |
|
||||
| Handler publish / remove | 60/min | Per user, all apps |
|
||||
| Handler publish / remove, app and user scope | 60/min | Per user, all apps |
|
||||
| Subscription and share-handle listings (combined) | 120/min | Per user, per app |
|
||||
| Handler listings | 120/min | Per user, per app |
|
||||
| Events worker listings | 120/min | Per user, per app |
|
||||
@@ -617,6 +617,9 @@ Unsubscribing counts toward its own `unsubscribe` limit, not the `subscribe` one
|
||||
| Source for all handlers combined | 5 MB | Per app, all users |
|
||||
| Handlers per `publishAll()` call | 50 | Per call |
|
||||
| Events worker deploys | 30/hour | Per app, all users |
|
||||
| Published handlers in a user scope | 20 | Per user, per app |
|
||||
| Source for all handlers in a user scope combined | 1 MB | Per user, per app |
|
||||
| Events worker deploys for a user scope | 10/hour | Per user, per app |
|
||||
| Run timeout | 30 seconds | Per run |
|
||||
| Retry delay (`queue`, temporary errors) | 2 seconds, doubling, up to 5 minutes | Per delivery |
|
||||
| Failures in a row before suspension | 5 | Per subscription |
|
||||
@@ -628,6 +631,19 @@ Only the handler's own failures count, including a run past the 30-second timeou
|
||||
|
||||
Over the deploy limit, deliveries stay queued and retry after the hour rolls over.
|
||||
|
||||
A [user scope](/Events/handlers/#user-scope) holds the handlers one user published in one app, including every inline `handler` passed to [`onSchedule()`](/Events/onSchedule/) or [`onPersistent()`](/Events/onPersistent/). Each one is its own script. The scope-specific rows above are separate from the app's: they don't count toward the app's 100 handlers or 5 MB.
|
||||
|
||||
- A publish over the handler cap fails with `events_handler_limit`, and one over the combined source cap with `events_worker_too_large`. A single handler over 64 KB fails with `events_handler_too_large`.
|
||||
- Publishing into a user scope needs credit on a hosted Puter server, and fails with `insufficient_funds` without it. A server that hasn't turned user scopes on answers `events_user_handlers_disabled`. Listing and removing a scope's handlers need neither.
|
||||
|
||||
#### Worker units
|
||||
|
||||
| Limit | Paid | Free | Scope |
|
||||
| ---------------------------------------- | ---- | ---- | ------------------ |
|
||||
| Worker units held at once | 100 | 16 | Per user, all apps |
|
||||
|
||||
The units are the user's [workers](/Workers/), their user scopes (one per app that holds a handler of theirs), and the [events workers](/Events/workers/) of apps they own. Only creating a new unit is refused, with a `403` and code `forbidden`. A user already over the limit keeps what they hold, and publishing into a scope they already have isn't refused.
|
||||
|
||||
#### Handler chains
|
||||
|
||||
| Limit | Scope | Paid | Free |
|
||||
|
||||
@@ -41,9 +41,11 @@ await puter.kv.set(`${prefix}score`, { home: 2, away: 1, final: true });
|
||||
|
||||
Publish a handler that alerts on the final score, and on every goal for fans
|
||||
who asked for that, with
|
||||
[`puter.events.handlers.publish()`](/Events/handlers/). Only the account that
|
||||
owns the app can publish, so run this once yourself, signed in to your site as
|
||||
the owner. The browser console is enough:
|
||||
[`puter.events.handlers.publish()`](/Events/handlers/). Publishing is a
|
||||
developer step, so run this once yourself, signed in to your site as the app's
|
||||
owner. The browser console is enough. To skip it, pass the same function as
|
||||
`handler` to each fan's `onPersistent()` call instead, which stores it in the
|
||||
fan's own [user scope](/Events/handlers/#user-scope):
|
||||
|
||||
```js
|
||||
await puter.events.handlers.publish('alertScore', async ({ event, user, ctx }) => {
|
||||
|
||||
@@ -14,10 +14,10 @@ snoozed task comes back to the list, a poll closes, or a shared link stops
|
||||
working. On other platforms you'd store the time in a database and run a job
|
||||
that checks it every minute.
|
||||
|
||||
In Puter, you publish a function once, and create a one-time schedule that
|
||||
runs it at that time with [`puter.events.onSchedule()`](/Events/onSchedule/)
|
||||
and `at`. Puter runs it in your app's [events worker](/Events/workers/), a
|
||||
serverless function that belongs to your app, even while your app is closed.
|
||||
In Puter, you create a one-time schedule that runs a function at that time with
|
||||
[`puter.events.onSchedule()`](/Events/onSchedule/) and `at`. Puter runs it in an
|
||||
[events worker](/Events/workers/), a serverless function, even while your app is
|
||||
closed.
|
||||
|
||||
When all that's due is a notification,
|
||||
[Remind Users Later](/recipes/remind-users-later/) sends one at a set time
|
||||
@@ -30,10 +30,12 @@ gets a notification about it.
|
||||
|
||||
## Publish the Handler
|
||||
|
||||
The function a schedule runs is called a handler. Publish it with
|
||||
[`puter.events.handlers.publish()`](/Events/handlers/). This is a developer
|
||||
step, like a deploy, and it only works for the account that owns the app. Run
|
||||
it from a deploy script or your browser console:
|
||||
The function a schedule runs is called a handler. This recipe publishes it once
|
||||
under a name with [`puter.events.handlers.publish()`](/Events/handlers/), so one
|
||||
copy serves every user and you can update it for all of them. Publishing is a
|
||||
developer step, like a deploy, and it only works for the account that owns the
|
||||
app. Run it from a deploy script or your browser console. To skip the deploy,
|
||||
[pass the handler inline](#pass-the-handler-inline) instead.
|
||||
|
||||
```js
|
||||
const app = await puter.apps.get('task-list'); // your app's name
|
||||
@@ -62,6 +64,32 @@ task as no longer snoozed with [`user.kv.update()`](/KV/update/).
|
||||
[Run Code on a Schedule](/recipes/run-code-on-a-schedule/#publish-the-handler)
|
||||
explains what else a handler can use.
|
||||
|
||||
### Pass the Handler Inline
|
||||
|
||||
For a one-time schedule, the handler can go straight into `onSchedule()`. Leave
|
||||
out `handlerName`, and Puter stores the code in the signed-in user's own
|
||||
[user scope](/Events/handlers/#user-scope) of your app:
|
||||
|
||||
```js
|
||||
await puter.events.onSchedule({
|
||||
at: Date.now() + 60 * 60 * 1000,
|
||||
handler: async ({ ctx, user }) => {
|
||||
const key = `task:${ctx.taskId}`;
|
||||
const task = await user.kv.get(key);
|
||||
if (!task) return;
|
||||
|
||||
await user.notifications.notify({ title: 'Back on your list', text: task.title });
|
||||
await user.kv.update(key, { snoozedUntil: null });
|
||||
},
|
||||
context: { taskId: 'task-42' },
|
||||
});
|
||||
```
|
||||
|
||||
The code stays in the user's scope after the schedule ends, so remove it when
|
||||
you're done with it, as
|
||||
[Clean Up Background Work](/recipes/clean-up-background-work/#remove-a-user-scope)
|
||||
shows.
|
||||
|
||||
## Ask for Permission
|
||||
|
||||
A schedule runs code while the user isn't there, so it needs the user's
|
||||
@@ -244,7 +272,7 @@ the schedule ends on its own after its last run.
|
||||
|
||||
Each run of a repeating schedule has its own `event.id`, so the handler keeps
|
||||
the id of the last run it handled and skips a retry of that run. Publish it as
|
||||
the app's developer, like `unsnooze`:
|
||||
the app's developer, like `unsnooze`, or pass it inline as `handler`:
|
||||
|
||||
```js
|
||||
async function reopenTask ({ event, ctx, user }) {
|
||||
|
||||
@@ -9,15 +9,17 @@ order: 70
|
||||
|
||||
An app that runs code in the background, as in
|
||||
[Run Code in the Background](/recipes/run-code-in-the-background/), leaves
|
||||
two kinds of things behind. Each user has persistent subscriptions in their
|
||||
account, and you have the handlers you published and the events worker that
|
||||
runs them. When a feature changes or goes away, the ones you no longer need can
|
||||
three kinds of things behind. Each user has persistent subscriptions and
|
||||
schedules in their account, and handlers they hold in their own
|
||||
[user scope](/Events/handlers/#user-scope) when your app passed code inline. You
|
||||
have the handlers you published and the events worker that runs them. When a feature changes or goes away, the ones you no longer need can
|
||||
keep running. A subscription with a handler runs it for every event it matches,
|
||||
and each run is billed to the user. An [events worker](/Events/workers/) can be
|
||||
billed to you even when nothing runs on it.
|
||||
|
||||
This recipe removes both. Your app cleans up each user's subscriptions, and you
|
||||
remove the handlers and events workers you no longer use.
|
||||
This recipe removes all of them. Your app cleans up each user's subscriptions
|
||||
and user scope, and you remove the handlers and events workers you no longer
|
||||
use.
|
||||
|
||||
## Remove Stale Subscriptions
|
||||
|
||||
@@ -50,7 +52,9 @@ uses, and ends the rest:
|
||||
- **A handler you stopped using.** Put only the handler names the current
|
||||
version of your app subscribes with in `handlersInUse`. A subscription made
|
||||
without a handler has `handlerName: null`, so add `null` if your app makes
|
||||
those.
|
||||
those. A subscription created with an inline `handler` has a `handlerName`
|
||||
that starts with `@`, so match those with `sub.handlerName?.startsWith('@')`
|
||||
if your app makes them.
|
||||
- **Paused with `permission_revoked`.** The user took back the permission, or
|
||||
lost access to what the subscription watched. It never resumes. It stays in
|
||||
the list for up to 30 days unless you remove it.
|
||||
@@ -61,10 +65,45 @@ Call `cleanUpSubscriptions()` when the app opens, before `startBackgroundWork()`
|
||||
from [Run Code in the Background](/recipes/run-code-in-the-background/#subscribe-once-per-user),
|
||||
so that function finds the subscription that's left.
|
||||
|
||||
## Remove a User Scope
|
||||
|
||||
An inline `handler` passed to [`onSchedule()`](/Events/onSchedule/) or
|
||||
[`onPersistent()`](/Events/onPersistent/) is stored in the signed-in user's own
|
||||
user scope of your app. Ending the subscription or schedule leaves the code
|
||||
there, where it counts toward the scope's
|
||||
[limits](/rate-limits-and-quotas/#handlers), and the scope keeps counting as one
|
||||
of the user's [worker units](/rate-limits-and-quotas/#worker-units). The user
|
||||
scope is the user's, so your app removes it from the user's session, with no
|
||||
owner step. [`handlers.list()`](/Events/handlers/) with `scope: 'user'` shows
|
||||
what the scope holds, and
|
||||
[`handlers.remove()`](/Events/handlers/) removes one:
|
||||
|
||||
```js
|
||||
for (const handler of await puter.events.handlers.list({ scope: 'user' })) {
|
||||
console.log(`${handler.name}: ${handler.subscriptions} subscriptions`);
|
||||
}
|
||||
|
||||
await puter.events.handlers.remove('syncFeeds', { scope: 'user' });
|
||||
```
|
||||
|
||||
Subscriptions and schedules bound to a removed handler are paused with
|
||||
`handler_not_found`, as for an app handler. To remove everything the user's scope
|
||||
holds and end the scope, pass the app's uid to
|
||||
[`puter.events.workers.destroy()`](/Events/workers/) with `scope: 'user'`:
|
||||
|
||||
```js
|
||||
await puter.events.workers.destroy(puter.appID, { scope: 'user' });
|
||||
```
|
||||
|
||||
Do this after you've ended the subscriptions and schedules that use it, with
|
||||
`unsubscribe()` and `removeSchedule()`. Listing and removing don't need credit,
|
||||
and [`workers.list()`](/Events/workers/) shows the user's scopes with
|
||||
`scope: 'user'`.
|
||||
|
||||
## Remove Handlers You No Longer Use
|
||||
|
||||
Removing a handler is a developer step, so run it from a deploy script or your
|
||||
browser console, signed in as the app's owner.
|
||||
Removing a published handler is a developer step, so run it from a deploy script
|
||||
or your browser console, signed in as the app's owner.
|
||||
[`puter.events.handlers.list()`](/Events/handlers/) shows how many
|
||||
subscriptions still use each handler, and
|
||||
[`puter.events.handlers.remove()`](/Events/handlers/) removes one:
|
||||
@@ -93,7 +132,8 @@ gets `events_handler_not_found` from
|
||||
## Remove an Events Worker
|
||||
|
||||
Your app's events worker is created when you publish its first handler, and
|
||||
removed with its last one. To see which of your apps have one, call
|
||||
removed with its last one. A user's scope is a unit of its own, and isn't part of
|
||||
yours. To see which of your apps have one, call
|
||||
[`puter.events.workers.list()`](/Events/workers/) outside an app, for example
|
||||
in your browser console on Puter. It returns a page at a time:
|
||||
|
||||
|
||||
@@ -199,7 +199,10 @@ closed. The handler calls [`notify()`](/Notifications/notify/) for its own
|
||||
member, so it needs no share. Use this in place of `notifyRoom()`.
|
||||
|
||||
Publish the handler once, signed in to your site as the app's owner, with
|
||||
[`puter.events.handlers.publish()`](/Events/handlers/).
|
||||
[`puter.events.handlers.publish()`](/Events/handlers/). To skip the publish,
|
||||
pass the same function as `handler` to each member's `onPersistent()` call
|
||||
instead, which stores it in the member's own
|
||||
[user scope](/Events/handlers/#user-scope).
|
||||
[Run Code in the Background](/recipes/run-code-in-the-background/#publish-the-handler)
|
||||
explains handlers in more detail:
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ timestamp.
|
||||
|
||||
The handler keeps its cursor in the user's key-value store, so each run picks
|
||||
up where the last one stopped. Publish it as the app's developer, from a
|
||||
deploy script or your browser console:
|
||||
deploy script or your browser console, so one copy serves every user. To skip
|
||||
the deploy, pass the same function as `handler` to `onSchedule()` instead, which
|
||||
stores it in the user's own [user scope](/Events/handlers/#user-scope):
|
||||
|
||||
```js
|
||||
const app = await puter.apps.get('order-sync'); // your app's name
|
||||
|
||||
@@ -136,7 +136,8 @@ done, run a handler at that time instead with
|
||||
checks the task and sends the notification itself.
|
||||
|
||||
Publish the handler once, as the app's developer, from a deploy script or
|
||||
your browser console. [Run Code on a Schedule](/recipes/run-code-on-a-schedule/)
|
||||
your browser console, or pass the same function as `handler` to `onSchedule()`
|
||||
to store it in the user's own [user scope](/Events/handlers/#user-scope). [Run Code on a Schedule](/recipes/run-code-on-a-schedule/)
|
||||
explains handlers and the `user` object they get:
|
||||
|
||||
```js
|
||||
|
||||
@@ -12,12 +12,12 @@ each file a user adds to a folder, or posting to a chat when a report is saved.
|
||||
On other platforms you'd use a webhook, a background job, or a cloud function
|
||||
that runs on upload.
|
||||
|
||||
In Puter, you publish a function once, and each user's persistent subscription
|
||||
In Puter, you give each user's persistent subscription a function, and it
|
||||
runs it when their files change. A persistent subscription, created with
|
||||
[`puter.events.onPersistent()`](/Events/onPersistent/), is saved to the user's
|
||||
account and keeps running after your app closes. Puter runs the function in
|
||||
your app's [events worker](/Events/workers/), a serverless function that
|
||||
belongs to your app, so you don't run a server. To update a page while it's
|
||||
an [events worker](/Events/workers/), a serverless function, so you don't run
|
||||
a server. To update a page while it's
|
||||
open, use the subscriptions in
|
||||
[Watch for Changes](/recipes/watch-for-changes/) instead.
|
||||
|
||||
@@ -27,10 +27,15 @@ so the app can list them later.
|
||||
|
||||
## Publish the Handler
|
||||
|
||||
A handler is the function a subscription runs. Publish it with
|
||||
[`puter.events.handlers.publish()`](/Events/handlers/). This is a developer
|
||||
A handler is the function a subscription runs. There are two ways to give one
|
||||
to a subscription. The simplest is to [pass it inline](#pass-the-handler-inline),
|
||||
which any signed-in user's session can do from your app. The other is to publish
|
||||
it once under a name with [`puter.events.handlers.publish()`](/Events/handlers/),
|
||||
and have every user's subscription bind to that name. Publishing is a developer
|
||||
step, like a deploy, and only the account that owns the app can do it, so run it
|
||||
from a deploy script or your browser console:
|
||||
from a deploy script or your browser console. Use it when you want one copy of
|
||||
the code that you can update for every user at once. This section and the ones
|
||||
after it use a published handler:
|
||||
|
||||
```js
|
||||
const app = await puter.apps.get('drop-box'); // your app's name
|
||||
@@ -78,6 +83,42 @@ the key-value store. A file it writes into `Inbox` is a new change that runs the
|
||||
handler again, and Puter stops that loop after a few runs without an error.
|
||||
[Handler chains](/rate-limits-and-quotas/#handler-chains) has the numbers.
|
||||
|
||||
## Pass the Handler Inline
|
||||
|
||||
To skip the deploy step, pass the function as `handler` and leave out
|
||||
`handlerName`. Puter stores the code in the signed-in user's own
|
||||
[user scope](/Events/handlers/#user-scope) of your app, and runs it for that
|
||||
user's events only:
|
||||
|
||||
```js
|
||||
const inbox = `~/AppData/${puter.appID}/Inbox`;
|
||||
|
||||
const sub = await puter.events.onPersistent({
|
||||
subject: `fs:${inbox}`,
|
||||
queue: true,
|
||||
handler: async ({ event, user }) => {
|
||||
const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
|
||||
if (!arrived) return;
|
||||
|
||||
await user.kv.set(`uploads:${event.uid}`, {
|
||||
name: event.path.split('/').pop(),
|
||||
addedAt: event.ts,
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The handler follows the same rules as a published one, and gets its settings
|
||||
through `context` in the same way. The handler is private to that user, so one
|
||||
user's copy can't be changed by anyone else, including you. It also counts
|
||||
toward that user's [limits](/rate-limits-and-quotas/#handlers) and bills to the
|
||||
user like their own workers, and publishing needs credit on a hosted Puter
|
||||
server. Changing the code later makes a new handler, so the old subscription
|
||||
keeps running the old code. To roll out new code, unsubscribe and subscribe
|
||||
again, or [publish a named handler into the user scope](/Events/handlers/#user-scope).
|
||||
[Clean Up Background Work](/recipes/clean-up-background-work/#remove-a-user-scope)
|
||||
covers removing what a user's scope holds.
|
||||
|
||||
## Ask for Permission
|
||||
|
||||
Running code while your app is closed needs the user's permission. Ask from
|
||||
|
||||
@@ -14,10 +14,9 @@ did. For example, sending a summary every weekday morning, clearing out old
|
||||
data every night, or checking a feed every hour. On other platforms you'd use
|
||||
a cron job or a scheduled cloud function.
|
||||
|
||||
In Puter, you write a function, publish it once, and give each user a
|
||||
schedule that runs it. Puter runs it in your app's
|
||||
[events worker](/Events/workers/), a serverless function that belongs to your
|
||||
app, at the times the schedule says. You don't need to run a server.
|
||||
In Puter, you write a function and give each user a schedule that runs it.
|
||||
Puter runs it in an [events worker](/Events/workers/), a serverless function,
|
||||
at the times the schedule says. You don't need to run a server.
|
||||
|
||||
[Run Code in the Background](/recipes/run-code-in-the-background/) runs a
|
||||
function when a user's files change. This recipe runs one at set times
|
||||
@@ -27,10 +26,14 @@ weekday morning, in their own time zone, with how many tasks they have open.
|
||||
|
||||
## Publish the Handler
|
||||
|
||||
The function a schedule runs is called a handler. Publish it with
|
||||
[`puter.events.handlers.publish()`](/Events/handlers/). This is a developer
|
||||
step, like a deploy, and it only works for the account that owns the app. Run
|
||||
it from a deploy script or your browser console:
|
||||
The function a schedule runs is called a handler. You can
|
||||
[pass it inline](#pass-the-handler-inline) to `onSchedule()`, or publish it once
|
||||
under a name with [`puter.events.handlers.publish()`](/Events/handlers/) and
|
||||
have every user's schedule bind to that name. Publishing is a developer step,
|
||||
like a deploy, and it only works for the account that owns the app, so run it
|
||||
from a deploy script or your browser console. Use it when you want one copy of
|
||||
the code that you can update for every user at once. This section and the ones
|
||||
after it use a published handler:
|
||||
|
||||
```js
|
||||
const app = await puter.apps.get('task-list'); // your app's name
|
||||
@@ -68,6 +71,40 @@ when none is open and the user turned pushes on.
|
||||
to set pushes up. The `group` makes each morning's summary replace the one
|
||||
before it.
|
||||
|
||||
## Pass the Handler Inline
|
||||
|
||||
Without a deploy step, pass the function as `handler` and leave out
|
||||
`handlerName`. Puter stores the code in the signed-in user's own
|
||||
[user scope](/Events/handlers/#user-scope) of your app, and runs it for that
|
||||
user's schedule only:
|
||||
|
||||
```js
|
||||
await puter.events.onSchedule({
|
||||
cron: '0 8 * * 1-5',
|
||||
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
handler: async ({ user }) => {
|
||||
const tasks = (await user.kv.get('tasks')) ?? [];
|
||||
const open = tasks.filter((task) => !task.done);
|
||||
if (open.length === 0) return;
|
||||
|
||||
await user.notifications.notify({
|
||||
title: 'Good morning',
|
||||
text: `You have ${open.length} open tasks today.`,
|
||||
group: 'morning-summary',
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
A schedule keeps running the code it was created with. To change it, remove the
|
||||
schedule and create a new one, or publish a named handler into the user scope
|
||||
with `scope: 'user'` and bind schedules to it with `handlerScope: 'user'`. The
|
||||
handler counts toward the user's [limits](/rate-limits-and-quotas/#handlers) and
|
||||
bills to the user like their own workers, and creating the schedule needs credit
|
||||
on a hosted Puter server.
|
||||
[Clean Up Background Work](/recipes/clean-up-background-work/#remove-a-user-scope)
|
||||
covers removing what a user's scope holds.
|
||||
|
||||
## Ask for Permission
|
||||
|
||||
A schedule runs code while the user isn't there, so it needs the user's
|
||||
|
||||
@@ -25,8 +25,13 @@ import { prepareHandler } from './handlerSource.js';
|
||||
* rejects `auth_canceled` if the visitor closes it.
|
||||
*/
|
||||
|
||||
/** One name in one app. The same name means different code in another. */
|
||||
const baseKey = (appUid, name) => `${appUid ?? ''}|${name}`;
|
||||
/**
|
||||
* One name in one app, in one scope. The same name means different code in
|
||||
* another app, and in the user's scope of the same app.
|
||||
*/
|
||||
const keyPrefix = (appUid, scope) =>
|
||||
`${scope === 'user' ? 'user:' : ''}${appUid ?? ''}|`;
|
||||
const baseKey = (appUid, name, scope) => `${keyPrefix(appUid, scope)}${name}`;
|
||||
|
||||
const invalidName = () =>
|
||||
new PuterJSError(
|
||||
@@ -44,7 +49,7 @@ export class EventHandlers {
|
||||
* base a publish claims it is updating. Empty until this client has
|
||||
* published or listed, which is what makes a first publish
|
||||
* create-or-idempotent. Keyed by app as well as name because one
|
||||
* name means different code in two apps.
|
||||
* name means different code in two apps, and in the two scopes.
|
||||
* @type {Map<string, string>}
|
||||
*/
|
||||
this.known = new Map();
|
||||
@@ -67,7 +72,7 @@ export class EventHandlers {
|
||||
async publish (name, handler, options = {}) {
|
||||
const [published] = await this.#send(
|
||||
[{ name, handler, replace: options.replace }],
|
||||
options.appUid,
|
||||
options,
|
||||
'/events/handlers/publish',
|
||||
);
|
||||
return published;
|
||||
@@ -89,7 +94,7 @@ export class EventHandlers {
|
||||
'invalid_request',
|
||||
);
|
||||
}
|
||||
return this.#send(handlers, options.appUid, '/events/handlers/publishAll');
|
||||
return this.#send(handlers, options, '/events/handlers/publishAll');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,11 +109,16 @@ export class EventHandlers {
|
||||
this.module.puter,
|
||||
'/events/handlers/list',
|
||||
undefined,
|
||||
options.appUid ? { appUid: options.appUid } : undefined,
|
||||
options.appUid || options.scope
|
||||
? {
|
||||
...(options.appUid ? { appUid: options.appUid } : {}),
|
||||
...(options.scope ? { scope: options.scope } : {}),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const handlers = /** @type {HandlerSummary[]} */ (response.handlers ?? []);
|
||||
for ( const handler of handlers )
|
||||
this.known.set(baseKey(options.appUid, handler.name), handler.hash);
|
||||
this.known.set(baseKey(options.appUid, handler.name, options.scope), handler.hash);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
@@ -127,9 +137,10 @@ export class EventHandlers {
|
||||
await request(this.module.puter, '/events/handlers/remove', {
|
||||
name,
|
||||
...(options.appUid ? { appUid: options.appUid } : {}),
|
||||
...(options.scope ? { scope: options.scope } : {}),
|
||||
})
|
||||
);
|
||||
this.known.delete(baseKey(options.appUid, name));
|
||||
this.known.delete(baseKey(options.appUid, name, options.scope));
|
||||
return removed;
|
||||
}
|
||||
|
||||
@@ -140,10 +151,13 @@ export class EventHandlers {
|
||||
* too: those are an app token's own app, which is the only app it can
|
||||
* have emptied.
|
||||
* @param {string} appUid
|
||||
* @param {'app' | 'user'} [scope] The scope that was emptied.
|
||||
*/
|
||||
forget (appUid) {
|
||||
forget (appUid, scope) {
|
||||
const prefix = keyPrefix(appUid, scope);
|
||||
const implicit = keyPrefix(undefined, scope);
|
||||
for ( const key of [...this.known.keys()] ) {
|
||||
if ( key.startsWith(`${appUid}|`) || key.startsWith('|') )
|
||||
if ( key.startsWith(prefix) || key.startsWith(implicit) )
|
||||
this.known.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -152,18 +166,19 @@ export class EventHandlers {
|
||||
* @internal Serialize, scan and send one or more publications, then record
|
||||
* what is now published so the next publish can name its base.
|
||||
* @param {HandlerPublication[]} items
|
||||
* @param {string | undefined} appUid
|
||||
* @param {HandlerOptions} options
|
||||
* @param {string} route
|
||||
* @returns {Promise<PublishedHandler[]>}
|
||||
*/
|
||||
async #send (items, appUid, route) {
|
||||
async #send (items, options, route) {
|
||||
const { appUid, scope } = options;
|
||||
const handlers = [];
|
||||
for ( const item of items ) {
|
||||
const name = item?.name;
|
||||
if ( typeof name !== 'string' || name.trim().length === 0 ) throw invalidName();
|
||||
|
||||
const { source } = await prepareHandler(this.module.puter, item.handler);
|
||||
const ifHash = this.known.get(baseKey(appUid, name));
|
||||
const ifHash = this.known.get(baseKey(appUid, name, scope));
|
||||
handlers.push({
|
||||
name,
|
||||
source,
|
||||
@@ -174,6 +189,7 @@ export class EventHandlers {
|
||||
|
||||
const body = {
|
||||
...(appUid ? { appUid } : {}),
|
||||
...(scope ? { scope } : {}),
|
||||
...(handlers.length === 1 && route.endsWith('/publish')
|
||||
? handlers[0]
|
||||
: { handlers }),
|
||||
@@ -184,7 +200,7 @@ export class EventHandlers {
|
||||
Array.isArray(response.handlers) ? response.handlers : [response]
|
||||
);
|
||||
for ( const handler of published )
|
||||
this.known.set(baseKey(appUid, handler.name), handler.hash);
|
||||
this.known.set(baseKey(appUid, handler.name, scope), handler.hash);
|
||||
return published;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockRequest = vi.fn();
|
||||
vi.mock('./api.js', () => ({
|
||||
request: (...args) => mockRequest(...args),
|
||||
}));
|
||||
|
||||
const { EventHandlers } = await import('./handlers.js');
|
||||
|
||||
const SOURCE = '({ event }) => { console.log(event.path); }';
|
||||
|
||||
const makeHandlers = () =>
|
||||
new EventHandlers({ puter: { APIOrigin: 'https://api.test', fs: { read: vi.fn() } } });
|
||||
|
||||
const routeOf = (index = 0) => mockRequest.mock.calls[index][1];
|
||||
const bodyOf = (index = 0) => mockRequest.mock.calls[index][2];
|
||||
const queryOf = (index = 0) => mockRequest.mock.calls[index][3];
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest.mockReset();
|
||||
mockRequest.mockImplementation(async (_puter, route, body) => ({
|
||||
name: body?.name,
|
||||
hash: `hash-${body?.scope ?? 'app'}`,
|
||||
handlers: body?.handlers?.map((h) => ({ name: h.name, hash: `hash-${body.scope ?? 'app'}` })),
|
||||
}));
|
||||
});
|
||||
|
||||
describe('scope', () => {
|
||||
it('sends `scope: user` on publish, publishAll, list and remove', async () => {
|
||||
const handlers = makeHandlers();
|
||||
await handlers.publish('a', SOURCE, { appUid: 'app-1', scope: 'user' });
|
||||
await handlers.publishAll([{ name: 'a', handler: SOURCE }], { scope: 'user' });
|
||||
mockRequest.mockResolvedValueOnce({ handlers: [] });
|
||||
await handlers.list({ appUid: 'app-1', scope: 'user' });
|
||||
await handlers.remove('a', { appUid: 'app-1', scope: 'user' });
|
||||
|
||||
expect(bodyOf(0)).toMatchObject({ appUid: 'app-1', scope: 'user', name: 'a' });
|
||||
expect(routeOf(1)).toBe('/events/handlers/publishAll');
|
||||
expect(bodyOf(1).scope).toBe('user');
|
||||
expect(queryOf(2)).toEqual({ appUid: 'app-1', scope: 'user' });
|
||||
expect(bodyOf(3)).toEqual({ name: 'a', appUid: 'app-1', scope: 'user' });
|
||||
});
|
||||
|
||||
it('sends no scope by default', async () => {
|
||||
const handlers = makeHandlers();
|
||||
await handlers.publish('a', SOURCE, { appUid: 'app-1' });
|
||||
mockRequest.mockResolvedValueOnce({ handlers: [] });
|
||||
await handlers.list();
|
||||
await handlers.remove('a');
|
||||
|
||||
expect(bodyOf(0)).not.toHaveProperty('scope');
|
||||
expect(queryOf(1)).toBeUndefined();
|
||||
expect(bodyOf(2)).toEqual({ name: 'a' });
|
||||
});
|
||||
|
||||
it('does not mix a name published in the user scope with the app scope', async () => {
|
||||
const handlers = makeHandlers();
|
||||
await handlers.publish('a', SOURCE, { appUid: 'app-1', scope: 'user' });
|
||||
await handlers.publish('a', SOURCE, { appUid: 'app-1' });
|
||||
|
||||
// The app-scope publish has no base: only the user scope was published.
|
||||
expect(bodyOf(1)).not.toHaveProperty('ifHash');
|
||||
|
||||
await handlers.publish('a', SOURCE, { appUid: 'app-1', scope: 'user' });
|
||||
expect(bodyOf(2).ifHash).toBe('hash-user');
|
||||
await handlers.publish('a', SOURCE, { appUid: 'app-1' });
|
||||
expect(bodyOf(3).ifHash).toBe('hash-app');
|
||||
});
|
||||
|
||||
it('removes only the cached base of the scope it removed', async () => {
|
||||
const handlers = makeHandlers();
|
||||
await handlers.publish('a', SOURCE, { appUid: 'app-1', scope: 'user' });
|
||||
await handlers.publish('a', SOURCE, { appUid: 'app-1' });
|
||||
await handlers.remove('a', { appUid: 'app-1', scope: 'user' });
|
||||
|
||||
expect([...handlers.known.keys()]).toEqual(['app-1|a']);
|
||||
});
|
||||
|
||||
it('records a user-scope listing under the user key', async () => {
|
||||
const handlers = makeHandlers();
|
||||
mockRequest.mockResolvedValueOnce({ handlers: [{ name: 'a', hash: 'h' }] });
|
||||
await handlers.list({ appUid: 'app-1', scope: 'user' });
|
||||
expect([...handlers.known.keys()]).toEqual(['user:app-1|a']);
|
||||
});
|
||||
|
||||
it('forgets one scope of an app without touching the other', () => {
|
||||
const handlers = makeHandlers();
|
||||
handlers.known.set('app-1|a', 'h1');
|
||||
handlers.known.set('user:app-1|a', 'h2');
|
||||
handlers.forget('app-1', 'user');
|
||||
expect([...handlers.known.keys()]).toEqual(['app-1|a']);
|
||||
handlers.forget('app-1');
|
||||
expect([...handlers.known.keys()]).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,9 @@ export class EventsWorkers {
|
||||
|
||||
/**
|
||||
* The caller's own events workers: one per app it owns with at least one
|
||||
* published handler, or — for an app token — its own worker alone.
|
||||
* published handler, or — for an app token — its own worker alone. Items
|
||||
* carry `scope`: `'user'` items are the caller's own scopes inside an app,
|
||||
* which may belong to another developer.
|
||||
*
|
||||
* @param {EventsWorkersListOptions} [options]
|
||||
* @returns {Promise<EventsWorkerPage>}
|
||||
@@ -70,14 +72,19 @@ export class EventsWorkers {
|
||||
* the same as removing each by name — never deleted.
|
||||
*
|
||||
* @param {string} appUid
|
||||
* @param {{ scope?: 'app' | 'user' }} [options] `scope: 'user'` removes
|
||||
* this user's own scope of the app instead of the app's.
|
||||
* @returns {Promise<DestroyedEventsWorker>}
|
||||
*/
|
||||
async destroy (appUid) {
|
||||
async destroy (appUid, options = {}) {
|
||||
if ( typeof appUid !== 'string' || appUid.trim().length === 0 ) throw invalidAppUid();
|
||||
const destroyed = /** @type {DestroyedEventsWorker} */ (
|
||||
await request(this.module.puter, '/events/workers/destroy', { appUid })
|
||||
await request(this.module.puter, '/events/workers/destroy', {
|
||||
appUid,
|
||||
...(options?.scope ? { scope: options.scope } : {}),
|
||||
})
|
||||
);
|
||||
this.module.handlers?.forget(appUid);
|
||||
this.module.handlers?.forget(appUid, options?.scope);
|
||||
return destroyed;
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,18 @@ describe('list', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('list scopes', () => {
|
||||
it('returns app and user items as the server sends them', async () => {
|
||||
const items = [
|
||||
{ scope: 'app', appUid: 'app-1', handlerCount: 1, script: 's' },
|
||||
{ scope: 'user', appUid: 'app-2', appName: 'n', handlerCount: 2 },
|
||||
];
|
||||
mockRequest.mockResolvedValue({ items, cursor: 'user:x', deployable: true });
|
||||
const page = await makeModule().list();
|
||||
expect(page).toEqual({ items, cursor: 'user:x', deployable: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('posts appUid to /events/workers/destroy', async () => {
|
||||
mockRequest.mockResolvedValue({ appUid: 'app-1', removed: 2, suspended: 1 });
|
||||
@@ -69,6 +81,19 @@ describe('destroy', () => {
|
||||
expect(result).toEqual({ appUid: 'app-1', removed: 2, suspended: 1 });
|
||||
});
|
||||
|
||||
it('sends `scope: user` and reports it back', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
appUid: 'app-1',
|
||||
removed: 1,
|
||||
suspended: 0,
|
||||
scope: 'user',
|
||||
});
|
||||
const result = await makeModule().destroy('app-1', { scope: 'user' });
|
||||
|
||||
expect(bodyOf()).toEqual({ appUid: 'app-1', scope: 'user' });
|
||||
expect(result.scope).toBe('user');
|
||||
});
|
||||
|
||||
it('rejects client-side for a non-string appUid, without calling the server', async () => {
|
||||
for ( const bad of [undefined, '', ' ', 42] ) {
|
||||
await expect(makeModule().destroy(bad)).rejects.toMatchObject({
|
||||
@@ -88,4 +113,15 @@ describe('destroy', () => {
|
||||
|
||||
expect([...module.handlers.known.keys()]).toEqual(['app-2|c']);
|
||||
});
|
||||
|
||||
it('drops only the destroyed scope`s cached bases', async () => {
|
||||
const module = { puter: { APIOrigin: 'https://api.test' }, handlers: new EventHandlers({}) };
|
||||
module.handlers.known.set('app-1|a', 'hash-a');
|
||||
module.handlers.known.set('user:app-1|a', 'hash-u');
|
||||
module.handlers.known.set('user:app-2|a', 'hash-v');
|
||||
|
||||
await new EventsWorkers(module).destroy('app-1', { scope: 'user' });
|
||||
|
||||
expect([...module.handlers.known.keys()].sort()).toEqual(['app-1|a', 'user:app-2|a']);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,9 @@ import { resolveSubject } from './lib/resolveSubject.js';
|
||||
* and is also sent to every connected client. With `queue`, each event goes
|
||||
* to exactly one consumer instead.
|
||||
*
|
||||
* With a `handler` and no `handlerName`, the source is sent and stored in the
|
||||
* signed-in user's own scope, and runs for this user's events only.
|
||||
*
|
||||
* Passing a function as `handler` also runs it in this client for each
|
||||
* delivery it receives: the same body, the same `{ event, ctx }`, plus `user`,
|
||||
* `fetch` and — for a `queue` subscription — `ack`.
|
||||
@@ -37,33 +40,28 @@ export async function onPersistent (options = {}) {
|
||||
assertSubject(options?.subject);
|
||||
const subject = resolveSubject(options.subject, puter);
|
||||
const { handler, handlerName } = options;
|
||||
// An inline handler is source the server has to match against something it
|
||||
// already has, and a name is the only thing it can match against.
|
||||
if ( handler !== undefined && handler !== null && ! handlerName ) {
|
||||
throw new PuterJSError(
|
||||
'An inline `handler` needs a `handlerName` to publish it under',
|
||||
'events_handler_name_required',
|
||||
);
|
||||
}
|
||||
const hasHandler = handler !== undefined && handler !== null;
|
||||
// A queued delivery is owed to exactly one consumer, and the handler is
|
||||
// the only one always there to take it.
|
||||
if ( (options.queue === true || options.delivery === 'single') && ! handlerName ) {
|
||||
if ( (options.queue === true || options.delivery === 'single') && ! handlerName && ! hasHandler ) {
|
||||
throw new PuterJSError(
|
||||
'A `queue` subscription needs a `handlerName`',
|
||||
'A `queue` subscription needs a `handlerName` or an inline `handler`',
|
||||
'events_handler_required',
|
||||
);
|
||||
}
|
||||
|
||||
const inline = handler === undefined || handler === null
|
||||
? null
|
||||
: await prepareHandler(puter, handler);
|
||||
const inline = hasHandler ? await prepareHandler(puter, handler) : null;
|
||||
|
||||
const body = {
|
||||
subject,
|
||||
...(typeof options.queue === 'boolean' ? { queue: options.queue } : {}),
|
||||
...(options.delivery ? { delivery: options.delivery } : {}),
|
||||
...(handlerName ? { handlerName } : {}),
|
||||
...(inline ? { handlerHash: inline.hash } : {}),
|
||||
...(options.handlerScope ? { handlerScope: options.handlerScope } : {}),
|
||||
// Named: the hash checks it against what is published. Unnamed: the
|
||||
// source itself, which the server stores under its own hash.
|
||||
...(inline && handlerName ? { handlerHash: inline.hash } : {}),
|
||||
...(inline && ! handlerName ? { handlerSource: inline.source } : {}),
|
||||
...(options.expiresAt !== undefined && options.expiresAt !== null
|
||||
? { expiresAt: options.expiresAt }
|
||||
: {}),
|
||||
|
||||
@@ -127,13 +127,54 @@ describe('onPersistent', () => {
|
||||
expect(JSON.stringify(bodyOf())).not.toContain('fetch(');
|
||||
});
|
||||
|
||||
it('refuses an inline handler with no name to publish it under', async () => {
|
||||
const error = await rejects(() =>
|
||||
makeModule().onPersistent({ subject: SUBJECT, handler: HANDLER }),
|
||||
);
|
||||
it('sends an inline handler with no name as source, never as a hash', async () => {
|
||||
await makeModule().onPersistent({ subject: SUBJECT, handler: HANDLER });
|
||||
|
||||
expect(error.code).toBe('events_handler_name_required');
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
expect(bodyOf()).toEqual({
|
||||
subject: SUBJECT,
|
||||
handlerSource: Function.prototype.toString.call(HANDLER),
|
||||
});
|
||||
expect(bodyOf().handlerHash).toBeUndefined();
|
||||
expect(mockRequest.mock.calls[0][1]).toBe('/events/subscribe');
|
||||
});
|
||||
|
||||
it('sends the source whose hash the named path would send', async () => {
|
||||
const { hashSource } = await import('./lib/handlerSource.js');
|
||||
await makeModule().onPersistent({ subject: SUBJECT, handler: HANDLER });
|
||||
expect(await hashSource(bodyOf().handlerSource)).toBe(handlerHash);
|
||||
});
|
||||
|
||||
it('sends a `queue` subscription with an inline handler and no name', async () => {
|
||||
for ( const options of [{ queue: true }, { delivery: 'single' }] ) {
|
||||
mockRequest.mockClear();
|
||||
await makeModule().onPersistent({
|
||||
subject: SUBJECT,
|
||||
handler: HANDLER,
|
||||
...options,
|
||||
});
|
||||
expect(bodyOf().handlerSource).toBeTypeOf('string');
|
||||
expect(bodyOf().handlerName).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('passes `handlerScope` through for a named binding', async () => {
|
||||
await makeModule().onPersistent({
|
||||
subject: SUBJECT,
|
||||
handlerName: 'ingestUpload',
|
||||
handlerScope: 'user',
|
||||
});
|
||||
expect(bodyOf()).toEqual({
|
||||
subject: SUBJECT,
|
||||
handlerName: 'ingestUpload',
|
||||
handlerScope: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('still runs an inline function handler in this client', async () => {
|
||||
const module = makeModule();
|
||||
mockRequest.mockResolvedValueOnce({ subId: 'sub-1' });
|
||||
await module.onPersistent({ subject: SUBJECT, handler: HANDLER });
|
||||
expect(module.channel.registered.map((row) => row.subId)).toEqual(['sub-1']);
|
||||
});
|
||||
|
||||
it('rejects an inline handler that closes over something', async () => {
|
||||
|
||||
@@ -36,7 +36,8 @@ const assertScheduleId = (scheduleId) => {
|
||||
*
|
||||
* Give exactly one of `every`, `cron` or `at`. `handlerName` names the
|
||||
* published handler each run calls; passing `handler` too checks that it is
|
||||
* the source published under that name.
|
||||
* the source published under that name. With `handler` and no `handlerName`,
|
||||
* the source is sent and stored in the signed-in user's own scope.
|
||||
*
|
||||
* `context` is evaluated **here, now** and delivered to every run as a frozen
|
||||
* `ctx`. It can't be changed later: remove the schedule and create a new one.
|
||||
@@ -59,15 +60,10 @@ export async function onSchedule (options) {
|
||||
);
|
||||
}
|
||||
const { handler, handlerName } = options;
|
||||
if ( handler !== undefined && handler !== null && ! handlerName ) {
|
||||
const hasHandler = handler !== undefined && handler !== null;
|
||||
if ( ! handlerName && ! hasHandler ) {
|
||||
throw new PuterJSError(
|
||||
'An inline `handler` needs a `handlerName` to check it against',
|
||||
'events_handler_name_required',
|
||||
);
|
||||
}
|
||||
if ( ! handlerName ) {
|
||||
throw new PuterJSError(
|
||||
'A schedule needs a `handlerName`',
|
||||
'A schedule needs a `handlerName` or an inline `handler`',
|
||||
'events_handler_required',
|
||||
);
|
||||
}
|
||||
@@ -81,18 +77,20 @@ export async function onSchedule (options) {
|
||||
}
|
||||
}
|
||||
|
||||
const inline = handler === undefined || handler === null
|
||||
? null
|
||||
: await prepareHandler(puter, handler);
|
||||
const inline = hasHandler ? await prepareHandler(puter, handler) : null;
|
||||
const handlerHash = inline?.hash ?? options.handlerHash;
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
const body = { handlerName };
|
||||
const body = {};
|
||||
if ( handlerName ) body.handlerName = handlerName;
|
||||
if ( options.handlerScope ) body.handlerScope = options.handlerScope;
|
||||
for ( const key of TIMING_OPTIONS ) {
|
||||
if ( options[key] !== undefined && options[key] !== null )
|
||||
body[key] = options[key];
|
||||
}
|
||||
if ( handlerHash !== undefined && handlerHash !== null )
|
||||
// Unnamed: the source itself, which the server stores under its own hash.
|
||||
if ( inline && ! handlerName ) body.handlerSource = inline.source;
|
||||
else if ( handlerHash !== undefined && handlerHash !== null )
|
||||
body.handlerHash = handlerHash;
|
||||
// Serialized only to check it against the cap before the round trip.
|
||||
if ( serializeContext(options.context) !== undefined )
|
||||
|
||||
@@ -140,14 +140,36 @@ describe('onSchedule', () => {
|
||||
expect(bodyOf(1)).not.toHaveProperty('handlerHash');
|
||||
});
|
||||
|
||||
it('needs a handler name before it calls anything', async () => {
|
||||
it('sends an inline handler with no name as source, never as a hash', async () => {
|
||||
await makeModule().onSchedule({ every: '1h', handler: HANDLER });
|
||||
|
||||
expect(bodyOf()).toEqual({
|
||||
every: '1h',
|
||||
handlerSource: Function.prototype.toString.call(HANDLER),
|
||||
});
|
||||
});
|
||||
|
||||
it('passes `handlerScope` through for a named binding', async () => {
|
||||
await makeModule().onSchedule({
|
||||
every: '1h',
|
||||
handlerName: 'poll',
|
||||
handlerScope: 'user',
|
||||
});
|
||||
expect(bodyOf()).toEqual({
|
||||
every: '1h',
|
||||
handlerName: 'poll',
|
||||
handlerScope: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('needs a handler name or an inline handler before it calls anything', async () => {
|
||||
const unnamed = await rejects(() => makeModule().onSchedule({ every: '1h' }));
|
||||
expect(unnamed.code).toBe('events_handler_required');
|
||||
|
||||
const inline = await rejects(() =>
|
||||
makeModule().onSchedule({ every: '1h', handler: HANDLER }),
|
||||
const hashOnly = await rejects(() =>
|
||||
makeModule().onSchedule({ every: '1h', handlerHash: 'a'.repeat(64) }),
|
||||
);
|
||||
expect(inline.code).toBe('events_handler_name_required');
|
||||
expect(hashOnly.code).toBe('events_handler_required');
|
||||
|
||||
const none = await rejects(() => makeModule().onSchedule());
|
||||
expect(none.code).toBe('invalid_request');
|
||||
|
||||
@@ -204,18 +204,21 @@
|
||||
* takes, e.g. `fs:~/Documents` or `fs:~/inbox/*.json:add`.
|
||||
* @property {boolean} [queue] Work-queue delivery: each event goes to one
|
||||
* consumer at a time and is offered again until acknowledged. Requires a
|
||||
* `handlerName`. Without it, every event runs the app's published handler
|
||||
* `handlerName` or an inline `handler`. Without it, every event runs the app's published handler
|
||||
* in its events worker and also reaches every connected client, once each.
|
||||
* @property {'broadcast' | 'single'} [delivery] Deprecated: use `queue`.
|
||||
* `'single'` is the same as `queue: true`.
|
||||
* @property {unknown[]} [targets] Deprecated and ignored.
|
||||
* @property {string} [handlerName] The published handler this subscription
|
||||
* binds to. Required for `queue`.
|
||||
* binds to.
|
||||
* @property {'app' | 'user'} [handlerScope] Where `handlerName` is looked up.
|
||||
* `'user'` binds to a handler in the signed-in user's own scope.
|
||||
* @property {Function | string | { file: string }} [handler] The handler
|
||||
* source this subscription was written against. Sent as a hash, not as
|
||||
* source: the subscription binds only if it matches what is published under
|
||||
* `handlerName`, which is also required when this is given. A function also
|
||||
* runs in this client for each delivery it receives.
|
||||
* source. With a `handlerName`, it is sent as a hash, not as source: the
|
||||
* subscription binds only if it matches what is published under that name.
|
||||
* Without one, the source is sent and stored in the signed-in user's own
|
||||
* scope, and runs for this user's events only. A function also runs in this
|
||||
* client for each delivery it receives.
|
||||
* @property {Record<string, unknown>} [context] Values the handler needs,
|
||||
* evaluated **now** and delivered as a frozen `ctx` on every invocation.
|
||||
* Capped at 4 KB serialized.
|
||||
@@ -268,6 +271,8 @@
|
||||
* @property {boolean} includeValue Whether its `kv:` deliveries carry the
|
||||
* key's new value.
|
||||
* @property {string | null} handlerName The handler it is bound to.
|
||||
* @property {'user'} [handlerScope] Present when it is bound to a handler in
|
||||
* the user's own scope.
|
||||
* @property {string | null} appUid The app that created it, or `null` for one
|
||||
* an account session made.
|
||||
* @property {string[] | null} contextKeys Key names of its stored context,
|
||||
@@ -351,11 +356,15 @@
|
||||
* run, inclusive.
|
||||
* @property {number} [maxRuns] Stop after this many runs (1 to 100,000). With
|
||||
* `expiresAt` too, the earlier end wins.
|
||||
* @property {string} handlerName The published handler each run calls.
|
||||
* @property {string} [handlerName] The published handler each run calls.
|
||||
* Required unless `handler` is given.
|
||||
* @property {'app' | 'user'} [handlerScope] Where `handlerName` is looked up.
|
||||
* `'user'` binds to a handler in the signed-in user's own scope.
|
||||
* @property {ScheduleHandler | string | { file: string }} [handler] The handler
|
||||
* source this schedule was written against. Sent as a hash: the schedule is
|
||||
* refused unless it matches what is published under `handlerName`. It never
|
||||
* runs in this client.
|
||||
* source. With a `handlerName`, it is sent as a hash: the schedule is refused
|
||||
* unless it matches what is published under that name. Without one, the
|
||||
* source is sent and stored in the signed-in user's own scope. It never runs
|
||||
* in this client.
|
||||
* @property {string} [handlerHash] The SHA-256 hex digest of that source, in
|
||||
* place of `handler`.
|
||||
* @property {Record<string, unknown>} [context] Values the handler needs,
|
||||
@@ -429,6 +438,12 @@
|
||||
* | 'events_schedule_sending'
|
||||
* | 'events_schedule_busy'
|
||||
* | 'events_handler_required'
|
||||
* | 'events_handler_forbidden'
|
||||
* | 'events_handler_limit'
|
||||
* | 'events_handler_name_invalid'
|
||||
* | 'events_worker_too_large'
|
||||
* | 'events_user_handlers_disabled'
|
||||
* | 'insufficient_funds'
|
||||
* | 'events_handler_name_required'
|
||||
* | 'events_handler_invalid'
|
||||
* | 'events_handler_hash_unavailable'
|
||||
@@ -450,6 +465,8 @@
|
||||
* `events_handler_conflict`.
|
||||
* @property {string} [appUid] The app to publish into. Required when the
|
||||
* caller is an account session rather than an app.
|
||||
* @property {'app' | 'user'} [scope] `'user'` works on the signed-in user's own
|
||||
* handlers for the app instead of the app's. Default `'app'`.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -500,6 +517,8 @@
|
||||
* worker standing behind them.
|
||||
*
|
||||
* @typedef {Object} EventsWorkerSummary
|
||||
* @property {'app' | 'user'} scope Whose handlers it runs: the app's, or the
|
||||
* caller's own inside the app.
|
||||
* @property {string} appUid
|
||||
* @property {string} appName
|
||||
* @property {string} appTitle
|
||||
@@ -508,7 +527,8 @@
|
||||
* being — its earliest published handler. Unix seconds.
|
||||
* @property {number} updatedAt Its most recently published or updated handler.
|
||||
* Unix seconds.
|
||||
* @property {string} script The deployed script name, for support/diagnosis.
|
||||
* @property {string} [script] The deployed script name, for support/diagnosis.
|
||||
* Only on `scope: 'app'` items.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -528,6 +548,7 @@
|
||||
*
|
||||
* @typedef {Object} DestroyedEventsWorker
|
||||
* @property {string} appUid
|
||||
* @property {'user'} [scope] Present when a user scope was destroyed.
|
||||
* @property {number} removed How many handlers were deleted.
|
||||
* @property {number} suspended How many subscriptions were suspended as a
|
||||
* result, across all of them.
|
||||
|
||||
@@ -126,8 +126,14 @@ export const loadPuterJsTestOptions = (
|
||||
// on too: `watch` shares and `kv:<handle>` subjects need `kvHandles`,
|
||||
// and `read`/`write` shares and handle-addressed KV calls need
|
||||
// `kv.shareHandles`. The fold-in routes notifications through events,
|
||||
// which `onNotification` reads.
|
||||
events: { enabled: true, kvHandles: true, notificationsFoldIn: true },
|
||||
// which `onNotification` reads. User-scope handlers and inline creates
|
||||
// are behind `userHandlers`.
|
||||
events: {
|
||||
enabled: true,
|
||||
kvHandles: true,
|
||||
notificationsFoldIn: true,
|
||||
userHandlers: true,
|
||||
},
|
||||
kv: { shareHandles: true },
|
||||
};
|
||||
|
||||
|
||||
@@ -144,12 +144,13 @@ const asApp = async <T>(
|
||||
t: TestContext,
|
||||
appUid: string,
|
||||
run: () => Promise<T>,
|
||||
account: { token: string } = t.env.users.user,
|
||||
): Promise<T> => {
|
||||
const response = await fetch(`${t.env.apiOrigin}/auth/get-user-app-token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${t.env.users.user.token}`,
|
||||
authorization: `Bearer ${account.token}`,
|
||||
},
|
||||
body: JSON.stringify({ app_uid: appUid }),
|
||||
});
|
||||
@@ -664,14 +665,64 @@ export default suite('events', {
|
||||
|
||||
// -- Persistent subscriptions ------------------------------------
|
||||
|
||||
'refuses an inline handler with no name to publish it under': async (t) => {
|
||||
const error = await t.assert.rejects(() =>
|
||||
t.puter.events.onPersistent({
|
||||
subject: `fs:/${t.env.users.user.username}`,
|
||||
'creates an inline handler with no name, and binds the subscription to it': async (
|
||||
t,
|
||||
) => {
|
||||
const dir = await makeDir(t, 'events-inline');
|
||||
const appUid = await makeApp(t);
|
||||
await t.puter.perms.grantApp(appUid, `fs:${dir}:list`);
|
||||
await t.puter.perms.grantApp(appUid, 'events:background');
|
||||
|
||||
await asApp(t, appUid, async () => {
|
||||
const sub = await t.puter.events.onPersistent({
|
||||
subject: `fs:${dir}`,
|
||||
handler: HANDLER,
|
||||
}),
|
||||
);
|
||||
t.assert.equal(codeOf(error), 'events_handler_name_required');
|
||||
});
|
||||
t.assert.ok(
|
||||
/^@[0-9a-f]{64}$/.test(sub.handlerName ?? ''),
|
||||
`the server names it by its hash, got ${sub.handlerName}`,
|
||||
);
|
||||
t.assert.equal(
|
||||
(sub as { handlerScope?: string }).handlerScope,
|
||||
'user',
|
||||
);
|
||||
|
||||
// The same source again binds to the same handler.
|
||||
const again = await t.puter.events.onPersistent({
|
||||
subject: `fs:${dir}/other`,
|
||||
queue: true,
|
||||
handler: HANDLER,
|
||||
});
|
||||
t.assert.equal(again.handlerName, sub.handlerName);
|
||||
t.assert.equal(again.queue, true);
|
||||
|
||||
await sub.off!();
|
||||
await again.off!();
|
||||
});
|
||||
},
|
||||
|
||||
'creates an inline schedule with no handler name': async (t) => {
|
||||
const appUid = await makeApp(t);
|
||||
await t.puter.perms.grantApp(appUid, 'events:background');
|
||||
|
||||
await asApp(t, appUid, async () => {
|
||||
let schedule;
|
||||
try {
|
||||
schedule = await t.puter.events.onSchedule({
|
||||
every: '1h',
|
||||
handler: HANDLER,
|
||||
});
|
||||
} catch (error) {
|
||||
// Without a schedules service every schedule call is refused.
|
||||
if (codeOf(error) === 'events_disabled') return;
|
||||
throw error;
|
||||
}
|
||||
t.assert.ok(
|
||||
/^@[0-9a-f]{64}$/.test(schedule.handlerName ?? ''),
|
||||
`the server names it by its hash, got ${schedule.handlerName}`,
|
||||
);
|
||||
await schedule.off();
|
||||
});
|
||||
},
|
||||
|
||||
'refuses a handler that closes over something it cannot carry': async (t) => {
|
||||
@@ -1115,6 +1166,85 @@ export default suite('events', {
|
||||
|
||||
// -- Events workers -----------------------------------------------
|
||||
|
||||
'publishes, lists and destroys a handler in the user scope': async (t) => {
|
||||
const appUid = await makeApp(t);
|
||||
|
||||
await asApp(t, appUid, async () => {
|
||||
const published = await t.puter.events.handlers.publish(
|
||||
'mine',
|
||||
HANDLER,
|
||||
{ scope: 'user' },
|
||||
);
|
||||
t.assert.equal(published.name, 'mine');
|
||||
t.assert.equal(published.outcome, 'created');
|
||||
|
||||
const listed = await t.puter.events.handlers.list({ scope: 'user' });
|
||||
t.assert.deepEqual(
|
||||
listed.map((row) => row.name),
|
||||
['mine'],
|
||||
);
|
||||
t.assert.deepEqual(
|
||||
await t.puter.events.handlers.list(),
|
||||
[],
|
||||
'the app scope does not see it',
|
||||
);
|
||||
|
||||
const page = await t.puter.events.workers.list();
|
||||
const worker = page.items.find(
|
||||
(row) => row.scope === 'user' && row.appUid === appUid,
|
||||
);
|
||||
t.assert.ok(worker, 'the user scope is listed with its scope');
|
||||
t.assert.equal(worker?.handlerCount, 1);
|
||||
|
||||
const destroyed = await t.puter.events.workers.destroy(appUid, {
|
||||
scope: 'user',
|
||||
});
|
||||
t.assert.equal(destroyed.removed, 1);
|
||||
t.assert.deepEqual(
|
||||
await t.puter.events.handlers.list({ scope: 'user' }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
'keeps one user\'s scope out of another\'s': async (t) => {
|
||||
const appUid = await makeApp(t);
|
||||
|
||||
await asApp(t, appUid, () =>
|
||||
t.puter.events.handlers.publish('mine', HANDLER, { scope: 'user' }),
|
||||
);
|
||||
|
||||
await asApp(
|
||||
t,
|
||||
appUid,
|
||||
async () => {
|
||||
t.assert.deepEqual(
|
||||
await t.puter.events.handlers.list({ scope: 'user' }),
|
||||
[],
|
||||
'the other user sees none of it',
|
||||
);
|
||||
const page = await t.puter.events.workers.list();
|
||||
t.assert.equal(
|
||||
page.items.find((row) => row.appUid === appUid),
|
||||
undefined,
|
||||
);
|
||||
const removed = await t.puter.events.handlers.remove('mine', {
|
||||
scope: 'user',
|
||||
});
|
||||
t.assert.equal(removed.removed, false);
|
||||
},
|
||||
t.env.users.other,
|
||||
);
|
||||
|
||||
await asApp(t, appUid, async () => {
|
||||
t.assert.equal(
|
||||
(await t.puter.events.handlers.list({ scope: 'user' })).length,
|
||||
1,
|
||||
);
|
||||
await t.puter.events.workers.destroy(appUid, { scope: 'user' });
|
||||
});
|
||||
},
|
||||
|
||||
'exposes the workers surface': async (t) => {
|
||||
for (const method of ['list', 'destroy'] as const) {
|
||||
t.assert.equal(
|
||||
|
||||
Reference in new issue
Block a user