letopis 1.4.1 → 2.0.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/AGENT-CHEATSHEET.md +329 -278
- package/CHANGELOG.md +558 -1
- package/README.md +606 -1037
- package/dist/acl.d.ts +78 -1
- package/dist/acl.js +264 -13
- package/dist/admin.d.ts +10 -141
- package/dist/admin.js +24 -175
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +5 -15
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +33 -10
- package/dist/chain.d.ts +19 -41
- package/dist/chain.js +169 -201
- package/dist/cli.js +51 -88
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +59 -13
- package/dist/errors.js +190 -29
- package/dist/index.d.ts +52 -71
- package/dist/index.js +245 -130
- package/dist/internal.d.ts +12 -0
- package/dist/internal.js +5 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +20 -12
- package/dist/load.js +225 -186
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +171 -215
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +171 -0
- package/dist/messages/api.js +215 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +95 -0
- package/dist/messages/index.js +118 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +233 -0
- package/dist/messages/model.js +283 -0
- package/dist/messages/query.d.ts +131 -0
- package/dist/messages/query.js +158 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +150 -0
- package/dist/messages/sql-core.js +181 -0
- package/dist/messages/sql-time.d.ts +71 -0
- package/dist/messages/sql-time.js +88 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +405 -0
- package/dist/messages/write.js +509 -0
- package/dist/model.d.ts +73 -60
- package/dist/model.js +26 -29
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +5 -9
- package/dist/signals.js +5 -9
- package/dist/sql.d.ts +13 -6
- package/dist/sql.js +176 -83
- package/dist/sync.d.ts +9 -3
- package/dist/sync.js +51 -21
- package/dist/tx.d.ts +26 -1
- package/dist/tx.js +44 -14
- package/dist/typed.d.ts +240 -45
- package/dist/types.d.ts +23 -91
- package/dist/types.js +12 -14
- package/dist/up.d.ts +53 -32
- package/dist/up.js +184 -73
- package/dist/uuid.js +5 -5
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +61 -29
- package/dist/watch.d.ts +11 -10
- package/dist/watch.js +33 -70
- package/dist/write.d.ts +9 -16
- package/dist/write.js +118 -187
- package/llms.txt +22 -25
- package/package.json +8 -11
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +7 -6
- package/sql/30-validate.sql +35 -27
- package/sql/40-class.sql +20 -114
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -667
- package/sql/70-read.sql +20 -16
- package/sql/80-auth.sql +24 -27
- package/sql/90-time.sql +70 -859
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +2 -1
- package/sql/README.md +32 -24
- package/sql/migrate/README.md +14 -0
- package/AGENT-CHEATSHEET.en.md +0 -485
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2859
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -350
- package/dist/project.js +0 -1224
- package/sql/75-history.sql +0 -358
- package/sql/88-derived.sql +0 -1235
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/index.js
CHANGED
|
@@ -5,64 +5,62 @@
|
|
|
5
5
|
* Этап 2: connect() — подключение под сессией, реестр классов, отложенный сигнал, db.sql.
|
|
6
6
|
* Этап 3: цепочки чтения t.Класс(…).Связь().rows(); этап 4 — запись цепочками и загрузчик.
|
|
7
7
|
* Этап 5: db.auth (креды, вход, сессии), db.acl, db.as(аккаунт, { tenant }) — имперсонация.
|
|
8
|
-
* Этап 6: история (
|
|
8
|
+
* Этап 6: история (versions, asOf, restore), maintain, watch, кэш результатов.
|
|
9
9
|
*/
|
|
10
10
|
import postgres from 'postgres';
|
|
11
|
-
import { LetopisError, fromDbError,
|
|
11
|
+
import { LetopisError, checkOptions, fromDbError, lerr, lwarn } from './errors.js';
|
|
12
12
|
import { Registry } from './registry.js';
|
|
13
13
|
import { AclPasses, ExplicitTx, Notifier, poolExecutor, runTx } from './tx.js';
|
|
14
14
|
import { checkReason } from './write.js';
|
|
15
|
-
import { registerDb } from './internal.js';
|
|
15
|
+
import { limits, registerDb } from './internal.js';
|
|
16
16
|
import { makeAuth } from './auth.js';
|
|
17
17
|
import { makeAcl } from './acl.js';
|
|
18
18
|
import { idOf as idOfClass } from './uuid.js';
|
|
19
|
-
import { load } from './load.js';
|
|
19
|
+
import { load, LOAD_OPTIONS } from './load.js';
|
|
20
20
|
import { makeBatch, startEntity, startStep } from './chain.js';
|
|
21
|
+
import { or as orFilters } from './ops.js';
|
|
21
22
|
import { DDL_REVISION } from './types.js';
|
|
22
23
|
import { ACL_CLASSES, ResultCache } from './cache.js';
|
|
23
24
|
import { Subscription } from './watch.js';
|
|
24
|
-
import { Projector } from './project.js';
|
|
25
25
|
import { SIGNAL_MODES, Signals, bucket, throttle } from './signals.js';
|
|
26
|
-
import {
|
|
27
|
-
import { stampDrift } from './up.js';
|
|
26
|
+
import { maintain as runMaintain, parseInterval, scheduleMaintain, } from './admin.js';
|
|
27
|
+
import { refuse1x, stampDrift } from './up.js';
|
|
28
28
|
export { DDL_REVISION } from './types.js';
|
|
29
29
|
export { uuidv5, v5Name, v5Id, idOf } from './uuid.js';
|
|
30
30
|
export { LetopisError, ValidationError, fromDbError } from './errors.js';
|
|
31
31
|
export { Registry } from './registry.js';
|
|
32
32
|
export { compileValidator, applyDefaults } from './validate.js';
|
|
33
33
|
export { syncModels, generateTypes, schemaToTs } from './sync.js';
|
|
34
|
-
export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not,
|
|
34
|
+
export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, isOp } from './ops.js';
|
|
35
|
+
/**
|
|
36
|
+
* Хотя бы один из фильтров: `db.Product(or({ sku: 'K-1' }, { price: lt(500) }))` — товар K-1 или дешевле 500. Ставится
|
|
37
|
+
* вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и». В шаге подключения
|
|
38
|
+
* с моделями (`TypedDb`) фильтры внутри — поля и роли класса шага: тип им даёт шаг (план 2.0, этап 4, п. 1). Та же
|
|
39
|
+
* фабрика, что в реестре операторов (ops.ts), — другой у неё только тип.
|
|
40
|
+
*/
|
|
41
|
+
export const or = orFilters;
|
|
35
42
|
export { cursorOf } from './sql.js';
|
|
36
43
|
export { inc, isInc } from './write.js';
|
|
37
44
|
export { totpCode, hashPassword, verifyHash } from './auth.js';
|
|
38
|
-
export { describeText } from './admin.js';
|
|
39
45
|
export { up, createTenant } from './up.js';
|
|
40
46
|
export { RESERVED_CLASS_NAMES, CHAIN_METHODS, DB_METHODS } from './types.js';
|
|
41
|
-
/** Снесено в 1.0.0: id v7 больше не выдаются (§2.3). */
|
|
42
|
-
export function uuidv7() {
|
|
43
|
-
throw removed('uuidv7()', 'uuidv5() и idOf(): id объекта вычисляется из ключа');
|
|
44
|
-
}
|
|
45
|
-
/** Снесено в 1.0.0: пространство имён v5 — id арендатора. Любое использование значения — ошибка. */
|
|
46
|
-
export const LETOPIS_NS = new Proxy({}, {
|
|
47
|
-
get(_t, p) {
|
|
48
|
-
if (p === 'then')
|
|
49
|
-
return undefined;
|
|
50
|
-
throw removed('LETOPIS_NS', 'пространство имён v5 — id арендатора: idOf(класс, ключ, { tenant })');
|
|
51
|
-
},
|
|
52
|
-
});
|
|
53
47
|
const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
54
48
|
const SCHEMA_NAME = /^v\d+\.[a-z_][a-z0-9_]{0,40}$/;
|
|
55
|
-
/**
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
container: 'Docker в up() не нужен',
|
|
61
|
-
image: 'Docker в up() не нужен',
|
|
62
|
-
dataDir: 'Docker в up() не нужен',
|
|
63
|
-
redisPort: 'Redis не нужен: сессии в базе',
|
|
64
|
-
version: 'версия схемы — константа движка',
|
|
49
|
+
/** Все опции connect(): неизвестная — invalid_data с похожими именами (checkOptions; план 2.0, 7.10 №4). */
|
|
50
|
+
const CONNECT_OPTIONS = {
|
|
51
|
+
dsn: true, sql: true, schema: true, token: true, apiKey: true, service: true, refreshSession: true, allowBypassRls: true,
|
|
52
|
+
admin: true, owner: true, notifyIntervalMs: true, max: true, onQuery: true, slowMs: true, models: true, listen: true,
|
|
53
|
+
cache: true, maintain: true,
|
|
65
54
|
};
|
|
55
|
+
/** Опции watch() (WatchOptions). */
|
|
56
|
+
const WATCH_OPTIONS = { from: true, classes: true, batch: true, pollMs: true };
|
|
57
|
+
/**
|
|
58
|
+
* То же подключение без строгих типов: шагом может быть любое имя, в том числе известное только во время выполнения, —
|
|
59
|
+
* `loose(db)[имя]()`. Объект тот же: проверки во время выполнения (неизвестное имя или поле, переход, слот) не меняются.
|
|
60
|
+
*/
|
|
61
|
+
export function loose(db) {
|
|
62
|
+
return db;
|
|
63
|
+
}
|
|
66
64
|
const quote = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
67
65
|
/** Фасад db: пул (exec — исполнитель пула) или явная транзакция (tx). */
|
|
68
66
|
function makeDb(sh, scope, exec, owner, tx) {
|
|
@@ -78,14 +76,23 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
78
76
|
// (актор, арендатор, владелец, транзакция), значит, в ней только планы, поставленные этим же фасадом
|
|
79
77
|
const batches = new Map();
|
|
80
78
|
const noTx = (what) => {
|
|
81
|
-
throw
|
|
79
|
+
throw lerr('api.tx_only', { what });
|
|
82
80
|
};
|
|
83
81
|
const auth = makeAuth(sh.S, exec, async () => {
|
|
84
82
|
sh.passes.clear();
|
|
85
83
|
await sh.refresh();
|
|
86
84
|
}, scope.root && !tx);
|
|
87
|
-
|
|
88
|
-
const
|
|
85
|
+
// запись правил (grant, deny, revoke, roles) идёт цепочками самого фасада — он объявлен ниже, берётся при вызове
|
|
86
|
+
const acl = makeAcl(sh.S, exec, () => facade);
|
|
87
|
+
const adm = { S: sh.S, txc, exec };
|
|
88
|
+
// один процесс (план 2.0, 7.9 №3): у корня с connect({ admin }) администраторские вызовы идут вторым пулом с той же
|
|
89
|
+
// сессией, а цепочки и db.sql — пулом приложения под RLS; db.as() и db.begin() администраторский пул не берут
|
|
90
|
+
const viaAdmin = scope.root && !tx && sh.adminSql ? sh.adminSql : null;
|
|
91
|
+
const adminReady = sh.adminReady ?? (async () => { });
|
|
92
|
+
const admCtx = () => {
|
|
93
|
+
const t = { ...txc, sql: viaAdmin };
|
|
94
|
+
return { ...adm, txc: t, exec: poolExecutor(t) };
|
|
95
|
+
};
|
|
89
96
|
const core = {
|
|
90
97
|
schema: sh.schema,
|
|
91
98
|
get token() { return txc.token; },
|
|
@@ -98,35 +105,34 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
98
105
|
idOf: (cls, key) => {
|
|
99
106
|
const tenant = scope.tenant();
|
|
100
107
|
if (!tenant)
|
|
101
|
-
throw
|
|
108
|
+
throw lerr('api.id_of_no_tenant');
|
|
102
109
|
const c = scope.registry().resolve(cls);
|
|
103
110
|
return idOfClass({ name: c.name, key: c.key, ends: Object.keys(c.ends) }, key, { tenant });
|
|
104
111
|
},
|
|
105
112
|
refresh: sh.refresh,
|
|
106
113
|
flushSignals: () => sh.notifier.flush(),
|
|
107
|
-
|
|
108
|
-
maintain: (o) => runMaintain(adm, o),
|
|
109
|
-
reset: async (o) => {
|
|
110
|
-
const r = await runReset(adm, o);
|
|
111
|
-
sh.passes.clear();
|
|
112
|
-
sh.cache?.clear();
|
|
113
|
-
return r;
|
|
114
|
-
},
|
|
115
|
-
trimHistory: (target, before) => runTrim(adm, target, before),
|
|
116
|
-
rekeyClass: (cls, key) => runRekeyClass(adm, cls, key),
|
|
117
|
-
describe: () => describeModel(adm),
|
|
114
|
+
maintain: () => (viaAdmin ? adminReady().then(() => runMaintain(admCtx())) : runMaintain(adm)),
|
|
118
115
|
watch: (o = {}) => {
|
|
119
|
-
|
|
120
|
-
throw removed('watch(cb) и watch(класс, cb)', 'watch({ from }) — подписка по курсору');
|
|
116
|
+
checkOptions('watch', o, WATCH_OPTIONS);
|
|
121
117
|
const reg = scope.registry();
|
|
122
118
|
const classes = o.classes ? [...new Set(o.classes.flatMap((c) => reg.family(c).map((x) => x.name)))] : null;
|
|
123
|
-
return new Subscription({
|
|
124
|
-
S: sh.S, exec: poolExecutor(txc), classes,
|
|
119
|
+
return sh.track(new Subscription({
|
|
120
|
+
S: sh.S, exec: poolExecutor(txc), classes, onClose: (s) => void sh.subs.delete(s),
|
|
121
|
+
// не бросает: класс удалили — список, снятый при watch(); иначе ошибка в общем разборе сигналов остановила бы
|
|
122
|
+
// доставку другим подписчикам
|
|
123
|
+
...(o.classes ? { classesOf: () => {
|
|
124
|
+
try {
|
|
125
|
+
return [...new Set(o.classes.flatMap((c) => scope.registry().family(c).map((x) => x.name)))];
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return classes;
|
|
129
|
+
}
|
|
130
|
+
} } : {}),
|
|
125
131
|
...(sh.listen ? {
|
|
126
132
|
onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); },
|
|
127
133
|
tenant: () => (sh.allTenants ? null : sh.sig.tenantToken(scope.tenant())),
|
|
128
134
|
} : {}),
|
|
129
|
-
}, o);
|
|
135
|
+
}, o));
|
|
130
136
|
},
|
|
131
137
|
cache: {
|
|
132
138
|
clear: () => sh.cache?.clear(),
|
|
@@ -135,7 +141,7 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
135
141
|
entity: (x) => startEntity(chainCtx, x),
|
|
136
142
|
as: async (account, o) => {
|
|
137
143
|
if (tx)
|
|
138
|
-
throw
|
|
144
|
+
throw lerr('api.as_in_tx');
|
|
139
145
|
const id = account === null ? undefined : typeof account === 'object' ? account.id : account;
|
|
140
146
|
const nextOwner = o?.owner ?? owner;
|
|
141
147
|
if (id === undefined && o?.tenant === undefined) {
|
|
@@ -145,51 +151,40 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
145
151
|
const scoped = { ...txc, account: id ?? txc.account, tenant: o?.tenant ?? (id === undefined ? txc.tenant : undefined) };
|
|
146
152
|
let [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
|
|
147
153
|
if (!r?.account)
|
|
148
|
-
throw
|
|
154
|
+
throw lerr('api.as_no_session');
|
|
149
155
|
// контекст сессии между запросами ушёл из арендатора (сменился или кончился): реестр не загружен и не
|
|
150
156
|
// кэширован (хвост ревью безопасности №3) — контекст читается заново, не больше трёх раз
|
|
151
157
|
for (let attempt = 1;; attempt++) {
|
|
152
158
|
const tenant = r.tenant;
|
|
153
159
|
const registry = await sh.registryFor(tenant, scoped);
|
|
154
160
|
if (registry)
|
|
155
|
-
return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => registry, root: false }, poolExecutor(scoped), nextOwner);
|
|
161
|
+
return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => sh.registryOf(tenant, registry, scoped), root: false }, poolExecutor(scoped), nextOwner);
|
|
156
162
|
if (attempt === 3)
|
|
157
|
-
throw
|
|
163
|
+
throw lerr('api.as_context_changing');
|
|
158
164
|
[r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
|
|
159
165
|
if (!r?.account)
|
|
160
|
-
throw
|
|
166
|
+
throw lerr('api.as_no_session');
|
|
161
167
|
}
|
|
162
168
|
},
|
|
163
169
|
begin: async (o) => {
|
|
164
170
|
if (tx)
|
|
165
|
-
throw
|
|
171
|
+
throw lerr('api.nested_begin');
|
|
166
172
|
// причина блока ставится в начале транзакции (setupSql) и действует на все её операции
|
|
167
173
|
const btx = o?.reason !== undefined ? { ...txc, reason: checkReason(o.reason, 'db.begin()') } : txc;
|
|
168
174
|
const t = await ExplicitTx.open(btx);
|
|
169
175
|
return makeDb(sh, btx === txc ? scope : { ...scope, txc: btx }, t, owner, t);
|
|
170
176
|
},
|
|
171
177
|
load: async (input, o) => {
|
|
178
|
+
// опции — до всего прочего: неизвестная — отказ раньше admin_required пула администратора (план 2.0, 7.10 №4)
|
|
179
|
+
checkOptions('load', o, LOAD_OPTIONS);
|
|
172
180
|
if (tx)
|
|
173
|
-
throw
|
|
181
|
+
throw lerr('api.load_in_tx');
|
|
182
|
+
if (viaAdmin) {
|
|
183
|
+
await adminReady();
|
|
184
|
+
return load({ sql: viaAdmin, schema: sh.schema, tenant: scope.tenant(), admin: true, ownerRole: sh.ownerRole }, input, o);
|
|
185
|
+
}
|
|
174
186
|
return load({ sql: sh.sql, schema: sh.schema, tenant: scope.tenant(), admin: sh.admin, ownerRole: sh.ownerRole }, input, o);
|
|
175
187
|
},
|
|
176
|
-
projector: (name, o) => {
|
|
177
|
-
if (tx)
|
|
178
|
-
throw new LetopisError('invalid_query', 'projector() — от корня подключения, а не внутри db.begin()');
|
|
179
|
-
if (!sh.admin)
|
|
180
|
-
throw new LetopisError('admin_required', 'проектору нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
|
|
181
|
-
return new Projector({
|
|
182
|
-
sql: sh.sql, schema: sh.schema, S: sh.S, ownerRole: sh.ownerRole,
|
|
183
|
-
...(sh.listen ? { onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); } } : {}),
|
|
184
|
-
}, name, o);
|
|
185
|
-
},
|
|
186
|
-
rebuildAggregates: async (cls) => {
|
|
187
|
-
if (tx)
|
|
188
|
-
throw new LetopisError('invalid_query', 'rebuildAggregates() — своей транзакцией, не внутри db.begin()');
|
|
189
|
-
if (!sh.admin)
|
|
190
|
-
throw new LetopisError('admin_required', 'ремонту агрегатов нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
|
|
191
|
-
return runRebuildAggregates({ sql: sh.sql, S: sh.S, ownerRole: sh.ownerRole }, cls);
|
|
192
|
-
},
|
|
193
188
|
batch: (name, o) => {
|
|
194
189
|
let q = batches.get(name);
|
|
195
190
|
if (!q)
|
|
@@ -201,89 +196,104 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
201
196
|
rollback: async (tr) => (tr ? tr.rollback() : tx ? tx.rollback() : noTx('rollback()')),
|
|
202
197
|
close: () => (tx ? tx.rollback() : sh.close()),
|
|
203
198
|
};
|
|
199
|
+
/** Свойство фасада: служебное — из core, иначе шаг класса, алиаса или роли. */
|
|
200
|
+
const member = (t, prop, r) => {
|
|
201
|
+
if (typeof prop === 'symbol' || prop in t)
|
|
202
|
+
return Reflect.get(t, prop, r);
|
|
203
|
+
if (prop === 'then')
|
|
204
|
+
return undefined;
|
|
205
|
+
return startStep(chainCtx, prop);
|
|
206
|
+
};
|
|
204
207
|
const facade = new Proxy(core, {
|
|
205
208
|
get(t, prop, r) {
|
|
206
|
-
|
|
207
|
-
return Reflect.get(t, prop, r);
|
|
208
|
-
switch (prop) {
|
|
209
|
-
case 'then':
|
|
210
|
-
return undefined;
|
|
211
|
-
case 'reloadSchema':
|
|
212
|
-
throw removed('reloadSchema()', 'реестр обновляется сам по сигналу; принудительно — db.refresh()');
|
|
213
|
-
case 'accounts':
|
|
214
|
-
case 'credentials':
|
|
215
|
-
case 'resources':
|
|
216
|
-
case 'rules':
|
|
217
|
-
throw removed(`фасад db.${prop}`, 'системные классы Account, Resource, rule, member и db.auth (accounts.purge — db.auth.purgeAccount)');
|
|
218
|
-
case 'compact':
|
|
219
|
-
throw removed('compact()', 'db.trimHistory(цель, момент), политика хранения history и history: false');
|
|
220
|
-
}
|
|
221
|
-
return startStep(chainCtx, prop);
|
|
209
|
+
return member(t, prop, r);
|
|
222
210
|
},
|
|
223
211
|
});
|
|
224
212
|
// модули пакета (letopis/mcp) узнают обход RLS по самому подключению, а не со слов вызывающего (internal.ts)
|
|
225
|
-
registerDb(facade, {
|
|
213
|
+
registerDb(facade, {
|
|
214
|
+
allTenants: sh.allTenants, explicit: Boolean(tx),
|
|
215
|
+
...(viaAdmin ? { adminTx: (fn) => adminReady().then(() => runTx({ ...txc, sql: viaAdmin }, fn)) } : {}),
|
|
216
|
+
});
|
|
226
217
|
return facade;
|
|
227
218
|
}
|
|
228
219
|
export async function connect(opts) {
|
|
220
|
+
// опции — первыми: неизвестная — invalid_data с похожими именами до прочих проверок (план 2.0, 7.10 №4)
|
|
221
|
+
checkOptions('connect', opts, CONNECT_OPTIONS);
|
|
229
222
|
if (!SCHEMA_NAME.test(opts.schema))
|
|
230
|
-
throw
|
|
223
|
+
throw lerr('api.connect_schema', { schema: opts.schema });
|
|
231
224
|
if (!opts.sql && !opts.dsn)
|
|
232
|
-
throw
|
|
233
|
-
for (const k of Object.keys(opts))
|
|
234
|
-
if (k in REMOVED_OPTIONS)
|
|
235
|
-
throw removed(`опция connect({ ${k} })`, REMOVED_OPTIONS[k]);
|
|
225
|
+
throw lerr('api.connect_no_dsn');
|
|
236
226
|
// интервал обслуживания — до пула и таймеров: неверный не оставляет за собой открытых соединений
|
|
237
227
|
const maintainEvery = opts.maintain === undefined ? undefined : parseInterval(opts.maintain);
|
|
228
|
+
if (opts.admin !== undefined && typeof opts.admin !== 'string' && typeof opts.admin !== 'function') {
|
|
229
|
+
throw lerr('api.connect_admin_type');
|
|
230
|
+
}
|
|
238
231
|
const own = !opts.sql;
|
|
239
232
|
// timestamptz — строкой: Date в JS режет микросекунды, а на них держатся asOf и курсоры по времени
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
233
|
+
const types = { timestamptz: { to: 1184, from: [1184], serialize: (v) => (v instanceof Date ? v.toISOString() : v), parse: (v) => v } };
|
|
234
|
+
const sql = (opts.sql ?? postgres(opts.dsn, { max: opts.max ?? 10, onnotice: () => { }, types }));
|
|
235
|
+
// один процесс (план 2.0, 7.9 №3): пул администратора postgres.js соединяется при первом запросе — до первого
|
|
236
|
+
// администраторского вызова соединений у него нет
|
|
237
|
+
const ownAdmin = typeof opts.admin === 'string';
|
|
238
|
+
const adminSql = (typeof opts.admin === 'string'
|
|
239
|
+
? postgres(opts.admin, { max: 2, onnotice: () => { }, types, connection: { application_name: 'letopis admin' } })
|
|
240
|
+
: opts.admin);
|
|
245
241
|
const S = quote(opts.schema);
|
|
246
242
|
const owner = opts.owner ?? 'letopis_owner';
|
|
243
|
+
let adminChecked;
|
|
244
|
+
const adminReady = adminSql ? () => (adminChecked ??= (async () => {
|
|
245
|
+
const [a] = await adminSql `select r.rolsuper, pg_has_role(current_user, ${owner}, 'SET') as can_set from pg_roles r where r.rolname = current_user`;
|
|
246
|
+
if (!a?.rolsuper && !a?.can_set) {
|
|
247
|
+
throw lerr('api.connect_admin_role', { owner });
|
|
248
|
+
}
|
|
249
|
+
})().catch((e) => {
|
|
250
|
+
adminChecked = undefined;
|
|
251
|
+
throw e;
|
|
252
|
+
})) : undefined;
|
|
247
253
|
try {
|
|
248
254
|
// роль, которая обходит RLS, видела бы данные всех арендаторов (§2.9)
|
|
255
|
+
// роли владельца нет (опечатка в owner) — понятная ошибка, а не сырая 42704 PostgreSQL (план 2.0, 7.5 №3)
|
|
256
|
+
const [known] = await sql `select exists (select 1 from pg_roles where rolname = ${owner}) as ok`;
|
|
257
|
+
if (!known.ok)
|
|
258
|
+
throw lerr('api.connect_no_owner', { owner });
|
|
249
259
|
const [role] = await sql `select r.rolsuper, r.rolbypassrls,
|
|
250
260
|
pg_has_role(current_user, ${owner}, 'USAGE') as inherits, pg_has_role(current_user, ${owner}, 'SET') as can_set
|
|
251
261
|
from pg_roles r where r.rolname = current_user`;
|
|
252
262
|
const bypass = role.rolsuper || role.rolbypassrls || role.inherits || role.can_set;
|
|
253
263
|
if (bypass && !opts.allowBypassRls) {
|
|
254
|
-
throw
|
|
264
|
+
throw lerr('api.connect_bypass_rls');
|
|
255
265
|
}
|
|
256
266
|
if (bypass)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const apiKey = opts.apiKey ?? opts.service;
|
|
260
|
-
if (!token && apiKey) {
|
|
261
|
-
const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]).catch((e) => {
|
|
262
|
-
throw fromDbError(e);
|
|
263
|
-
});
|
|
264
|
-
token = r.t;
|
|
265
|
-
}
|
|
266
|
-
// ревизия движка: библиотека новее схемы — накат отстал (этап 6, п. 10)
|
|
267
|
+
lwarn('api.admin_connection');
|
|
268
|
+
// ревизия движка — до первого вызова функций схемы (вход по ключу): схема letopis 1.x — отказ (план 2.0, 7.10 №5)
|
|
267
269
|
const [rv] = await sql `select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = ${opts.schema}`;
|
|
268
|
-
const rev = Number(/ddl_revision=(\d+)/.exec(String(rv?.c ?? ''))?.[1] ?? 0);
|
|
269
270
|
if (!rv)
|
|
270
|
-
throw
|
|
271
|
+
throw lerr('api.connect_no_schema', { schema: opts.schema });
|
|
272
|
+
const rev = Number(/ddl_revision=(\d+)/.exec(String(rv.c ?? ''))?.[1] ?? 0);
|
|
273
|
+
refuse1x(opts.schema, rev);
|
|
274
|
+
// библиотека новее схемы 2.x — накат отстал (этап 6, п. 10)
|
|
271
275
|
if (rev < DDL_REVISION)
|
|
272
|
-
|
|
276
|
+
lwarn('api.schema_older', { schema: opts.schema, rev, want: DDL_REVISION });
|
|
273
277
|
// схему обновил более новый пакет (во время раскатки старые экземпляры ещё работают): подключаемся, но предупреждаем —
|
|
274
278
|
// этот пакет может не знать функций и колонок новой ревизии; up() такую схему не трогает и отказывает
|
|
275
279
|
else if (rev > DDL_REVISION)
|
|
276
|
-
|
|
280
|
+
lwarn('api.schema_newer', { schema: opts.schema, rev, known: DDL_REVISION });
|
|
277
281
|
else {
|
|
278
282
|
// ревизия та же, а функции схемы ставила другая версия движка — штамп в комментарии схемы (99-revision.sql)
|
|
279
283
|
const drift = await stampDrift(opts.schema, String(rv.c ?? ''));
|
|
280
284
|
if (drift)
|
|
281
285
|
console.warn(drift);
|
|
282
286
|
}
|
|
283
|
-
|
|
287
|
+
let token = opts.token ?? null;
|
|
288
|
+
const apiKey = opts.apiKey ?? opts.service;
|
|
289
|
+
if (!token && apiKey) {
|
|
290
|
+
const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]).catch((e) => {
|
|
291
|
+
throw fromDbError(e);
|
|
292
|
+
});
|
|
293
|
+
token = r.t;
|
|
294
|
+
}
|
|
295
|
+
// режим сигналов установки (план 1.1, §2.2)
|
|
284
296
|
const [sm] = await sql.unsafe(`select ${S}.signal_mode() as m`).catch((e) => {
|
|
285
|
-
if (e.code === '42883')
|
|
286
|
-
return [{ m: 'class' }];
|
|
287
297
|
throw fromDbError(e);
|
|
288
298
|
});
|
|
289
299
|
const mode = SIGNAL_MODES.includes(sm?.m) ? sm.m : 'class';
|
|
@@ -298,7 +308,10 @@ export async function connect(opts) {
|
|
|
298
308
|
// продление сессии (§2.9): отдельным вызовом не чаще раза в минуту; ошибка (реплика,
|
|
299
309
|
// транзакции только для чтения) не мешает работе
|
|
300
310
|
let refreshedAt = Date.now();
|
|
311
|
+
// время последней удачной операции: после простоя дольше минуты сессию проверяют до операции (ctx.before)
|
|
312
|
+
let lastOk = Date.now();
|
|
301
313
|
const touch = () => {
|
|
314
|
+
lastOk = Date.now();
|
|
302
315
|
if (!token || opts.refreshSession === false || Date.now() - refreshedAt < 60_000)
|
|
303
316
|
return;
|
|
304
317
|
refreshedAt = Date.now();
|
|
@@ -308,6 +321,69 @@ export async function connect(opts) {
|
|
|
308
321
|
const ctx = { sql, token, notifier: mode === 'none' ? null : notifier, passes, after: touch,
|
|
309
322
|
...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}) };
|
|
310
323
|
const root = { tenant: null, registry: new Registry() };
|
|
324
|
+
// подключение по ключу APIKEY: сессия истекает по простою (срок — 7 суток), и процесс, простоявший дольше, получал
|
|
325
|
+
// бы no_session на каждый вызов. Ключ обменивается на новую сессию заново (один обмен на все ждущие вызовы), с тем
|
|
326
|
+
// же рабочим арендатором, что выбрал auth.switch(); операция повторяется один раз (runTx, ExplicitTx.open)
|
|
327
|
+
if (apiKey && !opts.token) {
|
|
328
|
+
let renewing = null;
|
|
329
|
+
const exchange = async () => {
|
|
330
|
+
const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]);
|
|
331
|
+
const next = r?.t ? String(r.t) : null;
|
|
332
|
+
const want = root.tenant;
|
|
333
|
+
if (next && want) {
|
|
334
|
+
await runTx({ sql, token: next, notifier: null }, async (q) => {
|
|
335
|
+
const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t`);
|
|
336
|
+
if (String(t?.t ?? '').toLowerCase() !== want.toLowerCase())
|
|
337
|
+
await q.unsafe(`select ${S}.auth_switch($1::uuid)`, [want]);
|
|
338
|
+
}, { retries: 0 });
|
|
339
|
+
}
|
|
340
|
+
return next;
|
|
341
|
+
};
|
|
342
|
+
ctx.renew = async (failed) => {
|
|
343
|
+
// ключ уже обменян другим вызовом — достаточно нового токена
|
|
344
|
+
if (token && failed.token !== token) {
|
|
345
|
+
failed.token = token;
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
renewing ??= exchange().catch(() => null).finally(() => {
|
|
349
|
+
renewing = null;
|
|
350
|
+
});
|
|
351
|
+
const next = await renewing;
|
|
352
|
+
if (!next)
|
|
353
|
+
return false;
|
|
354
|
+
token = next;
|
|
355
|
+
ctx.token = next;
|
|
356
|
+
failed.token = next;
|
|
357
|
+
refreshedAt = Date.now();
|
|
358
|
+
return true;
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
// после простоя дольше минуты — продление до операции: сессия, истёкшая по простою, даёт no_session (подключение по
|
|
362
|
+
// ключу выдаёт новую), а не анонимное чтение с пустым ответом; ошибка продления другого рода (реплика, транзакция
|
|
363
|
+
// только для чтения) работе не мешает, как у touch
|
|
364
|
+
let checking = null;
|
|
365
|
+
const checkSession = async () => {
|
|
366
|
+
try {
|
|
367
|
+
await runTx({ sql, token, notifier: null }, (q) => q.unsafe(`select ${S}.auth_refresh()`), { retries: 0 });
|
|
368
|
+
}
|
|
369
|
+
catch (e) {
|
|
370
|
+
if (!(e instanceof LetopisError && e.code === 'no_session'))
|
|
371
|
+
return;
|
|
372
|
+
if (!ctx.renew || !(await ctx.renew(ctx)))
|
|
373
|
+
throw e;
|
|
374
|
+
}
|
|
375
|
+
refreshedAt = lastOk = Date.now();
|
|
376
|
+
};
|
|
377
|
+
ctx.before = async (c) => {
|
|
378
|
+
if (!token || opts.refreshSession === false || Date.now() - lastOk < 60_000)
|
|
379
|
+
return;
|
|
380
|
+
checking ??= checkSession().finally(() => {
|
|
381
|
+
checking = null;
|
|
382
|
+
});
|
|
383
|
+
await checking;
|
|
384
|
+
if (token && c.token !== token)
|
|
385
|
+
c.token = token;
|
|
386
|
+
};
|
|
311
387
|
// классы арендатора tenant под контекстом txc; null — контекст уже не в этом арендаторе (сессия кончилась,
|
|
312
388
|
// пользователь выбыл из арендатора): тогда прежний реестр остаётся, а не пустеет (доработки после точки Б)
|
|
313
389
|
const loadClasses = (txc, tenant) => runTx(txc, async (q) => {
|
|
@@ -316,8 +392,18 @@ export async function connect(opts) {
|
|
|
316
392
|
return null;
|
|
317
393
|
return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
|
|
318
394
|
}, { read: true });
|
|
319
|
-
// реестры db.as() — общие на арендатора; txc — контекст последнего успешного as() этого
|
|
395
|
+
// реестры db.as() — общие на арендатора; txc — контекст последнего успешного as() этого арендатора. Порядок карты —
|
|
396
|
+
// давность обращения: сверх limits.asRegistries вытесняется самый давний (план 2.0, 7.4 №8 — иначе процесс,
|
|
397
|
+
// который ходит от имени многих арендаторов, копил бы реестры и перечитывал их все на каждый сигнал классов)
|
|
320
398
|
const others = new Map();
|
|
399
|
+
let latest = null;
|
|
400
|
+
const keep = (tenant, entry) => {
|
|
401
|
+
latest = tenant;
|
|
402
|
+
others.delete(tenant);
|
|
403
|
+
others.set(tenant, entry);
|
|
404
|
+
while (others.size > Math.max(1, limits.asRegistries))
|
|
405
|
+
others.delete(others.keys().next().value);
|
|
406
|
+
};
|
|
321
407
|
const loading = new Map();
|
|
322
408
|
// токены классов (режим opaque) — по именам всех реестров подключения
|
|
323
409
|
const tokens = () => sig.setClasses([root.registry, ...[...others.values()].map((o) => o.registry)].flatMap((r) => r.all().map((c) => c.name)));
|
|
@@ -343,7 +429,7 @@ export async function connect(opts) {
|
|
|
343
429
|
const registryFor = async (tenant, txc) => {
|
|
344
430
|
const known = others.get(tenant);
|
|
345
431
|
if (known) {
|
|
346
|
-
known.txc
|
|
432
|
+
keep(tenant, { registry: known.registry, txc });
|
|
347
433
|
return known.registry;
|
|
348
434
|
}
|
|
349
435
|
// одновременные as() нового арендатора получают один реестр
|
|
@@ -355,7 +441,7 @@ export async function connect(opts) {
|
|
|
355
441
|
return null;
|
|
356
442
|
const registry = new Registry();
|
|
357
443
|
registry.load(rows);
|
|
358
|
-
|
|
444
|
+
keep(tenant, { registry, txc });
|
|
359
445
|
tokens();
|
|
360
446
|
return registry;
|
|
361
447
|
})().finally(() => loading.delete(tenant));
|
|
@@ -363,6 +449,20 @@ export async function connect(opts) {
|
|
|
363
449
|
}
|
|
364
450
|
return pending;
|
|
365
451
|
};
|
|
452
|
+
// фасад db.as() берёт реестр через карту: свежий реестр арендатора, если его перечитал другой as(); вытесненный —
|
|
453
|
+
// обратно в карту (его снова обновляют сигналы) и перечитать фоном: пока он был вне карты, сигналы его не трогали
|
|
454
|
+
const registryOf = (tenant, registry, txc) => {
|
|
455
|
+
const cur = others.get(tenant);
|
|
456
|
+
if (cur) {
|
|
457
|
+
if (latest !== tenant)
|
|
458
|
+
keep(tenant, cur);
|
|
459
|
+
return cur.registry;
|
|
460
|
+
}
|
|
461
|
+
keep(tenant, { registry, txc });
|
|
462
|
+
void loadClasses(txc, tenant).then((rows) => { if (rows)
|
|
463
|
+
registry.load(rows); }).catch(() => { }).finally(tokens);
|
|
464
|
+
return registry;
|
|
465
|
+
};
|
|
366
466
|
// ключ сигналов (режимы tenant и opaque) — сессии с правом letopis.signals; без права процесс
|
|
367
467
|
// работает без ключа: арендатора (а в opaque — и класса) в сигнале не различает
|
|
368
468
|
const fetchKey = async () => {
|
|
@@ -373,15 +473,15 @@ export async function connect(opts) {
|
|
|
373
473
|
return null;
|
|
374
474
|
throw e;
|
|
375
475
|
});
|
|
376
|
-
// ключ
|
|
476
|
+
// ключ сменился: ответы с токенами старого ключа сигналы больше не узнают
|
|
377
477
|
if (sig.setKey(key))
|
|
378
478
|
cache?.clear();
|
|
379
479
|
};
|
|
380
480
|
await fetchKey();
|
|
381
481
|
await refresh();
|
|
382
|
-
// ключ, реестр и пропуска прав — заново по
|
|
383
|
-
// без ключа в режиме opaque — по любому сигналу; не чаще раза в 5 с: заливка канала (в том
|
|
384
|
-
// поддельными сигналами) не становится потоком перечитываний
|
|
482
|
+
// ключ, реестр и пропуска прав — заново после переподключения слушателя, по незнакомому токену, нагрузке не по
|
|
483
|
+
// режиму, а у процесса без ключа в режиме opaque — по любому сигналу; не чаще раза в 5 с: заливка канала (в том
|
|
484
|
+
// числе поддельными сигналами) не становится потоком перечитываний
|
|
385
485
|
const resync = throttle(() => {
|
|
386
486
|
passes.clear();
|
|
387
487
|
void fetchKey().then(() => refresh()).catch(() => { });
|
|
@@ -468,17 +568,28 @@ export async function connect(opts) {
|
|
|
468
568
|
stamper.unref?.();
|
|
469
569
|
}
|
|
470
570
|
let stopMaintain = null;
|
|
571
|
+
const subs = new Set();
|
|
471
572
|
const shared = {
|
|
472
|
-
sql, schema: opts.schema, S, notifier, passes, root, registryFor, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
|
|
573
|
+
sql, schema: opts.schema, S, notifier, passes, root, registryFor, registryOf, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
|
|
473
574
|
admin: Boolean(bypass && (role.rolsuper || role.can_set)),
|
|
474
575
|
ownerRole: owner,
|
|
576
|
+
...(adminSql ? { adminSql, adminReady } : {}),
|
|
475
577
|
cache,
|
|
476
578
|
sig,
|
|
477
579
|
allTenants: Boolean(bypass),
|
|
478
580
|
signals,
|
|
479
581
|
listen,
|
|
582
|
+
subs,
|
|
583
|
+
track: (s) => {
|
|
584
|
+
subs.add(s);
|
|
585
|
+
return s;
|
|
586
|
+
},
|
|
480
587
|
async close() {
|
|
481
|
-
//
|
|
588
|
+
// живые подписки закрываются: иначе с чужим пулом (connect({ sql })) они опрашивали бы журнал и после close()
|
|
589
|
+
// (план 2.0, 7.4 №8)
|
|
590
|
+
for (const s of [...subs])
|
|
591
|
+
s.close();
|
|
592
|
+
// идущий maintain() по расписанию доделывается: пул закрывается после него, а не посреди транзакции
|
|
482
593
|
await stopMaintain?.();
|
|
483
594
|
resync.stop();
|
|
484
595
|
startReload.stop();
|
|
@@ -488,16 +599,20 @@ export async function connect(opts) {
|
|
|
488
599
|
await listener?.unlisten();
|
|
489
600
|
if (own)
|
|
490
601
|
await sql.end({ timeout: 5 });
|
|
602
|
+
if (ownAdmin)
|
|
603
|
+
await adminSql.end({ timeout: 5 });
|
|
491
604
|
},
|
|
492
605
|
};
|
|
493
606
|
const db = makeDb(shared, { txc: ctx, tenant: () => root.tenant, registry: () => root.registry, root: true }, poolExecutor(ctx));
|
|
494
607
|
if (maintainEvery !== undefined)
|
|
495
|
-
stopMaintain = scheduleMaintain((
|
|
608
|
+
stopMaintain = scheduleMaintain(() => db.maintain(), maintainEvery);
|
|
496
609
|
return db;
|
|
497
610
|
}
|
|
498
611
|
catch (e) {
|
|
499
612
|
if (own)
|
|
500
613
|
await sql.end({ timeout: 1 });
|
|
614
|
+
if (ownAdmin)
|
|
615
|
+
await adminSql.end({ timeout: 1 });
|
|
501
616
|
throw e;
|
|
502
617
|
}
|
|
503
618
|
}
|
package/dist/internal.d.ts
CHANGED
|
@@ -9,7 +9,19 @@ export interface DbInternals {
|
|
|
9
9
|
readonly allTenants: boolean;
|
|
10
10
|
/** Фасад явной транзакции db.begin(). */
|
|
11
11
|
readonly explicit: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Транзакция на администраторском пуле connect({ admin }) с сессией фасада (план 2.0, 7.9 №3): ею пишет syncModels.
|
|
14
|
+
* Только у корня подключения с опцией admin.
|
|
15
|
+
*/
|
|
16
|
+
readonly adminTx?: <T>(fn: (tx: import('./tx.js').Tx) => Promise<T>) => Promise<T>;
|
|
12
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Пределы подключения, которых нет в опциях: реестров классов db.as() — не больше asRegistries арендаторов (план 2.0,
|
|
20
|
+
* 7.4 №8). Тест меняет предел, чтобы проверить вытеснение без тысячи арендаторов.
|
|
21
|
+
*/
|
|
22
|
+
export declare const limits: {
|
|
23
|
+
asRegistries: number;
|
|
24
|
+
};
|
|
13
25
|
/** Запомнить фасад (зовёт makeDb для каждого фасада, который отдаёт наружу). */
|
|
14
26
|
export declare function registerDb(db: object, info: DbInternals): void;
|
|
15
27
|
/** Свойства фасада; undefined — объект не фасад connect() этого пакета. */
|