@ultimabase/maintenance 0.0.0-stage → 0.4.0-canary.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +104 -0
- package/dist/state.d.ts +11 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +9 -0
- package/package.json +41 -3
- package/src/index.ts +168 -0
- package/src/state.ts +18 -0
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ultimabase/maintenance
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Maintenance for UltimaBase. While it is on, every server answers each request and WebSocket upgrade with 503 `MAINTENANCE` and `Retry-After`, and closes the sockets open with 1012; Master's requests still pass.
|
|
4
|
+
|
|
5
|
+
Documentation: [Maintenance](https://ultimabase.dev/docs/maintenance).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Http } from '@ultimabase/http';
|
|
2
|
+
import type { PluginOf, PluginTypes } from 'ultimabase';
|
|
3
|
+
/** What `maintenancePlugin` takes. */
|
|
4
|
+
export interface MaintenanceOptions {
|
|
5
|
+
/** In milliseconds, how long a client waits before it tries again: 60 000 by default. */
|
|
6
|
+
readonly retryAfter?: number;
|
|
7
|
+
}
|
|
8
|
+
/** What the maintenance plugin offers other plugins. */
|
|
9
|
+
export interface MaintenanceApi {
|
|
10
|
+
/** Begins the maintenance on every server, once stored. */
|
|
11
|
+
lock(): Promise<void>;
|
|
12
|
+
/** Ends it on every server, once stored. */
|
|
13
|
+
unlock(): Promise<void>;
|
|
14
|
+
/** Whether this server is in maintenance. */
|
|
15
|
+
locked(): boolean;
|
|
16
|
+
}
|
|
17
|
+
/** The maintenance plugin's types (D-57). */
|
|
18
|
+
export interface Maintenance extends PluginTypes {
|
|
19
|
+
readonly name: 'maintenance';
|
|
20
|
+
readonly appOptions: MaintenanceOptions;
|
|
21
|
+
readonly requires: Http;
|
|
22
|
+
readonly api: MaintenanceApi;
|
|
23
|
+
readonly errors: 'MAINTENANCE';
|
|
24
|
+
}
|
|
25
|
+
/** Maintenance, on every server at once (D-205). */
|
|
26
|
+
export declare const maintenancePlugin: PluginOf<Maintenance>;
|
|
27
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAA;AAE5C,OAAO,KAAK,EAAU,QAAQ,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAK/D,sCAAsC;AACtC,MAAM,WAAW,kBAAkB;IAClC,yFAAyF;IACzF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED,wDAAwD;AACxD,MAAM,WAAW,cAAc;IAC9B,2DAA2D;IAC3D,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,4CAA4C;IAC5C,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACvB,6CAA6C;IAC7C,MAAM,IAAI,OAAO,CAAA;CACjB;AAED,6CAA6C;AAC7C,MAAM,WAAW,WAAY,SAAQ,WAAW;IAC/C,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAA;IAC5B,QAAQ,CAAC,UAAU,EAAE,kBAAkB,CAAA;IACvC,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAA;IACvB,QAAQ,CAAC,GAAG,EAAE,cAAc,CAAA;IAC5B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAA;CAC9B;AAqID,oDAAoD;AACpD,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,WAAW,CAAsB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { httpPlugin, routes } from '@ultimabase/http';
|
|
2
|
+
import { definePlugin, master, PublicError } from 'ultimabase';
|
|
3
|
+
import manifest from '../package.json' with { type: 'json' };
|
|
4
|
+
import { MaintenanceState } from './state.js';
|
|
5
|
+
/** The route Master turns it on and off with, which it never refuses. */
|
|
6
|
+
const path = '/_ultimabase/maintenance';
|
|
7
|
+
const key = 'state';
|
|
8
|
+
/** Follows the stored state: as it begins, the sockets open close. */
|
|
9
|
+
function follow(state, locked) {
|
|
10
|
+
if (locked && !state.locked)
|
|
11
|
+
state.close();
|
|
12
|
+
state.locked = locked;
|
|
13
|
+
}
|
|
14
|
+
async function stored() {
|
|
15
|
+
const row = await MaintenanceState.findUnique({ where: { key } }, { as: master });
|
|
16
|
+
return row?.enabled ?? false;
|
|
17
|
+
}
|
|
18
|
+
async function store(state, locked) {
|
|
19
|
+
await MaintenanceState.upsert({
|
|
20
|
+
where: { key },
|
|
21
|
+
create: { key, enabled: locked },
|
|
22
|
+
update: { enabled: locked },
|
|
23
|
+
}, { as: master });
|
|
24
|
+
follow(state, locked);
|
|
25
|
+
}
|
|
26
|
+
/** Reads the state again; keeps the last one when it cannot. */
|
|
27
|
+
async function reload(state) {
|
|
28
|
+
try {
|
|
29
|
+
follow(state, await stored());
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
state.logger.error({ err: error }, 'The maintenance state could not be read: it stays as it was.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const states = new WeakMap();
|
|
36
|
+
function stateOf(context) {
|
|
37
|
+
const state = states.get(context);
|
|
38
|
+
if (state === undefined)
|
|
39
|
+
throw new Error('The maintenance plugin has no api.');
|
|
40
|
+
return state;
|
|
41
|
+
}
|
|
42
|
+
/** The body of `PUT /_ultimabase/maintenance`, `{ enabled }`. */
|
|
43
|
+
function enabledIn(body) {
|
|
44
|
+
const enabled = typeof body === 'object' && body !== null
|
|
45
|
+
? Reflect.get(body, 'enabled')
|
|
46
|
+
: undefined;
|
|
47
|
+
if (typeof enabled !== 'boolean') {
|
|
48
|
+
throw new PublicError('VALIDATION_FAILED', 'The body is { "enabled": true } or { "enabled": false }.');
|
|
49
|
+
}
|
|
50
|
+
return enabled;
|
|
51
|
+
}
|
|
52
|
+
const maintenance = definePlugin({
|
|
53
|
+
name: 'maintenance',
|
|
54
|
+
manifest,
|
|
55
|
+
requires: { http: httpPlugin },
|
|
56
|
+
models: [MaintenanceState],
|
|
57
|
+
api: (context) => {
|
|
58
|
+
const { plugins, options, identify, logger } = context;
|
|
59
|
+
const state = {
|
|
60
|
+
locked: false,
|
|
61
|
+
close: () => plugins.http.closeUpgrades({ code: 1012, reason: 'Maintenance' }),
|
|
62
|
+
logger,
|
|
63
|
+
};
|
|
64
|
+
states.set(context, state);
|
|
65
|
+
const seconds = Math.ceil((options.retryAfter ?? 60_000) / 1000);
|
|
66
|
+
plugins.http.gate(async (request) => {
|
|
67
|
+
if (!state.locked || new URL(request.url).pathname === path) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const caller = await identify(request).catch(() => undefined);
|
|
71
|
+
if (caller?.kind === 'master')
|
|
72
|
+
return undefined;
|
|
73
|
+
return Response.json({
|
|
74
|
+
code: 'MAINTENANCE',
|
|
75
|
+
message: 'The application is under maintenance.',
|
|
76
|
+
details: { retryAfter: seconds },
|
|
77
|
+
}, { status: 503, headers: { 'retry-after': String(seconds) } });
|
|
78
|
+
});
|
|
79
|
+
plugins.http.mount(routes().put(path, async (c) => {
|
|
80
|
+
if (c.var.caller.kind !== 'master') {
|
|
81
|
+
throw new PublicError('ACCESS_DENIED', 'Only the master key turns the maintenance on or off.');
|
|
82
|
+
}
|
|
83
|
+
const enabled = enabledIn(await c.req.json().catch(() => undefined));
|
|
84
|
+
await store(state, enabled);
|
|
85
|
+
return c.json({ enabled });
|
|
86
|
+
}));
|
|
87
|
+
return {
|
|
88
|
+
lock: () => store(state, true),
|
|
89
|
+
unlock: () => store(state, false),
|
|
90
|
+
locked: () => state.locked,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
start: async (context) => {
|
|
94
|
+
stateOf(context).locked = await stored();
|
|
95
|
+
},
|
|
96
|
+
changed: (changes, context) => {
|
|
97
|
+
if (changes.some((change) => change.model === MaintenanceState.name)) {
|
|
98
|
+
void reload(stateOf(context));
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
missed: (context) => void reload(stateOf(context)),
|
|
102
|
+
});
|
|
103
|
+
/** Maintenance, on every server at once (D-205). */
|
|
104
|
+
export const maintenancePlugin = maintenance.plugin;
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Field, ModelOf } from 'ultimabase';
|
|
2
|
+
type StateFields = {
|
|
3
|
+
readonly key: Field<string, {
|
|
4
|
+
readonly unique: true;
|
|
5
|
+
}>;
|
|
6
|
+
readonly enabled: Field<boolean, {}>;
|
|
7
|
+
};
|
|
8
|
+
/** maintenance's own model (D-110). */
|
|
9
|
+
export declare const MaintenanceState: ModelOf<'MaintenanceState', StateFields, []>;
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AAGhD,KAAK,WAAW,GAAG;IAClB,QAAQ,CAAC,GAAG,EAAE,KAAK,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAA;KAAE,CAAC,CAAA;IACtD,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,EAAE,CAAC,CAAA;CACpC,CAAA;AAED,uCAAuC;AACvC,eAAO,MAAM,gBAAgB,EAAE,OAAO,CAAC,kBAAkB,EAAE,WAAW,EAAE,EAAE,CAOvE,CAAA"}
|
package/dist/state.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimabase/maintenance",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.4.0-canary.0",
|
|
4
|
+
"description": "Maintenance for UltimaBase: every server answers 503 and closes its WebSockets until it ends, Master's requests aside.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"homepage": "https://ultimabase.dev/docs/maintenance",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/MrEkinox/ultimabase.git",
|
|
11
|
+
"directory": "packages/maintenance"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=24.7.0"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"!src/**/*.test.ts",
|
|
23
|
+
"!src/test"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"prepack": "rm -rf dist && yarn run -T tsc --project tsconfig.build.json"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@ultimabase/http": "^0.3.0",
|
|
35
|
+
"ultimabase": "^0.3.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@ultimabase/http": "0.3.0",
|
|
39
|
+
"@ultimabase/testing": "0.3.0",
|
|
40
|
+
"ultimabase": "0.3.0",
|
|
41
|
+
"vite": "8.3.4",
|
|
42
|
+
"vitest": "5.0.5"
|
|
43
|
+
}
|
|
6
44
|
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Maintenance (D-205): while it is on, every request and WebSocket upgrade but
|
|
2
|
+
// Master's answers 503 `MAINTENANCE`, and the sockets open close with 1012.
|
|
3
|
+
// Its state is a row: every server follows it through the change feed.
|
|
4
|
+
import type { Http } from '@ultimabase/http'
|
|
5
|
+
import { httpPlugin, routes } from '@ultimabase/http'
|
|
6
|
+
import type { Logger, PluginOf, PluginTypes } from 'ultimabase'
|
|
7
|
+
import { definePlugin, master, PublicError } from 'ultimabase'
|
|
8
|
+
import manifest from '../package.json' with { type: 'json' }
|
|
9
|
+
import { MaintenanceState } from './state.ts'
|
|
10
|
+
|
|
11
|
+
/** What `maintenancePlugin` takes. */
|
|
12
|
+
export interface MaintenanceOptions {
|
|
13
|
+
/** In milliseconds, how long a client waits before it tries again: 60 000 by default. */
|
|
14
|
+
readonly retryAfter?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** What the maintenance plugin offers other plugins. */
|
|
18
|
+
export interface MaintenanceApi {
|
|
19
|
+
/** Begins the maintenance on every server, once stored. */
|
|
20
|
+
lock(): Promise<void>
|
|
21
|
+
/** Ends it on every server, once stored. */
|
|
22
|
+
unlock(): Promise<void>
|
|
23
|
+
/** Whether this server is in maintenance. */
|
|
24
|
+
locked(): boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The maintenance plugin's types (D-57). */
|
|
28
|
+
export interface Maintenance extends PluginTypes {
|
|
29
|
+
readonly name: 'maintenance'
|
|
30
|
+
readonly appOptions: MaintenanceOptions
|
|
31
|
+
readonly requires: Http
|
|
32
|
+
readonly api: MaintenanceApi
|
|
33
|
+
readonly errors: 'MAINTENANCE'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The route Master turns it on and off with, which it never refuses. */
|
|
37
|
+
const path = '/_ultimabase/maintenance'
|
|
38
|
+
const key = 'state'
|
|
39
|
+
|
|
40
|
+
/** This server's state, and how it closes the sockets open. */
|
|
41
|
+
interface State {
|
|
42
|
+
locked: boolean
|
|
43
|
+
readonly close: () => void
|
|
44
|
+
readonly logger: Logger
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Follows the stored state: as it begins, the sockets open close. */
|
|
48
|
+
function follow(state: State, locked: boolean): void {
|
|
49
|
+
if (locked && !state.locked) state.close()
|
|
50
|
+
state.locked = locked
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function stored(): Promise<boolean> {
|
|
54
|
+
const row = await MaintenanceState.findUnique(
|
|
55
|
+
{ where: { key } },
|
|
56
|
+
{ as: master },
|
|
57
|
+
)
|
|
58
|
+
return row?.enabled ?? false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function store(state: State, locked: boolean): Promise<void> {
|
|
62
|
+
await MaintenanceState.upsert(
|
|
63
|
+
{
|
|
64
|
+
where: { key },
|
|
65
|
+
create: { key, enabled: locked },
|
|
66
|
+
update: { enabled: locked },
|
|
67
|
+
},
|
|
68
|
+
{ as: master },
|
|
69
|
+
)
|
|
70
|
+
follow(state, locked)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reads the state again; keeps the last one when it cannot. */
|
|
74
|
+
async function reload(state: State): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
follow(state, await stored())
|
|
77
|
+
} catch (error) {
|
|
78
|
+
state.logger.error(
|
|
79
|
+
{ err: error },
|
|
80
|
+
'The maintenance state could not be read: it stays as it was.',
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const states = new WeakMap<object, State>()
|
|
86
|
+
function stateOf(context: object): State {
|
|
87
|
+
const state = states.get(context)
|
|
88
|
+
if (state === undefined) throw new Error('The maintenance plugin has no api.')
|
|
89
|
+
return state
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The body of `PUT /_ultimabase/maintenance`, `{ enabled }`. */
|
|
93
|
+
function enabledIn(body: unknown): boolean {
|
|
94
|
+
const enabled: unknown =
|
|
95
|
+
typeof body === 'object' && body !== null
|
|
96
|
+
? Reflect.get(body, 'enabled')
|
|
97
|
+
: undefined
|
|
98
|
+
if (typeof enabled !== 'boolean') {
|
|
99
|
+
throw new PublicError(
|
|
100
|
+
'VALIDATION_FAILED',
|
|
101
|
+
'The body is { "enabled": true } or { "enabled": false }.',
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
return enabled
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const maintenance = definePlugin<Maintenance>({
|
|
108
|
+
name: 'maintenance',
|
|
109
|
+
manifest,
|
|
110
|
+
requires: { http: httpPlugin },
|
|
111
|
+
models: [MaintenanceState],
|
|
112
|
+
api: (context) => {
|
|
113
|
+
const { plugins, options, identify, logger } = context
|
|
114
|
+
const state: State = {
|
|
115
|
+
locked: false,
|
|
116
|
+
close: () =>
|
|
117
|
+
plugins.http.closeUpgrades({ code: 1012, reason: 'Maintenance' }),
|
|
118
|
+
logger,
|
|
119
|
+
}
|
|
120
|
+
states.set(context, state)
|
|
121
|
+
const seconds = Math.ceil((options.retryAfter ?? 60_000) / 1000)
|
|
122
|
+
plugins.http.gate(async (request) => {
|
|
123
|
+
if (!state.locked || new URL(request.url).pathname === path) {
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
const caller = await identify(request).catch(() => undefined)
|
|
127
|
+
if (caller?.kind === 'master') return undefined
|
|
128
|
+
return Response.json(
|
|
129
|
+
{
|
|
130
|
+
code: 'MAINTENANCE',
|
|
131
|
+
message: 'The application is under maintenance.',
|
|
132
|
+
details: { retryAfter: seconds },
|
|
133
|
+
},
|
|
134
|
+
{ status: 503, headers: { 'retry-after': String(seconds) } },
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
plugins.http.mount(
|
|
138
|
+
routes().put(path, async (c) => {
|
|
139
|
+
if (c.var.caller.kind !== 'master') {
|
|
140
|
+
throw new PublicError(
|
|
141
|
+
'ACCESS_DENIED',
|
|
142
|
+
'Only the master key turns the maintenance on or off.',
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
const enabled = enabledIn(await c.req.json().catch(() => undefined))
|
|
146
|
+
await store(state, enabled)
|
|
147
|
+
return c.json({ enabled })
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
return {
|
|
151
|
+
lock: () => store(state, true),
|
|
152
|
+
unlock: () => store(state, false),
|
|
153
|
+
locked: () => state.locked,
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
start: async (context) => {
|
|
157
|
+
stateOf(context).locked = await stored()
|
|
158
|
+
},
|
|
159
|
+
changed: (changes, context) => {
|
|
160
|
+
if (changes.some((change) => change.model === MaintenanceState.name)) {
|
|
161
|
+
void reload(stateOf(context))
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
missed: (context) => void reload(stateOf(context)),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
/** Maintenance, on every server at once (D-205). */
|
|
168
|
+
export const maintenancePlugin: PluginOf<Maintenance> = maintenance.plugin
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The maintenance's one row (D-110, D-205), which no transport exposes.
|
|
2
|
+
import type { Field, ModelOf } from 'ultimabase'
|
|
3
|
+
import { defineModel } from 'ultimabase'
|
|
4
|
+
|
|
5
|
+
type StateFields = {
|
|
6
|
+
readonly key: Field<string, { readonly unique: true }>
|
|
7
|
+
readonly enabled: Field<boolean, {}>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** maintenance's own model (D-110). */
|
|
11
|
+
export const MaintenanceState: ModelOf<'MaintenanceState', StateFields, []> =
|
|
12
|
+
defineModel({
|
|
13
|
+
name: 'MaintenanceState',
|
|
14
|
+
fields: (f) => ({
|
|
15
|
+
key: f.string({ unique: true }),
|
|
16
|
+
enabled: f.boolean(),
|
|
17
|
+
}),
|
|
18
|
+
})
|