Page MenuHomeSealhub

collection.ts
No OneTemporary

collection.ts

import type Koa from "koa";
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, { type 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, { type SortParams } 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 T["fields"] & 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);
promises.push(field.init(this.app, this));
field.setName(field_name);
}
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(["/feed"], async (ctx) => {
ctx.type = "text/xml";
ctx.body = await this.getFeed(ctx);
});
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
);
}
})
);
}
hasFeed(): boolean {
return Object.keys(this.fields).includes("title");
}
// how many items to include
async getFeedSize(_ctx: Koa.Context): Promise<number> {
return 50;
}
async getFeedSortOrder(_ctx: Koa.Context): Promise<SortParams<this>> {
return {
"_metadata.modified_at": "desc" as const,
} as SortParams<this>;
}
async getFeedItems(ctx: Koa.Context): Promise<CollectionItem<this>[]> {
const { items } = await this.list(ctx.$context)
.sort(await this.getFeedSortOrder(ctx))
.paginate({ items: await this.getFeedSize(ctx) })
.fetch();
return items;
}
mapFieldsToFeed(): FieldEntryMapping<this> {
return {
title: async (_, item) => {
return (
item.get("title" as unknown as Fieldnames<this>) ||
"Unknown title"
);
},
link: async (ctx, item) => {
return [
{
href: `${ctx.$app.manifest.base_url}/api/v1/collections/${this.name}/${item.id}`,
},
];
},
author: async (_, item) => {
return [
item.get("author" as unknown as Fieldnames<this>) ||
"Unknown author",
];
},
id: async (ctx, item) => {
return (
`${ctx.$app.manifest.base_url}/api/v1/colections/${this.name}/${item.id}` ||
"Unknown id"
);
},
content: async (_, item) => {
return (
item.get("content" as unknown as Fieldnames<this>) ||
"Unknown content"
);
},
published: async (_, item) => {
const fields_to_try = [
"published",
"publishedDate",
"publishDate",
"publish_date",
"published_date",
"date",
];
for (const field_name in fields_to_try) {
if (
Object.keys(this.fields).includes(field_name) &&
["date", "datetime"].includes(
this.fields[field_name].typeName
)
) {
const value = item.get(
field_name as unknown as Fieldnames<this>
);
if (value) {
return new Date(value);
}
}
}
return new Date(item._metadata.created_at);
},
updated: async (_, item) => {
const fields_to_try = [
"modified",
"modifiedDate",
"modified_date",
"last_modified",
"lastModifiedDate",
"last_modified_date",
];
for (const field_name in fields_to_try) {
if (
Object.keys(this.fields).includes(field_name) &&
["date", "datetime"].includes(
this.fields[field_name].typeName
)
) {
const value = item.get(
field_name as unknown as Fieldnames<this>
);
if (value) {
return new Date(value);
}
}
}
return new Date(item._metadata.created_at);
},
};
}
async getFeedTitle(ctx: Koa.Context) {
return `${ctx.$app.manifest.name} / ${this.name}`;
}
async getFeedItemData(
ctx: Koa.Context,
item: CollectionItem<this>
): Promise<FeedEntryShape> {
const mapping = this.mapFieldsToFeed();
return Object.fromEntries(
await Promise.all(
Object.entries(mapping).map(async ([key, value]) => {
if (typeof value == "function") {
return [key, await value(ctx, item)];
} else {
return [key, value];
}
})
)
);
}
async getFeed(ctx: Koa.Context): Promise<string> {
const items = await this.getFeedItems(ctx);
const last_update = new Date(
items
.map((e) => e._metadata.modified_at)
.sort()
.reverse()[0]
);
return /* HTML */ `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${await this.getFeedTitle(ctx)}</title>
<link
href="${ctx.$app.manifest
.base_url}/api/v1/collections/${this.name}/feed"
rel="self"
/>
<id
>${ctx.$app.manifest.base_url}/api/v1/collections/${this
.name}/feed</id
>
<link href="${ctx.$app.manifest.base_url}" />
<updated>${last_update.toISOString()}</updated>
${(
await Promise.all(
items.map(async (item) => {
const data = await this.getFeedItemData(ctx, item);
return /* HTML */ `<entry>
<title>${data.title}</title>
${data.link
.map(
({
rel,
type,
href,
}) => /* HTML */ `<link
${rel ? `rel="${rel}"` : ""}
${type ? `type="${type}"` : ""}
href="${href}"
/>`
)
.join("\n")}
<id>${data.id}</id>
<published
>${data.published.toISOString()}</published
>
<updated>${data.updated.toISOString()}</updated>
<content type="xhtml">
<div xmlns="http://www.w3.org/1999/xhtml">
${data.content}
</div>
</content>
${data.author
.map(
(author) => /* HTML */ `<author>
<name>${author}</name>
</author>`
)
.join("\n")}
</entry>`;
})
)
).join("\n")}
</feed>`;
}
}
export type FieldToFeedMappingEntry<C extends Collection, T> =
| T
| ((context: Koa.Context, item: CollectionItem<C>) => Promise<T>);
export type FeedEntryShape = {
title: string;
link: { rel?: string; type?: string; href: string }[];
author: string[];
category?: string[];
id: string;
content: string;
published: Date;
updated: Date;
};
export type FieldEntryMapping<C extends Collection> = {
[Property in keyof FeedEntryShape]: FieldToFeedMappingEntry<
C,
FeedEntryShape[Property]
>;
};

File Metadata

Mime Type
text/x-java
Expires
Wed, May 7, 19:45 (1 d, 3 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
675435
Default Alt Text
collection.ts (17 KB)

Event Timeline