Page MenuHomeSealhub

collection.ts
No OneTemporary

collection.ts

import Router from "@koa/router";
import Emittery from "emittery";
import type { ActionName } from "../action.js";
import type { App } from "../app/app.js";
import Public from "../app/policy-types/public.js";
import type Context from "../context.js";
import parseBody from "../http/parse-body.js";
import { BadContext, NotFound } from "../response/errors.js";
import type CalculatedField from "./calculated-field.js";
import CollectionItem, { ItemMetadata } from "./collection-item.js";
import CollectionItemBody from "./collection-item-body.js";
import type Field from "./field.js";
import type {
FieldsetEncoded,
FieldsetInput,
FieldsetOutput,
} from "./fieldset.js";
import ItemList from "./item-list.js";
import type Policy from "./policy.js";
import type SpecialFilter from "./special-filter.js";
export type CollectionEvent =
| "before:create"
| "after:create"
| "before:remove"
| "after:remove"
| "before:edit"
| "after:edit";
export type CollectionCallback = ([context, item, event]: [
Context,
CollectionItem,
CollectionEvent
]) => Promise<void>;
export type CollectionValidationResult = { error: string; fields: string[] }[];
export type CollectionOutput<T extends Collection> = FieldsetOutput<
T["fields"]
>;
export type CollectionInput<T extends Collection> = FieldsetInput<T["fields"]>;
export type CollectionEncoded<T extends Collection> = FieldsetEncoded<
T["fields"]
>;
export type Fieldnames<T extends Collection> = keyof CollectionInput<T> &
string;
/** Creates a collection. All collections are automatically served via
* the REST API, with permissions set by the Policies */
export default abstract class Collection {
abstract fields: Record<string, Field<any>>;
private emitter = new Emittery();
/** the name of the collection, will be used as part of the URI in
* the REST API */
name: string;
/** policies for this collection. Who can do what */
policies: Partial<{ [a in ActionName]: Policy }> = {};
/** The policy to use when deciding on an action not specified in
* `policies` */
defaultPolicy: Policy = new Public();
/** The app this collection is tied to */
app: App;
named_filters: Record<string, SpecialFilter> = {};
calculated_fields: Record<string, CalculatedField<unknown>> = {};
/** initializes the fields @internal */
async initFieldDetails(): Promise<void> {
const promises = [];
for (const [field_name, field] of Object.entries(this.fields)) {
field.setCollection(this);
field.setName(field_name);
promises.push(field.init(this.app, this));
}
await Promise.all(promises);
}
async suCreate(data: CollectionInput<this>): Promise<CollectionItem<this>> {
return this.create(new this.app.SuperContext(), data);
}
/* the "unsafe" flavor of CRUD functions are meant for cases where you want
to just get the errror message and parse it - for example, when you want to
get a nice error that can be used to display errors in a form */
async suCreateUnsafe(
data: Record<string, unknown>
): Promise<CollectionItem<this>> {
return this.createUnsafe(new this.app.SuperContext(), data);
}
async create(
context: Context,
data: CollectionInput<this>
): Promise<CollectionItem<this>> {
return this.make(data).save(context);
}
/* the "unsafe" flavor of CRUD functions are meant for cases where you want
to just get the errror message and parse it - for example, when you want to
get a nice error that can be used to display errors in a form */
async createUnsafe(
context: Context,
data: Record<string, unknown>
): Promise<CollectionItem<this>> {
return this.make(data as Partial<CollectionInput<this>>).save(context);
}
/** Makes a new item object that can be saved later */
make(input?: Partial<CollectionInput<this>>): CollectionItem<this> {
return new CollectionItem<this>(
this,
new CollectionItemBody(this, input, {}, {})
);
}
async suGetByID(id: string): Promise<CollectionItem<this>> {
return this.getByID(new this.app.SuperContext(), id);
}
async getByID(
context: Context,
id: string,
give_descriptive_errors = false
): Promise<CollectionItem<this>> {
const policy = this.getPolicy("show");
if (!(await policy.isItemSensitive(context))) {
const checkResult = await policy.check(context);
if (!checkResult?.allowed) {
throw new BadContext(checkResult?.reason as string);
}
}
const restrictedListResult = (await context.app.Datastore.aggregate(
this.name,
[
{ $match: { id } },
...(await policy.getRestrictingQuery(context)).toPipeline(),
]
)) as Record<string, unknown>[];
if (!restrictedListResult.length) {
if (!give_descriptive_errors) {
throw new NotFound(`${this.name}: id ${id} not found`);
}
const unrestrictedListResult =
(await context.app.Datastore.aggregate(this.name, [
{ $match: { id } },
])) as Record<string, unknown>[];
if (!unrestrictedListResult.length) {
throw new NotFound(`${this.name}: id ${id} not found`);
}
const checkResult = await policy.checkerFunction(
context,
async () =>
new CollectionItem(
this,
new CollectionItemBody(
this,
{},
{},
unrestrictedListResult[0] as unknown as FieldsetEncoded<
this["fields"]
>
),
unrestrictedListResult[0]._metadata as ItemMetadata,
id
)
);
throw new BadContext(checkResult?.reason as string);
}
const ret = new CollectionItem(
this,
new CollectionItemBody(
this,
{},
{},
restrictedListResult[0] as unknown as FieldsetEncoded<
this["fields"]
>
),
restrictedListResult[0]._metadata as ItemMetadata,
id
);
await ret.decode(context);
return ret;
}
async suRemoveByID(
id: string,
wait_for_after_events = true
): Promise<void> {
await this.removeByID(
new this.app.SuperContext(),
id,
wait_for_after_events
);
}
async removeByID(
context: Context,
id: string,
wait_for_after_events = true
): Promise<void> {
const item =
this.emitter.listenerCount("before:remove") ||
this.emitter.listenerCount("after:remove")
? await this.getByID(context, id)
: null;
if (this.emitter.listenerCount("before:remove")) {
void this.emit("before:remove", [context, item]);
}
const result = await this.getPolicy("delete").check(context, () =>
this.getByID(context, id)
);
if (!result?.allowed) {
throw new BadContext(result?.reason as string);
}
await context.app.Datastore.remove(this.name, { id: id }, true);
if (this.emitter.listenerCount("after:remove")) {
const promise = this.emit("after:remove", [context, item]);
if (wait_for_after_events) {
await promise;
} else {
void promise;
}
}
}
/** Get a policy for given action, an inherited policy, or the default
* policy, if no policy is specified for this action */
getPolicy(action: ActionName): Policy {
const policy = this.policies[action];
if (policy !== undefined) {
return policy;
}
// show and list are actions that can use each others' policies.
if (action === "show" && this.policies["list"]) {
return this.policies["list"];
} else if (action === "list" && this.policies["show"]) {
return this.policies["show"];
}
return this.defaultPolicy;
}
/** Initialize all the fields and filters
* @internal
*/
async init(app: App, collection_name: string): Promise<void> {
this.name = collection_name;
this.app = app;
await this.initFieldDetails();
for (const filter of Object.values(this.named_filters)) {
filter.init(app);
}
}
/** Whether or not any of the fields' behavior depends on the
* current values of themselves or other fields
*
* @param action_name
* the action for which to check @internal
*/
isOldValueSensitive(action_name: ActionName): boolean {
for (const field_name in this.fields) {
if (this.fields[field_name].isOldValueSensitive(action_name)) {
return true;
}
}
return false;
}
/** Return a named filter from the collection
* @param filter_name the name of the filter
*/
getNamedFilter(filter_name: string): SpecialFilter {
return this.named_filters[filter_name];
}
suList(): ItemList<this> {
return this.list(new this.app.SuperContext());
}
list(context: Context): ItemList<this> {
return new ItemList<this>(this, context);
}
createFromDB(document: Record<string, unknown>): CollectionItem<this> {
const id = document?.id;
delete document.id;
delete document._id;
return new CollectionItem<this>(
this,
new CollectionItemBody(
this,
{},
{},
document as unknown as FieldsetEncoded<this["fields"]>
),
document._metadata as ItemMetadata,
id as string
);
}
on(
event_name: CollectionEvent,
cb: CollectionCallback
): Emittery.UnsubscribeFn {
return this.emitter.on(event_name, cb);
}
getRequiredFields(): Field<unknown>[] {
return Object.values(this.fields).filter((field) => field.required);
}
setPolicy(action: ActionName, policy: Policy): this {
this.policies[action] = policy;
return this;
}
getRouter(): Router {
const router = new Router();
router.get(["/", "/@:filter1", "/@:filter1/@:filter2"], async (ctx) => {
const list = this.list(ctx.$context).setParams(ctx.query);
for (const key of ["filter1", "filter2"]) {
if (ctx.params[key]) {
list.namedFilter(ctx.params[key]);
}
}
ctx.body = (
await list.fetch({ is_http_api_request: true })
).serialize();
});
router.post("/", parseBody(), async (ctx) => {
const item = this.make();
item.setMultiple(ctx.request.body);
await item.save(ctx.$context, true);
await item.decode(ctx.$context, {}, true);
ctx.body = item.serializeBody();
ctx.status = 201;
});
router.get("/:id", async (ctx) => {
const [ret] = await this.list(ctx.$context)
.ids([ctx.params.id])
.safeFormat(ctx.query.format)
.fetch();
const format = ctx.query.format;
await ret.safeLoadAttachments(
ctx.$context,
ctx.query.attachments,
typeof format == "object" && format ? format : {}
);
ctx.body = ret.serialize();
});
router.patch("/:id", parseBody(), async (ctx) => {
const item = await this.getByID(ctx.$context, ctx.params.id);
item.setMultiple(ctx.request.body);
await item.save(ctx.$context);
await item.decode(ctx.$context);
ctx.body = item.serialize();
});
router.put("/:id", parseBody(), async (ctx) => {
const item = await this.getByID(ctx.$context, ctx.params.id);
item.replace(ctx.request.body);
await item.save(ctx.$context);
await item.decode(ctx.$context);
ctx.body = item.serialize();
});
router.delete("/:id", async (ctx) => {
await (
await this.getByID(ctx.$context, ctx.params.id)
).remove(ctx.$context);
ctx.status = 204; // "No content"
});
return router;
}
clearListeners() {
this.emitter.clearListeners();
}
emit(event_name: string, event_data?: any): Promise<void> {
return this.emitter.emitSerial(event_name, event_data);
}
async validate(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context: Context,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
new_body: CollectionItemBody<any>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
old_body: CollectionItemBody<any>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
action: "create" | "edit"
): Promise<CollectionValidationResult> {
// empty function, meant to be overwritten in order to implement custom validation logic
return [];
}
static getFieldnames<C extends Collection>(
collection: C
): Array<keyof FieldsetInput<C["fields"]> & string> {
return Object.keys(collection.fields) as Array<
keyof FieldsetInput<C["fields"]> & string
>;
}
async upsert<
C extends Collection,
IdentityField extends keyof CollectionInput<C>
>(
context: Context,
identify_by: IdentityField,
entries: ({ [key in IdentityField]: unknown } & CollectionInput<C>)[]
): Promise<void> {
await Promise.all(
entries.map(async (entry) => {
const {
items: [item],
} = await context.app.collections[this.name]
.list(context)
.filter({ [identify_by]: entry[identify_by] })
.paginate({ items: 1 })
.fetch();
if (item) {
let has_changes = false;
for (const key of Object.keys(
entry
) as (keyof typeof entry)[]) {
if (entry[key] != item.get(key as string)) {
has_changes = true;
} else {
delete entry[key];
}
}
if (has_changes) {
item.setMultiple(entry);
await item.save(context);
}
} else {
return context.app.collections[this.name].create(
context,
entry
);
}
})
);
}
}

File Metadata

Mime Type
text/x-java
Expires
Sun, Jul 13, 04:51 (1 d, 14 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
801717
Default Alt Text
collection.ts (12 KB)

Event Timeline