@open-rap/runtime 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +14 -2
- package/dist/abap/background.d.ts +14 -0
- package/dist/abap/background.js +130 -0
- package/dist/abap/background.js.map +1 -0
- package/dist/abap/bridge.d.ts +62 -0
- package/dist/abap/bridge.js +402 -0
- package/dist/abap/bridge.js.map +1 -0
- package/dist/abap/convert.d.ts +26 -0
- package/dist/abap/convert.js +197 -0
- package/dist/abap/convert.js.map +1 -0
- package/dist/abap/http.d.ts +34 -0
- package/dist/abap/http.js +133 -0
- package/dist/abap/http.js.map +1 -0
- package/dist/abap/mail.d.ts +10 -0
- package/dist/abap/mail.js +83 -0
- package/dist/abap/mail.js.map +1 -0
- package/dist/abap/odata-client.d.ts +33 -0
- package/dist/abap/odata-client.js +576 -0
- package/dist/abap/odata-client.js.map +1 -0
- package/dist/abap/sql.d.ts +92 -0
- package/dist/abap/sql.js +261 -0
- package/dist/abap/sql.js.map +1 -0
- package/dist/abap/utils.d.ts +41 -0
- package/dist/abap/utils.js +976 -0
- package/dist/abap/utils.js.map +1 -0
- package/dist/app.d.ts +53 -0
- package/dist/app.js +169 -0
- package/dist/app.js.map +1 -0
- package/dist/auth/auth.d.ts +68 -0
- package/dist/auth/auth.js +140 -0
- package/dist/auth/auth.js.map +1 -0
- package/dist/auth/authorizations.d.ts +39 -0
- package/dist/auth/authorizations.js +103 -0
- package/dist/auth/authorizations.js.map +1 -0
- package/dist/auth/business-users.d.ts +15 -0
- package/dist/auth/business-users.js +36 -0
- package/dist/auth/business-users.js.map +1 -0
- package/dist/auth/dev-oauth.d.ts +38 -0
- package/dist/auth/dev-oauth.js +156 -0
- package/dist/auth/dev-oauth.js.map +1 -0
- package/dist/auth/environment.d.ts +7 -0
- package/dist/auth/environment.js +57 -0
- package/dist/auth/environment.js.map +1 -0
- package/dist/auth/session.d.ts +45 -0
- package/dist/auth/session.js +257 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/user.d.ts +21 -0
- package/dist/auth/user.js +12 -0
- package/dist/auth/user.js.map +1 -0
- package/dist/db/database.d.ts +30 -0
- package/dist/db/database.js +131 -0
- package/dist/db/database.js.map +1 -0
- package/dist/db/seed.d.ts +9 -0
- package/dist/db/seed.js +63 -0
- package/dist/db/seed.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/launchpad/dev.d.ts +14 -0
- package/dist/launchpad/dev.js +48 -0
- package/dist/launchpad/dev.js.map +1 -0
- package/dist/launchpad/proxy.d.ts +30 -0
- package/dist/launchpad/proxy.js +98 -0
- package/dist/launchpad/proxy.js.map +1 -0
- package/dist/launchpad/server.d.ts +77 -0
- package/dist/launchpad/server.js +177 -0
- package/dist/launchpad/server.js.map +1 -0
- package/dist/mcp/server.d.ts +22 -0
- package/dist/mcp/server.js +222 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/odata/access.d.ts +31 -0
- package/dist/odata/access.js +142 -0
- package/dist/odata/access.js.map +1 -0
- package/dist/odata/batch.d.ts +36 -0
- package/dist/odata/batch.js +147 -0
- package/dist/odata/batch.js.map +1 -0
- package/dist/odata/csrf.d.ts +13 -0
- package/dist/odata/csrf.js +27 -0
- package/dist/odata/csrf.js.map +1 -0
- package/dist/odata/custom.d.ts +90 -0
- package/dist/odata/custom.js +373 -0
- package/dist/odata/custom.js.map +1 -0
- package/dist/odata/draft.d.ts +78 -0
- package/dist/odata/draft.js +590 -0
- package/dist/odata/draft.js.map +1 -0
- package/dist/odata/errors.d.ts +27 -0
- package/dist/odata/errors.js +41 -0
- package/dist/odata/errors.js.map +1 -0
- package/dist/odata/handler.d.ts +90 -0
- package/dist/odata/handler.js +312 -0
- package/dist/odata/handler.js.map +1 -0
- package/dist/odata/json.d.ts +23 -0
- package/dist/odata/json.js +85 -0
- package/dist/odata/json.js.map +1 -0
- package/dist/odata/read.d.ts +90 -0
- package/dist/odata/read.js +686 -0
- package/dist/odata/read.js.map +1 -0
- package/dist/odata/service.d.ts +45 -0
- package/dist/odata/service.js +145 -0
- package/dist/odata/service.js.map +1 -0
- package/dist/odata/uri/ast.d.ts +103 -0
- package/dist/odata/uri/ast.js +3 -0
- package/dist/odata/uri/ast.js.map +1 -0
- package/dist/odata/uri/parser.d.ts +26 -0
- package/dist/odata/uri/parser.js +697 -0
- package/dist/odata/uri/parser.js.map +1 -0
- package/dist/odata/write.d.ts +127 -0
- package/dist/odata/write.js +799 -0
- package/dist/odata/write.js.map +1 -0
- package/dist/platform.d.ts +14 -0
- package/dist/platform.js +66 -0
- package/dist/platform.js.map +1 -0
- package/dist/rap/bo.d.ts +76 -0
- package/dist/rap/bo.js +184 -0
- package/dist/rap/bo.js.map +1 -0
- package/dist/rap/kernel.d.ts +7 -0
- package/dist/rap/kernel.js +287 -0
- package/dist/rap/kernel.js.map +1 -0
- package/dist/rap/runtime.d.ts +84 -0
- package/dist/rap/runtime.js +128 -0
- package/dist/rap/runtime.js.map +1 -0
- package/dist/rap/transaction.d.ts +227 -0
- package/dist/rap/transaction.js +1385 -0
- package/dist/rap/transaction.js.map +1 -0
- package/dist/rap/types.d.ts +74 -0
- package/dist/rap/types.js +25 -0
- package/dist/rap/types.js.map +1 -0
- package/dist/server.d.ts +34 -0
- package/dist/server.js +114 -0
- package/dist/server.js.map +1 -0
- package/dist/ui/plugin.d.ts +14 -0
- package/dist/ui/plugin.js +47 -0
- package/dist/ui/plugin.js.map +1 -0
- package/dist/ui/resources.d.ts +22 -0
- package/dist/ui/resources.js +88 -0
- package/dist/ui/resources.js.map +1 -0
- package/dist/ui/variants.d.ts +25 -0
- package/dist/ui/variants.js +68 -0
- package/dist/ui/variants.js.map +1 -0
- package/dist/values.d.ts +43 -0
- package/dist/values.js +367 -0
- package/dist/values.js.map +1 -0
- package/package.json +60 -3
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft (decision D54): the drafts of a draft BO (`with draft`) are kept in the draft tables, one
|
|
3
|
+
* per entity, with the administrative data of each draft (`rapt_draft_admin`). The OData service
|
|
4
|
+
* reads the active instances and the drafts together (key IsActiveEntity); this module runs the
|
|
5
|
+
* writes on drafts and the actions of the draft:
|
|
6
|
+
*
|
|
7
|
+
* - POST on the root collection creates a draft; POST through a composition of a draft creates a
|
|
8
|
+
* draft child; PATCH and DELETE change or remove drafts, without determinations or validations;
|
|
9
|
+
* - Edit copies an active instance and its compositions into a draft (exclusive: one draft per
|
|
10
|
+
* instance, in process by one user; a draft unchanged for 15 minutes may be taken over);
|
|
11
|
+
* - Activate applies the draft to the active instances through the managed runtime in the LUW of
|
|
12
|
+
* the request (creations, changes and deletions of the composition tree; determinations,
|
|
13
|
+
* validations and the saver run), then removes the draft;
|
|
14
|
+
* - Discard removes the draft, Resume takes it over again, Prepare returns it.
|
|
15
|
+
*
|
|
16
|
+
* Active instances that have a draft cannot be changed directly (409).
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { sql } from "kysely";
|
|
20
|
+
import { operationId } from "../rap/transaction.js";
|
|
21
|
+
import { Responses } from "../rap/types.js";
|
|
22
|
+
import { fromSql, initialValue, isInitial, toSql } from "../values.js";
|
|
23
|
+
import { badRequest, methodNotAllowed, notFound, notImplemented, ODataError } from "./errors.js";
|
|
24
|
+
import { etagValue } from "./json.js";
|
|
25
|
+
/** A draft not changed for this long may be taken over by another user (SAP: 15 minutes). */
|
|
26
|
+
export const DRAFT_LOCK_MINUTES = 15;
|
|
27
|
+
const ACTIVATION = { local: false, privileged: false, external: false, keepKeys: true };
|
|
28
|
+
const DRAFT_ACTIONS = new Set(["Edit", "Activate", "Prepare", "Resume", "Discard", "Share"]);
|
|
29
|
+
export class DraftRuntime {
|
|
30
|
+
writes;
|
|
31
|
+
constructor(writes) {
|
|
32
|
+
this.writes = writes;
|
|
33
|
+
}
|
|
34
|
+
table(bo) {
|
|
35
|
+
const table = bo.behavior.draftTable;
|
|
36
|
+
if (table === undefined) {
|
|
37
|
+
throw new ODataError(500, "INTERNAL_ERROR", `${bo.entity.name} has no draft table`);
|
|
38
|
+
}
|
|
39
|
+
return table;
|
|
40
|
+
}
|
|
41
|
+
isDraftSet(set) {
|
|
42
|
+
return set.info.set.draft !== undefined;
|
|
43
|
+
}
|
|
44
|
+
// -------------------------------------------------------------------------------------------
|
|
45
|
+
// SQL of the drafts
|
|
46
|
+
where(bo, key) {
|
|
47
|
+
return sql.join(bo.keys.map((k) => sql `${sql.id(k.name)} = ${toSql(k.type, (key[k.name] ?? null))}`), sql ` AND `);
|
|
48
|
+
}
|
|
49
|
+
rowOf(bo, raw) {
|
|
50
|
+
const row = {};
|
|
51
|
+
for (const field of bo.fields) {
|
|
52
|
+
// a calculated element (D67) has no column in the draft table: the draft view computes it
|
|
53
|
+
row[field.name] = field.expression !== undefined ? initialValue(field.type) : fromSql(field.type, raw[field.name]);
|
|
54
|
+
}
|
|
55
|
+
return { ...row, DraftUUID: String(raw["DraftUUID"]), HasActiveEntity: raw["HasActiveEntity"] === true };
|
|
56
|
+
}
|
|
57
|
+
async draft(db, bo, key) {
|
|
58
|
+
const { rows } = await sql `SELECT * FROM ${sql.table(this.table(bo))} WHERE ${this.where(bo, key)}`.execute(db);
|
|
59
|
+
return rows[0] === undefined ? undefined : this.rowOf(bo, rows[0]);
|
|
60
|
+
}
|
|
61
|
+
async children(db, association, parent) {
|
|
62
|
+
const child = association.target;
|
|
63
|
+
const conditions = association.on.map((p) => {
|
|
64
|
+
const field = child.fields.find((f) => f.name === p.target);
|
|
65
|
+
return sql `${sql.id(p.target)} = ${toSql(field.type, (parent[p.source] ?? null))}`;
|
|
66
|
+
});
|
|
67
|
+
const { rows } = await sql `SELECT * FROM ${sql.table(this.table(child))} WHERE "DraftUUID" = ${parent.DraftUUID} AND ${sql.join(conditions, sql ` AND `)}`.execute(db);
|
|
68
|
+
return rows.map((r) => this.rowOf(child, r));
|
|
69
|
+
}
|
|
70
|
+
async insert(db, bo, row, draftUuid, hasActive) {
|
|
71
|
+
const stored = bo.fields.filter((f) => f.expression === undefined);
|
|
72
|
+
const columns = [...stored.map((f) => sql.id(f.name)), sql.id("DraftUUID"), sql.id("HasActiveEntity")];
|
|
73
|
+
const values = [...stored.map((f) => sql `${toSql(f.type, (row[f.name] ?? initialValue(f.type)))}`), sql `${draftUuid}`, sql `${hasActive}`];
|
|
74
|
+
await sql `INSERT INTO ${sql.table(this.table(bo))} (${sql.join(columns)}) VALUES (${sql.join(values)})`.execute(db);
|
|
75
|
+
await this.touch(db, bo, row);
|
|
76
|
+
}
|
|
77
|
+
/** The change time of the draft, and its ETag field (the ETag of a draft changes with it). */
|
|
78
|
+
async touch(db, bo, key) {
|
|
79
|
+
const etag = bo.etagField === undefined ? sql `` : sql `, ${sql.id(bo.etagField)} = now()`;
|
|
80
|
+
await sql `UPDATE ${sql.table(this.table(bo))} SET "DraftEntityLastChangeDateTime" = now()${etag} WHERE ${this.where(bo, key)}`.execute(db);
|
|
81
|
+
}
|
|
82
|
+
async admin(db, draftUuid) {
|
|
83
|
+
const { rows } = await sql `SELECT "DraftUUID", "InProcessByUser", "LastChangeDateTime"::text AS "LastChangeDateTime" FROM "rapt_draft_admin" WHERE "DraftUUID" = ${draftUuid}`.execute(db);
|
|
84
|
+
return rows[0];
|
|
85
|
+
}
|
|
86
|
+
async touchAdmin(db, draftUuid, user) {
|
|
87
|
+
await sql `UPDATE "rapt_draft_admin" SET "LastChangeDateTime" = now(), "LastChangedByUser" = ${user} WHERE "DraftUUID" = ${draftUuid}`.execute(db);
|
|
88
|
+
}
|
|
89
|
+
/** Removes a draft instance and the drafts of its compositions. */
|
|
90
|
+
async remove(db, bo, row) {
|
|
91
|
+
for (const composition of bo.compositions) {
|
|
92
|
+
for (const child of await this.children(db, composition, row)) {
|
|
93
|
+
await this.remove(db, composition.target, child);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
await sql `DELETE FROM ${sql.table(this.table(bo))} WHERE ${this.where(bo, row)}`.execute(db);
|
|
97
|
+
}
|
|
98
|
+
async removeDraft(db, root, row) {
|
|
99
|
+
await this.remove(db, root, row);
|
|
100
|
+
await sql `DELETE FROM "rapt_draft_admin" WHERE "DraftUUID" = ${row.DraftUUID}`.execute(db);
|
|
101
|
+
await sql `DELETE FROM "rapt_draft_user" WHERE "DraftUUID" = ${row.DraftUUID}`.execute(db);
|
|
102
|
+
}
|
|
103
|
+
/** A BO `with collaborative draft` (D59). */
|
|
104
|
+
collaborative(bo) {
|
|
105
|
+
return bo.bdef.collaborativeDraft === true;
|
|
106
|
+
}
|
|
107
|
+
/** Adds a user to the users of a collaborative draft (O: owner, E: editor). */
|
|
108
|
+
async addUser(db, draftUuid, user, role) {
|
|
109
|
+
await sql `INSERT INTO "rapt_draft_user" ("DraftUUID", "UserID", "UserDescription", "UserAccessRole") VALUES (${draftUuid}, ${user}, ${user}, ${role}) ON CONFLICT ("DraftUUID", "UserID") DO NOTHING`.execute(db);
|
|
110
|
+
}
|
|
111
|
+
async isUser(db, draftUuid, user) {
|
|
112
|
+
const { rows } = await sql `SELECT 1 AS n FROM "rapt_draft_user" WHERE "DraftUUID" = ${draftUuid} AND "UserID" = ${user}`.execute(db);
|
|
113
|
+
return rows.length > 0;
|
|
114
|
+
}
|
|
115
|
+
/** A new draft: its administrative data, and its owner for a collaborative draft. */
|
|
116
|
+
async newDraft(db, bo, draftUuid, user) {
|
|
117
|
+
await sql `INSERT INTO "rapt_draft_admin" ("DraftUUID", "CreatedByUser", "LastChangedByUser", "InProcessByUser", "RootEntity") VALUES (${draftUuid}, ${user}, ${user}, ${user}, ${bo.entity.name})`.execute(db);
|
|
118
|
+
if (this.collaborative(bo)) {
|
|
119
|
+
await this.addUser(db, draftUuid, user, "O");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
expired(admin) {
|
|
123
|
+
return Date.now() - Date.parse(admin.LastChangeDateTime.replace(" ", "T").replace(/\+00$/, "Z")) > DRAFT_LOCK_MINUTES * 60_000;
|
|
124
|
+
}
|
|
125
|
+
/** The draft must be in process by the user. */
|
|
126
|
+
async owned(db, set, row, user) {
|
|
127
|
+
const admin = await this.admin(db, row.DraftUUID);
|
|
128
|
+
if (admin === undefined) {
|
|
129
|
+
throw notFound(`the draft of ${set.info.set.name}`);
|
|
130
|
+
}
|
|
131
|
+
if (admin.InProcessByUser !== user && !(this.collaborative(set.bo) && (await this.isUser(db, row.DraftUUID, user)))) {
|
|
132
|
+
throw new ODataError(409, "DRAFT_LOCKED", `The draft is in process by ${admin.InProcessByUser}`);
|
|
133
|
+
}
|
|
134
|
+
return admin;
|
|
135
|
+
}
|
|
136
|
+
// -------------------------------------------------------------------------------------------
|
|
137
|
+
// routing
|
|
138
|
+
/** The key without IsActiveEntity, and its value (undefined: not given). */
|
|
139
|
+
split(set, key) {
|
|
140
|
+
const active = key["IsActiveEntity"];
|
|
141
|
+
return { key: this.writes.baseKey(set, key), active: active === undefined || active === null ? undefined : active === true || active === "true" };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The outcome of a request on a draft BO when it concerns drafts or the actions of the draft;
|
|
145
|
+
* undefined when the managed runtime handles it (an active instance), after the lock check.
|
|
146
|
+
*/
|
|
147
|
+
async route(tx, request, target, ctx, minimal) {
|
|
148
|
+
const segments = target.segments;
|
|
149
|
+
const first = segments[0];
|
|
150
|
+
if (first === undefined || first.kind !== "entitySet") {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
const firstSet = this.writes.sets.get(first.name);
|
|
154
|
+
if (firstSet === undefined || !this.isDraftSet(firstSet)) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
const options = target.options;
|
|
158
|
+
const outcome = (status) => ({ status, messages: [], options, minimal });
|
|
159
|
+
const user = ctx.user.abapName;
|
|
160
|
+
const db = tx.db;
|
|
161
|
+
const last = segments[segments.length - 1];
|
|
162
|
+
// the path: entity set (key), then navigations (key)
|
|
163
|
+
let set = firstSet;
|
|
164
|
+
let key = first.key === undefined ? undefined : this.split(set, this.writes.service.key(set.info, first.key));
|
|
165
|
+
let navigation;
|
|
166
|
+
let parent;
|
|
167
|
+
for (const segment of segments.slice(1)) {
|
|
168
|
+
if (segment.kind === "operation")
|
|
169
|
+
break;
|
|
170
|
+
if (segment.kind !== "navigation" || key === undefined) {
|
|
171
|
+
throw badRequest(`Writes are not supported on ${segment.kind} segments`);
|
|
172
|
+
}
|
|
173
|
+
const nav = set.info.navigations.get(segment.name);
|
|
174
|
+
const targetSet = nav === undefined ? undefined : this.writes.sets.get(nav.targetSet);
|
|
175
|
+
if (nav === undefined || targetSet === undefined) {
|
|
176
|
+
throw badRequest(`${set.info.type.name} has no navigation property ${segment.name}`);
|
|
177
|
+
}
|
|
178
|
+
if (segment.key !== undefined) {
|
|
179
|
+
const next = this.split(targetSet, this.writes.service.key(targetSet.info, segment.key));
|
|
180
|
+
set = targetSet;
|
|
181
|
+
key = { key: next.key, active: next.active ?? key.active };
|
|
182
|
+
navigation = undefined;
|
|
183
|
+
parent = undefined;
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
parent = { set, key: key.key };
|
|
187
|
+
navigation = segment.name;
|
|
188
|
+
set = targetSet;
|
|
189
|
+
key = { key: {}, active: key.active };
|
|
190
|
+
if (!nav.collection) {
|
|
191
|
+
throw notImplemented(`${segment.name}: writes through a to-one navigation of a draft`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const isDraft = key?.active === false;
|
|
196
|
+
// actions of the draft
|
|
197
|
+
if (last?.kind === "operation" && DRAFT_ACTIONS.has(last.name.replace(/^.*\./, ""))) {
|
|
198
|
+
if (key === undefined || navigation !== undefined) {
|
|
199
|
+
throw badRequest(`${last.name} applies to an instance of ${set.info.set.name}`);
|
|
200
|
+
}
|
|
201
|
+
return this.action(tx, request, set, key.key, isDraft, last.name.replace(/^.*\./, ""), outcome, user);
|
|
202
|
+
}
|
|
203
|
+
// other operations: on active instances only
|
|
204
|
+
if (last?.kind === "operation") {
|
|
205
|
+
if (isDraft) {
|
|
206
|
+
throw notImplemented(`${last.name} on a draft: activate the draft first`);
|
|
207
|
+
}
|
|
208
|
+
if (key !== undefined)
|
|
209
|
+
await this.checkNotLocked(db, set, key.key);
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
if (request.method === "POST") {
|
|
213
|
+
const body = parseObject(request.body);
|
|
214
|
+
if (navigation !== undefined && parent !== undefined) {
|
|
215
|
+
if (key?.active !== false) {
|
|
216
|
+
// creation through a composition of an active instance: the managed runtime, if not locked
|
|
217
|
+
await this.checkNotLocked(db, parent.set, parent.key);
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
const created = await this.createChild(db, parent.set, parent.key, navigation, body, user);
|
|
221
|
+
return { ...outcome(minimal ? 204 : 201), entity: created, location: this.writes.path(created.set, created.key) };
|
|
222
|
+
}
|
|
223
|
+
if (key === undefined) {
|
|
224
|
+
if (body["IsActiveEntity"] === true) {
|
|
225
|
+
return undefined; // an active instance, created directly
|
|
226
|
+
}
|
|
227
|
+
const created = await this.createRoot(tx, set, body, user);
|
|
228
|
+
return { ...outcome(minimal ? 204 : 201), entity: created, location: this.writes.path(created.set, created.key) };
|
|
229
|
+
}
|
|
230
|
+
throw methodNotAllowed("POST on an entity: use PATCH, or POST on its collection");
|
|
231
|
+
}
|
|
232
|
+
if (key === undefined) {
|
|
233
|
+
throw methodNotAllowed(`${request.method} needs a single entity`);
|
|
234
|
+
}
|
|
235
|
+
if (!isDraft) {
|
|
236
|
+
await this.checkNotLocked(db, set, key.key);
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
const row = await this.draft(db, set.bo, key.key);
|
|
240
|
+
if (row === undefined) {
|
|
241
|
+
throw notFound(this.writes.path(set, { ...this.writes.projectedKey(set, key.key), IsActiveEntity: false }));
|
|
242
|
+
}
|
|
243
|
+
await this.owned(db, set, row, user);
|
|
244
|
+
if (request.method === "PATCH" || request.method === "PUT") {
|
|
245
|
+
this.checkEtag(request, set, row);
|
|
246
|
+
await this.update(db, set, row, parseObject(request.body), request.method === "PUT", user);
|
|
247
|
+
return { ...outcome(minimal ? 204 : 200), entity: { set, key: { ...this.writes.projectedKey(set, key.key), IsActiveEntity: false } } };
|
|
248
|
+
}
|
|
249
|
+
if (request.method === "DELETE") {
|
|
250
|
+
if (set.bo.parent === undefined) {
|
|
251
|
+
await this.removeDraft(db, set.bo, row);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
await this.remove(db, set.bo, row);
|
|
255
|
+
await this.touchAdmin(db, row.DraftUUID, user);
|
|
256
|
+
}
|
|
257
|
+
return outcome(204);
|
|
258
|
+
}
|
|
259
|
+
throw methodNotAllowed(`${request.method} is not supported`);
|
|
260
|
+
}
|
|
261
|
+
/** An active instance with a draft cannot be changed directly. */
|
|
262
|
+
async checkNotLocked(db, set, key) {
|
|
263
|
+
if (!this.isDraftSet(set) || set.bo.keys.some((k) => key[k.name] === undefined)) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const row = await this.draft(db, set.bo, key);
|
|
267
|
+
if (row !== undefined) {
|
|
268
|
+
const admin = await this.admin(db, row.DraftUUID);
|
|
269
|
+
throw new ODataError(409, "DRAFT_LOCKED", `${this.writes.path(set, this.writes.projectedKey(set, key))} has a draft (in process by ${admin?.InProcessByUser ?? "another user"}): edit or discard the draft`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
checkEtag(request, set, row) {
|
|
273
|
+
const header = request.headers["if-match"]?.trim();
|
|
274
|
+
const property = set.info.type.etag;
|
|
275
|
+
if (header === undefined || header === "" || header === "*" || property === undefined) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const field = set.toBase.get(property) ?? property;
|
|
279
|
+
if (!header.split(",").map((t) => t.trim()).includes(etagValue(row[field]))) {
|
|
280
|
+
throw new ODataError(412, "PRECONDITION_FAILED", "The draft was changed in the meantime: reload it");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// -------------------------------------------------------------------------------------------
|
|
284
|
+
// creation and change of drafts
|
|
285
|
+
/** Values of a body for a draft: the fields a consumer may write (keys of a creation included). */
|
|
286
|
+
draftValues(set, body, replace, create, hasActive) {
|
|
287
|
+
const { values, fields } = this.writes.values(set, body, replace);
|
|
288
|
+
for (const field of [...fields]) {
|
|
289
|
+
const characteristics = this.writes.rap.bo.characteristics(set.bo, field);
|
|
290
|
+
const isKey = set.bo.keys.some((k) => k.name === field);
|
|
291
|
+
const readonly = characteristics.includes("readonly") || characteristics.includes("numberingManaged") || (!create && isKey) || (hasActive && characteristics.includes("readonlyUpdate"));
|
|
292
|
+
if (readonly) {
|
|
293
|
+
if (replace) {
|
|
294
|
+
fields.delete(field);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
throw badRequest(`${set.toProperty.get(field) ?? field} is read-only`, set.toProperty.get(field));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return { values, fields };
|
|
301
|
+
}
|
|
302
|
+
newRow(set, values, fields) {
|
|
303
|
+
const row = {};
|
|
304
|
+
for (const field of set.bo.fields) {
|
|
305
|
+
row[field.name] = fields.has(field.name) ? (values[field.name] ?? initialValue(field.type)) : initialValue(field.type);
|
|
306
|
+
}
|
|
307
|
+
for (const key of set.bo.keys) {
|
|
308
|
+
const characteristics = this.writes.rap.bo.characteristics(set.bo, key.name);
|
|
309
|
+
if (characteristics.includes("numberingManaged")) {
|
|
310
|
+
row[key.name] = randomUUID();
|
|
311
|
+
}
|
|
312
|
+
else if (isInitial(key.type, row[key.name] ?? null)) {
|
|
313
|
+
throw badRequest(`${set.toProperty.get(key.name) ?? key.name}: the key is required`, set.toProperty.get(key.name));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return row;
|
|
317
|
+
}
|
|
318
|
+
async createRoot(tx, set, body, user) {
|
|
319
|
+
if (!set.info.set.insertable) {
|
|
320
|
+
throw methodNotAllowed(`${set.info.set.name} does not allow creation`);
|
|
321
|
+
}
|
|
322
|
+
// the global authorization and features of the creation
|
|
323
|
+
if (!(await tx.allowed(set.bo, [{ kind: "create" }])).has("create")) {
|
|
324
|
+
throw new ODataError(403, "FORBIDDEN", `You are not authorized to create ${set.info.set.name}`);
|
|
325
|
+
}
|
|
326
|
+
const { values, fields } = this.draftValues(set, body, false, true, false);
|
|
327
|
+
const row = this.newRow(set, values, fields);
|
|
328
|
+
if (await this.draft(tx.db, set.bo, row)) {
|
|
329
|
+
throw new ODataError(409, "CONFLICT", `${this.writes.path(set, this.writes.projectedKey(set, row))} already has a draft`);
|
|
330
|
+
}
|
|
331
|
+
const draftUuid = randomUUID();
|
|
332
|
+
await this.newDraft(tx.db, set.bo, draftUuid, user);
|
|
333
|
+
await this.insert(tx.db, set.bo, row, draftUuid, false);
|
|
334
|
+
await this.createInline(tx.db, set, row, draftUuid, body);
|
|
335
|
+
return { set, key: { ...this.writes.projectedKey(set, row), IsActiveEntity: false } };
|
|
336
|
+
}
|
|
337
|
+
/** Deep insert in a draft: the children given inline. */
|
|
338
|
+
async createInline(db, set, parentRow, draftUuid, body) {
|
|
339
|
+
for (const [name, value] of Object.entries(body)) {
|
|
340
|
+
const nav = set.info.navigations.get(name);
|
|
341
|
+
if (nav === undefined || nav.kind !== "composition") {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const childSet = this.writes.sets.get(nav.targetSet);
|
|
345
|
+
const association = this.writes.associationOf(set, name);
|
|
346
|
+
for (const child of Array.isArray(value) ? value : [value]) {
|
|
347
|
+
const childBody = child;
|
|
348
|
+
const { values, fields } = this.draftValues(childSet, childBody, false, true, false);
|
|
349
|
+
const row = this.newRow(childSet, { ...values, ...this.foreignKey(association, parentRow) }, new Set([...fields, ...association.on.map((p) => p.target)]));
|
|
350
|
+
await this.insert(db, childSet.bo, row, draftUuid, false);
|
|
351
|
+
await this.createInline(db, childSet, row, draftUuid, childBody);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
foreignKey(association, parent) {
|
|
356
|
+
return Object.fromEntries(association.on.map((p) => [p.target, parent[p.source] ?? null]));
|
|
357
|
+
}
|
|
358
|
+
async createChild(db, parentSet, parentKey, navigation, body, user) {
|
|
359
|
+
const nav = parentSet.info.navigations.get(navigation);
|
|
360
|
+
if (nav === undefined || nav.kind !== "composition" || !nav.insertable) {
|
|
361
|
+
throw methodNotAllowed(`${parentSet.info.set.name}/${navigation}: creation through this navigation is not allowed`);
|
|
362
|
+
}
|
|
363
|
+
const parent = await this.draft(db, parentSet.bo, parentKey);
|
|
364
|
+
if (parent === undefined) {
|
|
365
|
+
throw notFound(`the draft of ${this.writes.path(parentSet, this.writes.projectedKey(parentSet, parentKey))}`);
|
|
366
|
+
}
|
|
367
|
+
await this.owned(db, parentSet, parent, user);
|
|
368
|
+
const childSet = this.writes.sets.get(nav.targetSet);
|
|
369
|
+
const association = this.writes.associationOf(parentSet, navigation);
|
|
370
|
+
const { values, fields } = this.draftValues(childSet, body, false, true, false);
|
|
371
|
+
const row = this.newRow(childSet, { ...values, ...this.foreignKey(association, parent) }, new Set([...fields, ...association.on.map((p) => p.target)]));
|
|
372
|
+
await this.insert(db, childSet.bo, row, parent.DraftUUID, false);
|
|
373
|
+
await this.createInline(db, childSet, row, parent.DraftUUID, body);
|
|
374
|
+
await this.touchAdmin(db, parent.DraftUUID, user);
|
|
375
|
+
return { set: childSet, key: { ...this.writes.projectedKey(childSet, row), IsActiveEntity: false } };
|
|
376
|
+
}
|
|
377
|
+
async update(db, set, row, body, replace, user) {
|
|
378
|
+
const { values, fields } = this.draftValues(set, body, replace, false, row.HasActiveEntity);
|
|
379
|
+
for (const key of set.bo.keys) {
|
|
380
|
+
fields.delete(key.name);
|
|
381
|
+
}
|
|
382
|
+
if (fields.size > 0) {
|
|
383
|
+
const assignments = [...fields].map((name) => {
|
|
384
|
+
const field = set.bo.fields.find((f) => f.name === name);
|
|
385
|
+
return sql `${sql.id(name)} = ${toSql(field.type, (values[name] ?? null))}`;
|
|
386
|
+
});
|
|
387
|
+
await sql `UPDATE ${sql.table(this.table(set.bo))} SET ${sql.join(assignments)} WHERE ${this.where(set.bo, row)}`.execute(db);
|
|
388
|
+
}
|
|
389
|
+
await this.touch(db, set.bo, row);
|
|
390
|
+
await this.touchAdmin(db, row.DraftUUID, user);
|
|
391
|
+
}
|
|
392
|
+
// -------------------------------------------------------------------------------------------
|
|
393
|
+
// actions of the draft
|
|
394
|
+
async action(tx, request, set, key, isDraft, name, outcome, user) {
|
|
395
|
+
if (request.method !== "POST") {
|
|
396
|
+
throw methodNotAllowed(`${name} is an action: POST`);
|
|
397
|
+
}
|
|
398
|
+
if (set.bo.parent !== undefined) {
|
|
399
|
+
throw badRequest(`${name} applies to the root of the draft`);
|
|
400
|
+
}
|
|
401
|
+
const db = tx.db;
|
|
402
|
+
const draftKey = (k) => ({ ...this.writes.projectedKey(set, k), IsActiveEntity: false });
|
|
403
|
+
if (name === "Edit") {
|
|
404
|
+
if (isDraft) {
|
|
405
|
+
throw badRequest("Edit applies to an active instance");
|
|
406
|
+
}
|
|
407
|
+
const body = parseObject(request.body);
|
|
408
|
+
const existing = await this.draft(db, set.bo, key);
|
|
409
|
+
if (existing !== undefined) {
|
|
410
|
+
const admin = await this.admin(db, existing.DraftUUID);
|
|
411
|
+
if (admin?.InProcessByUser === user && body["PreserveChanges"] !== true) {
|
|
412
|
+
return { ...outcome(200), entity: { set, key: draftKey(key) } };
|
|
413
|
+
}
|
|
414
|
+
if (admin !== undefined && admin.InProcessByUser !== user && this.collaborative(set.bo)) {
|
|
415
|
+
// collaborative draft (D59): the user joins the draft of the instance
|
|
416
|
+
await this.addUser(db, existing.DraftUUID, user, "E");
|
|
417
|
+
await this.touchAdmin(db, existing.DraftUUID, user);
|
|
418
|
+
return { ...outcome(200), entity: { set, key: draftKey(key) } };
|
|
419
|
+
}
|
|
420
|
+
if (admin !== undefined && admin.InProcessByUser !== user && !this.expired(admin)) {
|
|
421
|
+
throw new ODataError(409, "DRAFT_LOCKED", `${this.writes.path(set, this.writes.projectedKey(set, key))} is being edited by ${admin.InProcessByUser}`);
|
|
422
|
+
}
|
|
423
|
+
if (admin?.InProcessByUser === user) {
|
|
424
|
+
throw new ODataError(409, "DRAFT_EXISTS", "You already have a draft of this instance: resume or discard it");
|
|
425
|
+
}
|
|
426
|
+
await this.removeDraft(db, set.bo, existing); // the lock of the other user has expired
|
|
427
|
+
}
|
|
428
|
+
// the user must be allowed to change the instance (authorization, feature control)
|
|
429
|
+
const features = await tx.features(set.bo, [key], [{ kind: "update" }], []);
|
|
430
|
+
const k = JSON.stringify(set.bo.keys.map((f) => key[f.name] ?? null));
|
|
431
|
+
if (features.global.has(operationId({ kind: "update" })) || features.disabled.has(`${k}|update`)) {
|
|
432
|
+
throw new ODataError(403, "FORBIDDEN", `${this.writes.path(set, this.writes.projectedKey(set, key))} cannot be edited`);
|
|
433
|
+
}
|
|
434
|
+
const { rows } = await tx.read(set.bo, [{ key }]);
|
|
435
|
+
const active = rows[0];
|
|
436
|
+
if (active === undefined) {
|
|
437
|
+
throw notFound(this.writes.path(set, this.writes.projectedKey(set, key)));
|
|
438
|
+
}
|
|
439
|
+
const draftUuid = randomUUID();
|
|
440
|
+
await this.newDraft(db, set.bo, draftUuid, user);
|
|
441
|
+
await this.copy(tx, set.bo, active, draftUuid);
|
|
442
|
+
return { ...outcome(200), entity: { set, key: draftKey(key) } };
|
|
443
|
+
}
|
|
444
|
+
if (!isDraft) {
|
|
445
|
+
throw badRequest(`${name} applies to a draft (IsActiveEntity=false)`);
|
|
446
|
+
}
|
|
447
|
+
const row = await this.draft(db, set.bo, key);
|
|
448
|
+
if (row === undefined) {
|
|
449
|
+
throw notFound(this.writes.path(set, draftKey(key)));
|
|
450
|
+
}
|
|
451
|
+
const admin = await this.admin(db, row.DraftUUID);
|
|
452
|
+
if (name === "Resume") {
|
|
453
|
+
if (admin !== undefined && admin.InProcessByUser !== user && !this.expired(admin) && !(this.collaborative(set.bo) && (await this.isUser(db, row.DraftUUID, user)))) {
|
|
454
|
+
throw new ODataError(409, "DRAFT_LOCKED", `The draft is in process by ${admin.InProcessByUser}`);
|
|
455
|
+
}
|
|
456
|
+
await sql `UPDATE "rapt_draft_admin" SET "InProcessByUser" = ${user}, "LastChangeDateTime" = now() WHERE "DraftUUID" = ${row.DraftUUID}`.execute(db);
|
|
457
|
+
return { ...outcome(200), entity: { set, key: draftKey(key) } };
|
|
458
|
+
}
|
|
459
|
+
await this.owned(db, set, row, user);
|
|
460
|
+
if (name === "Share") {
|
|
461
|
+
// collaborative draft (D59): the users invited to edit the draft
|
|
462
|
+
if (!this.collaborative(set.bo)) {
|
|
463
|
+
throw badRequest(`${set.info.set.name} has no collaborative draft`);
|
|
464
|
+
}
|
|
465
|
+
const body = parseObject(request.body);
|
|
466
|
+
const users = Array.isArray(body["Users"]) ? body["Users"] : [];
|
|
467
|
+
for (const entry of users) {
|
|
468
|
+
const id = typeof entry === "object" && entry !== null ? entry["UserID"] : undefined;
|
|
469
|
+
if (typeof id !== "string" || id.trim() === "" || id.length > 12) {
|
|
470
|
+
throw badRequest("Share: Users is a list of { UserID, UserAccessRole }, UserID of at most 12 characters", "Users");
|
|
471
|
+
}
|
|
472
|
+
await this.addUser(db, row.DraftUUID, id.trim().toUpperCase(), "E");
|
|
473
|
+
}
|
|
474
|
+
await this.touchAdmin(db, row.DraftUUID, user);
|
|
475
|
+
return outcome(204);
|
|
476
|
+
}
|
|
477
|
+
if (name === "Discard") {
|
|
478
|
+
await this.removeDraft(db, set.bo, row);
|
|
479
|
+
return outcome(204);
|
|
480
|
+
}
|
|
481
|
+
if (name === "Prepare") {
|
|
482
|
+
// the validations run at the activation, in the LUW that saves
|
|
483
|
+
return { ...outcome(200), entity: { set, key: draftKey(key) } };
|
|
484
|
+
}
|
|
485
|
+
// Activate
|
|
486
|
+
const responses = new Responses();
|
|
487
|
+
const activeKey = await this.activate(tx, set, row, responses);
|
|
488
|
+
await this.writes.determine(tx, responses);
|
|
489
|
+
await this.removeDraft(db, set.bo, row);
|
|
490
|
+
return { ...outcome(200), entity: { set, key: { ...this.writes.projectedKey(set, activeKey), IsActiveEntity: true } }, messages: this.writes.messages(responses.reported, { set, key: activeKey }) };
|
|
491
|
+
}
|
|
492
|
+
/** Copies an active instance and its compositions into the draft tables. */
|
|
493
|
+
async copy(tx, bo, active, draftUuid) {
|
|
494
|
+
await this.insert(tx.db, bo, active, draftUuid, true);
|
|
495
|
+
for (const composition of bo.compositions) {
|
|
496
|
+
const { results } = await tx.readByAssociation(bo, composition, [active]);
|
|
497
|
+
for (const { target } of results) {
|
|
498
|
+
await this.copy(tx, composition.target, target, draftUuid);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** The fields of a draft the activation writes (not those the runtime computes). */
|
|
503
|
+
writableFields(bo, create) {
|
|
504
|
+
return bo.fields
|
|
505
|
+
.filter((f) => {
|
|
506
|
+
const c = this.writes.rap.bo.characteristics(bo, f.name);
|
|
507
|
+
const isKey = bo.keys.some((k) => k.name === f.name);
|
|
508
|
+
if (isKey)
|
|
509
|
+
return create;
|
|
510
|
+
return !c.includes("readonly") && !c.includes("numberingManaged") && !(c.includes("readonlyUpdate") && !create) && f.semantics === undefined;
|
|
511
|
+
})
|
|
512
|
+
.map((f) => f.name);
|
|
513
|
+
}
|
|
514
|
+
changed(bo, draft, active) {
|
|
515
|
+
return new Set(this.writableFields(bo, false).filter((f) => JSON.stringify(draft[f] ?? null) !== JSON.stringify(active[f] ?? null)));
|
|
516
|
+
}
|
|
517
|
+
/** Applies a draft to the active instances; returns the key of the active root. */
|
|
518
|
+
async activate(tx, set, draft, responses) {
|
|
519
|
+
const bo = set.bo;
|
|
520
|
+
const fail = (r) => {
|
|
521
|
+
responses.add(r);
|
|
522
|
+
if (r.hasFailed) {
|
|
523
|
+
throw this.writes.error(r, { set, key: draft });
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
let rootCid;
|
|
527
|
+
let active;
|
|
528
|
+
if (draft.HasActiveEntity) {
|
|
529
|
+
active = (await tx.read(bo, [{ key: draft }])).rows[0];
|
|
530
|
+
}
|
|
531
|
+
if (active !== undefined) {
|
|
532
|
+
const fields = this.changed(bo, draft, active);
|
|
533
|
+
if (fields.size > 0) {
|
|
534
|
+
fail(await tx.update(bo, [{ key: draft, values: draft, fields }], ACTIVATION));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
rootCid = "%DRAFT-ROOT";
|
|
539
|
+
fail(await tx.create(bo, [{ cid: rootCid, values: draft, fields: new Set(this.writableFields(bo, true)) }], ACTIVATION));
|
|
540
|
+
}
|
|
541
|
+
await this.activateChildren(tx, bo, draft, active !== undefined ? draft : undefined, rootCid, fail);
|
|
542
|
+
const created = rootCid === undefined ? undefined : tx.createdByCid(rootCid);
|
|
543
|
+
return created?.key ?? draft;
|
|
544
|
+
}
|
|
545
|
+
async activateChildren(tx, bo, draftParent, activeParentKey, parentCid, fail) {
|
|
546
|
+
let n = 0;
|
|
547
|
+
for (const composition of bo.compositions) {
|
|
548
|
+
const child = composition.target;
|
|
549
|
+
const drafts = await this.children(tx.db, composition, draftParent);
|
|
550
|
+
const actives = activeParentKey === undefined ? [] : (await tx.readByAssociation(bo, composition, [activeParentKey])).results.map((r) => r.target);
|
|
551
|
+
const same = (a, b) => child.keys.every((k) => JSON.stringify(a[k.name] ?? null) === JSON.stringify(b[k.name] ?? null));
|
|
552
|
+
for (const removed of actives.filter((a) => !drafts.some((d) => same(a, d)))) {
|
|
553
|
+
fail(await tx.delete(child, [{ key: removed }], ACTIVATION));
|
|
554
|
+
}
|
|
555
|
+
for (const draft of drafts) {
|
|
556
|
+
const active = actives.find((a) => same(a, draft));
|
|
557
|
+
let cid;
|
|
558
|
+
if (active !== undefined) {
|
|
559
|
+
const fields = this.changed(child, draft, active);
|
|
560
|
+
if (fields.size > 0) {
|
|
561
|
+
fail(await tx.update(child, [{ key: draft, values: draft, fields }], ACTIVATION));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
cid = `%DRAFT-${child.entity.name}-${n++}`;
|
|
566
|
+
const target = { cid, values: draft, fields: new Set(this.writableFields(child, true)) };
|
|
567
|
+
const request = parentCid !== undefined && activeParentKey === undefined ? { key: {}, cidRef: parentCid, targets: [target] } : { key: activeParentKey ?? draftParent, targets: [target] };
|
|
568
|
+
fail(await tx.createByAssociation(bo, composition, [request], ACTIVATION));
|
|
569
|
+
}
|
|
570
|
+
await this.activateChildren(tx, child, draft, active !== undefined ? draft : undefined, cid, fail);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
function parseObject(body) {
|
|
576
|
+
if (body === undefined || body.trim() === "") {
|
|
577
|
+
return {};
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
const value = JSON.parse(body);
|
|
581
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
582
|
+
throw new Error("not an object");
|
|
583
|
+
}
|
|
584
|
+
return value;
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
throw badRequest("The request body must be a JSON object");
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
//# sourceMappingURL=draft.js.map
|