Page MenuHomeSealhub

No OneTemporary

diff --git a/src/app/base-chips/field-types/cached-value.ts b/src/app/base-chips/field-types/cached-value.ts
index 50ca6c4f..e92fe6e7 100644
--- a/src/app/base-chips/field-types/cached-value.ts
+++ b/src/app/base-chips/field-types/cached-value.ts
@@ -1,308 +1,312 @@
import type {
App,
Field,
Context,
ValidationResult,
Collection,
CollectionItem,
ExtractFieldInput,
} from "../../../main.js";
import ItemList from "../../../chip-types/item-list.js";
import { BadContext } from "../../../response/errors.js";
import isEmpty from "../../../utils/is-empty.js";
import HybridField from "../../../chip-types/field-hybrid.js";
import {
CollectionRefreshCondition,
RefreshCondition,
} from "../../event-description.js";
import DerivedValue from "./derived-value.js";
type GetValue<DecodedValue> = (
context: Context,
item: CollectionItem
) => Promise<DecodedValue>;
type CachedValueSettings<InputType, DecodedType> = {
refresh_on: RefreshCondition[];
get_value: GetValue<DecodedType>;
initial_value: InputType | null;
derive_from?: string[];
};
export default class CachedValue<
DecodedType,
StorageType,
- T extends Field<any, any, any>
+ T extends Field<any, any, any>,
> extends HybridField<
DecodedType,
ExtractFieldInput<T>,
{ timestamp: number; value: StorageType },
DecodedType,
ExtractFieldInput<T>,
StorageType,
T
> {
typeName = "cached-value";
app: App;
base_field: T;
refresh_on: RefreshCondition[];
get_value: GetValue<DecodedType>;
hasDefaultValue: () => true;
private initial_value: ExtractFieldInput<T> | null;
private virtual_derived: DerivedValue<
DecodedType,
ExtractFieldInput<T>,
T
> | null = null; // sometimes it's necessary to have a field react to both the changes in local fields, as well as changes in cron/another collection
constructor(
base_field: T,
public params: CachedValueSettings<ExtractFieldInput<T>, DecodedType>
) {
super(base_field);
super.setParams(params);
this.refresh_on = params.refresh_on;
this.base_field = base_field;
}
async init(app: App, collection: Collection): Promise<void> {
this.get_value = this.params.get_value;
this.initial_value = this.params.initial_value;
if (this.params.derive_from) {
this.virtual_derived = new DerivedValue(this.base_field, {
fields: this.params.derive_from,
deriving_fn: this.params.get_value,
});
}
await super.init(app, collection);
await this.virtual_field.init(app, collection);
await this.virtual_derived?.init(app, collection);
this.checkForPossibleRecursiveEdits();
const create_action = this.refresh_on.find((condition) => {
return (
condition instanceof CollectionRefreshCondition &&
condition.event_names.some((name) => name.includes("create"))
);
});
if (
create_action &&
create_action instanceof CollectionRefreshCondition
) {
app.on("started", () =>
this.refresh_outdated_cache_values(app, create_action)
);
}
for (const condition of this.refresh_on) {
condition.attachTo(app, this.collection, async (arg) => {
- const cache_resource_ids = await condition.resource_id_getter(
- arg
- );
+ const cache_resource_ids =
+ await condition.resource_id_getter(arg);
if (!Array.isArray(cache_resource_ids)) {
throw new Error(
`resource_id_getter return value should be an array of strings, got: ${JSON.stringify(
cache_resource_ids
)}`
);
}
app.Logger.debug3("CACHED VALUE", "Inside hook", {
cache_resource_ids,
});
const promises = [];
const context = arg[0];
const { items } = await this.collection
.list(context)
.ids(cache_resource_ids)
.fetch();
const collection =
context.app.collections[this.collection.name];
for (const item of items) {
if (collection) {
promises.push(
this.get_value(context, item).then(
async (value) => {
const su_item = await collection.suGetByID(
item.id
);
su_item.set(this.name, value);
await su_item.save(new app.SuperContext());
}
)
);
} else {
throw new Error("Collection is missing");
}
}
await Promise.all(promises);
});
}
}
checkForPossibleRecursiveEdits(): void {
const doesAnyMatches = this.refresh_on.some(
(condition) =>
condition instanceof CollectionRefreshCondition &&
condition.collection_name === this.collection.name
);
if (doesAnyMatches) {
throw new Error(
"In the " +
this.collection.name +
" collection definition you've tried to create the " +
this.name +
" cached-value field that refers to the collection itself. Consider using 'derived-value' field type to avoid problems with endless recurrence."
);
}
}
private async refresh_outdated_cache_values(
app: App,
condition: CollectionRefreshCondition
) {
const referenced_collection_name = condition.collection_name;
app.Logger.debug3(
"CACHED VALUE",
`Finding resources without cached value for field ${this.collection.name}.${this.name}. For this, we're looking for items from ${referenced_collection_name} and we'll be looking at them newest-to-oldest.`
);
const collection = this.app.collections[referenced_collection_name];
if (!collection) {
throw new Error("referenced collection is missing");
}
const response = await new ItemList(
collection,
new this.app.SuperContext()
)
.sort({ "_metadata.modified_at": "desc" })
.paginate({ items: 1 })
.fetch();
if (response.empty) {
return;
}
const responseItem = response.items[0];
if (!responseItem) {
throw new Error("item is missing");
}
const last_modified_timestamp = responseItem._metadata.modified_at;
app.Logger.debug3(
"CACHED VALUE",
`Continuing searching for resources without cached value for field ${this.collection.name}.${this.name}. Now, we find resources that are potentially outdated.`
);
const outdated_resource_bodies = await this.app.Datastore.aggregate(
this.collection.name,
[
{
$match: {
$or: [
{
[`${this.name}.timestamp`]: {
$lt: last_modified_timestamp,
},
},
{ [this.name]: { $exists: false } },
],
},
},
]
);
this.app.Logger.debug3(
"CACHED",
"Outdated items",
outdated_resource_bodies
);
if (!outdated_resource_bodies) {
return;
}
const su_context = new this.app.SuperContext();
const { items } = await this.collection
.suList()
.ids(outdated_resource_bodies.map((b: { id: string }) => b.id))
.fetch();
for (const item of items) {
const value = await this.get_value(su_context, item);
const cache_value = await this.encode(su_context, value as any);
this.app.Logger.debug3(
"CACHED",
`New value for item ${item.id}.${this.name}`,
value
);
await this.app.Datastore.update(
this.collection.name,
{ id: item.id },
{ $set: { [this.name]: cache_value } }
);
}
}
async getDefaultValue(_: Context) {
return this.initial_value;
}
async encode(context: Context, new_value: ExtractFieldInput<T>) {
const encoded_value = await super.encode(context, new_value);
const ret = { timestamp: Date.now(), value: encoded_value };
context.app.Logger.debug3("CACHED VALUE", "Encode", { new_value, ret });
return ret as any;
}
async decode(
context: Context,
db_value: { timestamp: number; value: StorageType },
old_value: any,
format: any
) {
return super.decode(context, db_value.value as any, old_value, format);
}
async isProperValue(
context: Context,
new_value: Parameters<T["checkValue"]>[1],
old_value: Parameters<T["checkValue"]>[2],
new_value_blessing_token: symbol | null
): Promise<ValidationResult> {
if (this.virtual_derived) {
return this.virtual_derived.isProperValue(
context,
new_value,
old_value,
new_value_blessing_token
);
}
if (!isEmpty(new_value) && !context.is_super) {
throw new BadContext("This is a read-only field");
}
return this.virtual_field.checkValue(
context,
new_value,
old_value,
new_value_blessing_token
);
}
async getValuePath(): Promise<string> {
return `${this.name}.value`;
}
setName(name: string): void {
super.setName(name);
this.virtual_derived?.setName(name);
}
+
+ getPostgreSqlFieldDefinitions(): string[] {
+ const baseFieldsDefs = this.base_field.getPostgreSqlFieldDefinitions();
+ return [...baseFieldsDefs, `"${this.name}:timestamp" TIMESTAMP`];
+ }
}
diff --git a/src/app/base-chips/field-types/text.ts b/src/app/base-chips/field-types/text.ts
index 8797a232..1715b662 100644
--- a/src/app/base-chips/field-types/text.ts
+++ b/src/app/base-chips/field-types/text.ts
@@ -1,78 +1,78 @@
import { Field, Context } from "../../../main.js";
import TextStorage from "./text-storage.js";
export type TextParams = Partial<{
full_text_search: boolean;
min_length: number;
max_length: number;
}>;
/** A simple text field. Can support full text search and have a configurable min/max length.
*
* **All html-like chars are escaped, so none of them are interpreted as HTML**.
*
* Params:
* - `full_text_search` - `Boolean` - whether or not the DB should create a full text search for this field
* - `min_length` - `Number` - the text should have at least this many characters
* - `max_length` - `Number` - the text shuld have at most this many characters
*/
export default class Text extends TextStorage {
typeName = "text";
params: TextParams;
async getOpenApiSchema(context: Context): Promise<Record<string, unknown>> {
return {
...(await super.getOpenApiSchema(context)),
maxLength: this.params.max_length,
minLength: this.params.min_length,
};
}
/** Depends on the provided params
* @internal */
async hasIndex() {
if (this.params.full_text_search) {
return { original: "text" as const };
} else {
return false;
}
}
/** Checks if the input conforms with the constraints specified in the params
* @internal */
async isProperValue(context: Context, input: string) {
context.app.Logger.debug2("TEXT STORAGE", "isProperValue", input);
if (typeof input !== "string") {
return Field.invalid(
context.app.i18n("invalid_text", [input, typeof input])
);
}
if (this.params.min_length && input.length < this.params.min_length) {
return Field.invalid(
context.app.i18n("too_short_text", [
input,
this.params.min_length,
])
);
}
if (this.params.max_length && input.length > this.params.max_length) {
return Field.invalid(
context.app.i18n("too_long_text", [
input,
this.params.max_length,
])
);
}
return Field.valid();
}
getPostgreSqlFieldDefinitions(): string[] {
- return [`${this.name}_original TEXT`, `${this.name}_safe TEXT`];
+ return [`"${this.name}:original" TEXT`, `"${this.name}:safe" TEXT`];
}
/** Sets the params @ignore */
constructor(params: TextParams = {}) {
super();
this.params = params;
}
}
diff --git a/src/app/base-chips/field-types/value-existing-in-collection.ts b/src/app/base-chips/field-types/value-existing-in-collection.ts
index 4e6cc823..45bc6a1c 100644
--- a/src/app/base-chips/field-types/value-existing-in-collection.ts
+++ b/src/app/base-chips/field-types/value-existing-in-collection.ts
@@ -1,125 +1,130 @@
import Field from "../../../chip-types/field.js";
-import type { Context, App } from "../../../main.js";
+import { type Context, type App, SuperContext } from "../../../main.js";
import { OpenApiTypes } from "../../../schemas/open-api-types.js";
import type { ExtractTail } from "../../../utils/extract-tail.js";
export default class ValueExistingInCollection extends Field<unknown> {
typeName = "value-existing-in-collection";
target_field_name: string;
target_collection_name: string;
include_forbidden: boolean;
open_api_type = OpenApiTypes.NONE; // unknown without context :C
constructor(params: {
field: string;
collection: string;
include_forbidden: boolean;
}) {
super();
this.target_field_name = params.field;
this.target_collection_name = params.collection;
this.include_forbidden = params.include_forbidden;
}
async isProperValue(
context: Context,
new_value: unknown,
old_value: unknown
) {
const field = this.getField(context.app);
if (!field) {
throw new Error("field is missing");
}
const collection = field.collection;
const result = await field.checkValue(
context,
new_value,
old_value,
null
);
if (!result.valid) {
return result;
}
if (this.include_forbidden) {
context = new this.app.SuperContext();
}
const sealious_response = await collection
.list(context)
.filter({ [field.name]: new_value })
.fetch();
if (sealious_response.empty) {
return Field.invalid(
context.app.i18n("invalid_existing_value", [
collection.name,
field.name,
new_value,
])
);
}
return Field.valid();
}
getField(app: App) {
const targetCollection = app.collections[this.target_collection_name];
if (targetCollection) {
return targetCollection.fields[this.target_field_name];
} else {
throw new Error(
`target collection is missing: "${this.target_collection_name}"`
);
}
}
encode(
context: Context,
...args: ExtractTail<Parameters<Field<unknown>["encode"]>>
): Promise<unknown> {
const field = this.getField(context.app);
if (field) {
return field.encode(context, ...args);
} else {
throw new Error(`field is missing: "${this.target_field_name}"`);
}
}
decode(
context: Context,
...args: ExtractTail<Parameters<Field<unknown>["decode"]>>
) {
const field = this.getField(context.app);
if (field) {
return field.decode(context, ...args);
} else {
throw new Error(`field is missing: "${this.target_field_name}"`);
}
}
getMatchQueryValue(
context: Context,
...args: ExtractTail<Parameters<Field<unknown>["getMatchQueryValue"]>>
) {
const field = this.getField(context.app);
if (field) {
return field.getMatchQueryValue(context, ...args);
} else {
throw new Error("field is missing");
}
}
getAggregationStages(
context: Context,
...args: ExtractTail<Parameters<Field<unknown>["getMatchQueryValue"]>>
) {
const field = this.getField(context.app);
if (field) {
return field.getAggregationStages(context, ...args);
} else {
throw new Error("field is missing");
}
}
+
+ getPostgreSqlFieldDefinitions(): string[] {
+ const field = this.getField(this.app);
+ return field?.getPostgreSqlFieldDefinitions() || [];
+ }
}
diff --git a/src/app/base-chips/field-types/value-not-existing-in-collection.ts b/src/app/base-chips/field-types/value-not-existing-in-collection.ts
index f712b190..8afc49fd 100644
--- a/src/app/base-chips/field-types/value-not-existing-in-collection.ts
+++ b/src/app/base-chips/field-types/value-not-existing-in-collection.ts
@@ -1,37 +1,42 @@
import ValueExistingInCollection from "./value-existing-in-collection.js";
import { Context, Field } from "../../../main.js";
import { OpenApiTypes } from "../../../schemas/open-api-types.js";
export default class ValueNotExistingInCollection extends ValueExistingInCollection {
getTypeName = () => "value-not-existing-in-collection";
open_api_type = OpenApiTypes.NONE; // unknown without context :C
async isProperValue(
context: Context,
new_value: unknown,
old_value: unknown
) {
const field = this.getField(context.app);
if (!field) {
throw new Error("field is missing");
}
await field.checkValue(context, new_value, old_value, null);
if (this.include_forbidden) {
context = new this.app.SuperContext();
}
const sealious_response = await field.collection
.list(context)
.filter({ [field.name]: new_value })
.fetch();
if (!sealious_response.empty) {
return Field.invalid(
context.app.i18n("invalid_non_existing_value", [
field.collection.name,
field.name,
new_value,
])
);
}
return Field.valid();
}
+
+ getPostgreSqlFieldDefinitions(): string[] {
+ const baseFieldsDefs = super.getPostgreSqlFieldDefinitions();
+ return baseFieldsDefs.map((field) => `${field} UNIQUE`);
+ }
}
diff --git a/src/chip-types/collection.test.ts b/src/chip-types/collection.test.ts
index 7cbe3334..37c53f3e 100644
--- a/src/chip-types/collection.test.ts
+++ b/src/chip-types/collection.test.ts
@@ -1,735 +1,768 @@
import { hasShape, predicates } from "@sealcode/ts-predicates";
import assert from "assert";
import Int from "../app/base-chips/field-types/int.js";
import { App, CollectionItem, Context, FieldTypes, Policies } from "../main.js";
import { assertThrowsAsync } from "../test_utils/assert-throws-async.js";
import type { TestApp } from "../test_utils/test-app.js";
import {
type TestAppConstructor,
withRunningApp,
+ withStoppedApp,
} from "../test_utils/with-test-app.js";
import Collection, {
type FieldEntryMapping,
type Fieldnames,
type FieldToFeedMappingEntry,
} from "./collection.js";
import prettier from "prettier";
import { sleep } from "../test_utils/sleep.js";
type Policies = Collection["policies"];
function extend(t: TestAppConstructor<TestApp>, passedPolicies: Policies = {}) {
return class extends t {
collections = {
...App.BaseCollections,
coins: new (class extends Collection {
fields = { value: new Int() };
policies = passedPolicies;
})(),
};
};
}
describe("collection router", () => {
it("propertly responds to a GET request to list resources", async () =>
withRunningApp(extend, async ({ rest_api }) => {
await rest_api.post("/api/v1/collections/coins", { value: 2 });
const response = await rest_api.get("/api/v1/collections/coins");
if (
!hasShape(
{
items: predicates.array(
predicates.shape({
id: predicates.string,
value: predicates.number,
})
),
},
response
)
) {
throw new Error("Wrong reponse shape");
}
assert.ok(response.items[0]!.id);
assert.strictEqual(response.items[0]!.value, 2);
}));
});
describe("policy sharing for list and show", () => {
it("proper inheritance of list policy from show policy", () => {
return withRunningApp(
(t) => {
return extend(t, { show: new Policies.Noone() });
},
async ({ app }) => {
assert.strictEqual(
app.collections.coins.getPolicy("list") instanceof
Policies.Noone,
true
);
}
);
});
it("proper inheritance of show policy from list policy", () => {
return withRunningApp(
(t) => {
return extend(t, { list: new Policies.Noone() });
},
async ({ app }) => {
assert.strictEqual(
app.collections.coins.getPolicy("show") instanceof
Policies.Noone,
true
);
}
);
});
it("action policy is favoured over inherited policy", () => {
return withRunningApp(
(t) => {
return extend(t, {
list: new Policies.Noone(),
show: new Policies.LoggedIn(),
});
},
async ({ app }) => {
assert.strictEqual(
app.collections.coins.getPolicy("list") instanceof
Policies.Noone,
true
);
}
);
});
});
describe("types", () => {
it("throws a ts error when a required field is missing", () => {
// this test does not have to run in runitme, just creating a code structure to reflect the use case mentioned here: https://forum.sealcode.org/t/sealious-problem-z-typami/1399/3
return withRunningApp(
(t: TestAppConstructor<TestApp>) =>
class TheApp extends t {
collections = {
...App.BaseCollections,
withRequired:
new (class withRequired extends Collection {
fields = {
required: FieldTypes.Required(
new FieldTypes.Int()
),
};
})(),
};
},
async ({ app }) => {
await app.collections.withRequired.create(
new app.SuperContext(),
{ required: 2 } // try removing or renaming this property and you should get an error
);
await app.collections.withRequired.suCreate({ required: 2 });
}
);
});
it("doesn't throw a ts error when a non-required field is missing", () => {
return withRunningApp(
(t: TestAppConstructor<TestApp>) =>
class TheApp extends t {
collections = {
...App.BaseCollections,
withRequired:
new (class withRequired extends Collection {
fields = {
nonrequired: new FieldTypes.Int(),
};
})(),
};
},
async ({ app }) => {
await app.collections.withRequired.create(
new app.SuperContext(),
{}
);
}
);
});
});
describe("collection", () => {
+ describe("initFieldDetails", () => {
+ it("should throw error when field name contains colon", async () =>
+ withStoppedApp(
+ (t) =>
+ class extends t {
+ collections = {
+ ...App.BaseCollections,
+ patrons: new (class extends Collection {
+ fields = {
+ email: new FieldTypes.Email(),
+ "amount:monthly": new FieldTypes.Float(),
+ };
+ })(),
+ };
+ },
+
+ async ({ app }) => {
+ await assertThrowsAsync(
+ async () => {
+ await app.start();
+ },
+ async (e) => {
+ assert.strictEqual(
+ e.message,
+ "Field names cannot contain the `:` character."
+ );
+ }
+ );
+ }
+ ));
+ });
+
describe("removeByID", () => {
it("calls after:remove", async () =>
withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
test: new (class extends Collection {
fields = {};
})(),
};
},
async ({ app }) => {
let called = false;
app.collections.test.on("after:remove", async () => {
called = true;
});
const item = await app.collections.test.suCreate({});
await app.collections.test.suRemoveByID(item.id);
assert.strictEqual(called, true);
}
));
});
describe("getByID", () => {
it("throws an ugly error by default", async () => {
return withRunningApp(
(t) =>
extend(t, {
create: new Policies.Public(),
show: new Policies.Owner(),
}),
async ({ app }) => {
const collection = "coins";
const item = await app.collections[collection].suCreate({
value: 1,
});
const guest = await app.collections.users.suCreate({
password: "12345678",
username: "Adam",
});
const guestContext = new Context(
app,
new Date().getTime(),
guest.id
);
await assertThrowsAsync(
async () => {
await app.collections.coins.getByID(
guestContext,
item.id
);
},
async (e) => {
assert.strictEqual(
e.message,
`${collection}: id ${item.id} not found`
);
}
);
}
);
});
});
it("throws an nice error when ordered to", async () => {
return withRunningApp(
(t) =>
extend(t, {
create: new Policies.Public(),
show: new Policies.Owner(),
}),
async ({ app }) => {
const collection = "coins";
const item = await app.collections[collection].suCreate({
value: 1,
});
const guest = await app.collections.users.suCreate({
password: "12345678",
username: "Adam",
});
const guestContext = new Context(
app,
new Date().getTime(),
guest.id
);
await assertThrowsAsync(
async () => {
await app.collections.coins.getByID(
guestContext,
item.id,
true
);
},
async (e) => {
assert.strictEqual(
e.message,
guestContext.app.i18n("policy_owner_deny")
);
}
);
}
);
});
describe("upsert", () => {
it("creates new items and updates the old ones", () =>
withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
patrons: new (class extends Collection {
fields = {
email: new FieldTypes.Email(),
amount_monthly: new FieldTypes.Float(),
end_date: new FieldTypes.Date(),
};
})(),
};
},
async ({ app }) => {
await app.collections.patrons.upsert(
new app.SuperContext(),
"email",
[
{
email: "adam@example.com",
amount_monthly: 3,
end_date: "2023-12-24",
},
{
email: "eve@example.com",
amount_monthly: 7,
end_date: "2024-10-13",
},
]
);
await app.collections.patrons.upsert(
new app.SuperContext(),
"email",
[
{
email: "adam@example.com",
end_date: "2024-12-24", // one year later
},
]
);
const { items: patrons } = await app.collections.patrons
.suList()
.sort({ email: "asc" })
.fetch();
assert.strictEqual(patrons.length, 2);
assert.strictEqual(
patrons[0]!.get("email"),
"adam@example.com"
);
assert.strictEqual(
patrons[0]!.get("end_date"),
"2024-12-24"
);
assert.strictEqual(
patrons[1]!.get("email"),
"eve@example.com"
);
assert.strictEqual(patrons[1]!.get("amount_monthly"), 7);
}
));
it("updates only the resources that actually have any changes", async () =>
withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
patrons: new (class extends Collection {
fields = {
email: new FieldTypes.Email(),
amount_monthly: new FieldTypes.Float(),
end_date: new FieldTypes.Date(),
};
})(),
};
},
async ({ app }) => {
await app.collections.patrons.upsert(
new app.SuperContext(),
"email",
[
{
email: "adam@example.com",
amount_monthly: 3,
end_date: "2023-12-24",
},
{
email: "eve@example.com",
amount_monthly: 7,
end_date: "2024-10-13",
},
]
);
let edits = 0;
app.collections.patrons.on("before:edit", async () => {
edits++;
});
await app.collections.patrons.upsert(
new app.SuperContext(),
"email",
[
{
email: "adam@example.com",
amount_monthly: 3,
end_date: "2023-12-24",
},
]
);
assert.strictEqual(edits, 0);
await app.collections.patrons.upsert(
new app.SuperContext(),
"email",
[
{
email: "adam@example.com",
amount_monthly: 7,
end_date: "2023-12-24",
},
]
);
assert.strictEqual(edits, 1);
}
));
it("only sets the fields that hve changed, doesn't submit the fields whose value is same as before", async () =>
withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
patrons: new (class extends Collection {
fields = {
email: new FieldTypes.Email(),
amount_monthly: new FieldTypes.Float(),
end_date: new FieldTypes.Date(),
};
})(),
};
},
async ({ app }) => {
await app.collections.patrons.upsert(
new app.SuperContext(),
"email",
[
{
email: "adam@example.com",
amount_monthly: 3,
end_date: "2023-12-24",
},
]
);
let changes;
app.collections.patrons.on(
"before:edit",
async function ([context, item]) {
changes = await item.summarizeChanges(context);
}
);
await app.collections.patrons.upsert(
new app.SuperContext(),
"email",
[
{
email: "adam@example.com",
amount_monthly: 3,
end_date: "2023-12-24",
},
]
);
await app.collections.patrons.upsert(
new app.SuperContext(),
"email",
[
{
email: "adam@example.com",
amount_monthly: 7,
end_date: "2023-12-24",
},
]
);
assert.deepStrictEqual(changes, {
amount_monthly: { was: 3, is: 7 },
});
}
));
it("only sets the fields that hve changed, doesn't submit the fields whose value is same as before, even when they are encoded differently", async () =>
withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
patrons: new (class extends Collection {
fields = {
email: new FieldTypes.Email(),
amount_monthly: new FieldTypes.Float(),
end_date: new FieldTypes.Date(),
};
})(),
};
},
async ({ app }) => {
await app.collections.patrons.upsert(
new app.SuperContext(),
"email",
[
{
email: "adam@example.com",
amount_monthly: 3,
end_date: "2023-12-24",
},
]
);
let changes;
app.collections.patrons.on(
"before:edit",
async function ([context, item]) {
changes = await item.summarizeChanges(context);
}
);
await app.collections.patrons.upsert(
new app.SuperContext(),
"email",
[
{
email: "adam@example.com",
amount_monthly: "3",
end_date: "2023-12-24",
},
]
);
assert.deepStrictEqual(changes, undefined);
}
));
});
describe("atom feed", () => {
it("generates a valid XML feed for a list of blog items", async () =>
withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
posts: new (class extends Collection {
fields = {
title: new FieldTypes.Text(),
content: new FieldTypes.Text(),
};
})(),
};
},
async ({ app, rest_api }) => {
await app.collections.posts.upsert(
new app.SuperContext(),
"title",
[
{
title: "article 1",
content: "article 1 content",
},
]
);
// to maintain proper sorting based on timestamps
await sleep(100);
await app.collections.posts.upsert(
new app.SuperContext(),
"title",
[
{
title: "article 2",
content: "article 2 content",
},
]
);
const atom_feed = await rest_api.get(
"/api/v1/collections/posts/feed"
);
const normalizeXml = (xml: string) => {
return prettier.format(
xml
.replace(
/http:\/\/127\.0\.0\.1:\d+/g,
"http://127.0.0.1:PORT"
) // Normalize port numbers
.replace(
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g,
"TIMESTAMP"
)
.replace(
/\/posts\/[a-zA-Z0-9_-]+/g,
"/posts/ID"
) // Normalize post IDs in URLs
.replace(
/<id>[^<]+<\/id>/g,
"<id>http://127.0.0.1:PORT/api/v1/colections/posts/ID</id>"
),
{ parser: "html" }
); // Normalize timestamps
};
assert.strictEqual(
await normalizeXml(atom_feed),
await normalizeXml(
/* HTML */ `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>testing app / posts</title>
<link
href="http://127.0.0.1:33255/api/v1/collections/posts/feed"
rel="self"
/>
<id
>http://127.0.0.1:33255/api/v1/collections/posts/feed</id
>
<link href="http://127.0.0.1:33255" />
<updated>2025-03-09T14:29:57.639Z</updated>
<entry>
<title>article 2</title>
<link
href="http://127.0.0.1:33255/api/v1/collections/posts/Mh-6kc8F1fPOBb3eWso9Q"
/>
<id
>http://127.0.0.1:33255/api/v1/colections/posts/Mh-6kc8F1fPOBb3eWso9Q</id
>
<published
>2025-03-09T14:29:57.639Z</published
>
<updated
>2025-03-09T14:29:57.639Z</updated
>
<content type="xhtml">
<div
xmlns="http://www.w3.org/1999/xhtml"
>
article 2 content
</div>
</content>
<author>
<name>Unknown author</name>
</author>
</entry>
<entry>
<title>article 1</title>
<link
href="http://127.0.0.1:33255/api/v1/collections/posts/c40Rq8ahEs3-OgKJecsjg"
/>
<id
>http://127.0.0.1:33255/api/v1/colections/posts/c40Rq8ahEs3-OgKJecsjg</id
>
<published
>2025-03-09T14:29:57.638Z</published
>
<updated
>2025-03-09T14:29:57.638Z</updated
>
<content type="xhtml">
<div
xmlns="http://www.w3.org/1999/xhtml"
>
article 1 content
</div>
</content>
<author>
<name>Unknown author</name>
</author>
</entry>
</feed>`
)
);
}
));
it("lets the user modify the values of the atom feed entries", async () =>
withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
posts: new (class extends Collection {
readonly fields = {
title: new FieldTypes.Text(),
feedTitle: new FieldTypes.Text(),
content: new FieldTypes.Text(),
} as const;
mapFieldsToFeed(): FieldEntryMapping<this> {
return {
...super.mapFieldsToFeed(),
title: async (_ctx, item) =>
item.get("feedTitle") ||
item.get("title") ||
"Untitled",
};
}
})(),
};
},
async ({ app }) => {
await app.collections.posts.upsert(
new app.SuperContext(),
"title",
[
{
title: "article 1",
content: "article 1 content",
},
]
);
}
));
it("escapes the content of the atom feed html entry", async () =>
withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
posts: new (class extends Collection {
fields = {
title: new FieldTypes.Text(),
} as const;
mapFieldsToFeed(): FieldEntryMapping<this> {
return {
...super.mapFieldsToFeed(),
content: `<b>STRONG&nbsp;CONTENT</b>`,
};
}
})(),
};
},
async ({ app, rest_api }) => {
await app.collections.posts.upsert(
new app.SuperContext(),
"title",
[
{
title: "article 1",
},
]
);
const atom_feed = await rest_api.get(
"/api/v1/collections/posts/feed"
);
// both the < and the & need to be escaped
assert(
atom_feed.includes(
"&lt;b>STRONG&amp;nbsp;CONTENT&lt;/b>"
)
);
}
));
});
});
diff --git a/src/chip-types/collection.ts b/src/chip-types/collection.ts
index 1eef29a9..d95e1d6f 100644
--- a/src/chip-types/collection.ts
+++ b/src/chip-types/collection.ts
@@ -1,755 +1,756 @@
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";
import type { CollectionProperties } from "../schemas/generator.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;
/** Whether or not should be visible in docs */
internal = false;
named_filters: Record<string, SpecialFilter> = {};
calculated_fields: Record<string, CalculatedField<unknown>> = {};
/** initializes the fields @internal */
async initFieldDetails(): Promise<void> {
const promises = [];
+ // error jak zla nazwa
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]) {
throw new Error("field name is missing");
}
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 {
const filter = this.named_filters[filter_name];
if (filter) {
return filter;
} else {
throw new Error("filer is missing");
}
}
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 id = ctx.params.id;
if (!id) {
throw new Error("id is missing");
}
const [ret] = await this.list(ctx.$context)
.ids([id])
.safeFormat(ctx.query.format)
.fetch();
const format = ctx.query.format;
if (!ret) {
throw new Error("ret is missing");
}
await ret.safeLoadAttachments(
ctx.$context,
ctx.query.attachments,
typeof format == "object" && format ? format : {}
);
ctx.body = ret.serialize();
});
router.patch("/:id", parseBody(), async (ctx) => {
const id = ctx.params.id;
if (!id) {
throw new Error("id is missing");
}
const item = await this.getByID(ctx.$context, 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 id = ctx.params.id;
if (!id) {
throw new Error("id is missing");
}
const item = await this.getByID(ctx.$context, 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) => {
const id = ctx.params.id;
if (!id) {
throw new Error("id is missing");
}
await (await this.getByID(ctx.$context, 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 collection = context.app.collections[this.name];
if (!collection) {
throw new Error("collection is missing");
}
const {
items: [item],
} = await collection
.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 collection.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 of fields_to_try) {
const field = this.fields[field_name];
if (
field &&
["date", "datetime"].includes(field.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 of fields_to_try) {
const field = this.fields[field_name];
if (
field &&
["date", "datetime"].includes(field.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 latest_item_timestamp = items
.map((e) => e._metadata.modified_at)
.sort()
.reverse()[0];
const last_update = latest_item_timestamp
? new Date(latest_item_timestamp)
: new Date();
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
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")}
</div>
</content>
${data.author
.map(
(author) =>
/* HTML */ `<author>
<name>${author}</name>
</author>`
)
.join("\n")}
</entry>`;
})
)
).join("\n")}
</feed>`;
}
static async getOpenApiSubfieldsSchema(
context: Context,
fields: Record<string, Field<unknown>>
): Promise<CollectionProperties> {
const collectionSchema: CollectionProperties = {
type: "object",
properties: {},
required: [],
};
for (const [field_name, field] of Object.entries(fields)) {
collectionSchema.properties[field_name] =
// eslint-disable-next-line no-await-in-loop
await field.getOpenApiSchema(context);
if (field.required) collectionSchema.required?.push(field_name);
}
if (!collectionSchema.required?.length)
delete collectionSchema.required;
return collectionSchema;
}
async getOpenApiSchema(context: Context): Promise<CollectionProperties> {
return Collection.getOpenApiSubfieldsSchema(context, this.fields);
}
}
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]
>;
};
diff --git a/src/chip-types/field-base.ts b/src/chip-types/field-base.ts
index 2bcb2679..22aaaa3a 100644
--- a/src/chip-types/field-base.ts
+++ b/src/chip-types/field-base.ts
@@ -1,331 +1,334 @@
import type Collection from "./collection.js";
import type Context from "../context.js";
import type { ActionName } from "../action.js";
import type { QueryStage } from "../datastore/query-stage.js";
import type { MatchBody } from "../datastore/query-stage.js";
import { BadSubjectAction } from "../response/errors.js";
import isEmpty from "../utils/is-empty.js";
import type { App } from "../app/app.js";
import { ItemListResult } from "./item-list-result.js";
import { OpenApiTypeMapping, OpenApiTypes } from "../schemas/open-api-types.js";
export type Depromisify<T> = T extends Promise<infer V> ? V : T;
export type ExtractParams<F extends Field<unknown, unknown, unknown>> =
Parameters<F["setParams"]>[0];
export type ExtractFilterParams<F extends Field<unknown, unknown, unknown>> =
Parameters<F["getMatchQueryValue"]>[1];
export type ValidationResult = {
valid: boolean;
reason?: string;
};
export type ExtractFieldDecoded<F extends Field<unknown, unknown, unknown>> =
F extends Field<infer T, unknown, unknown> ? T : never;
export type ExtractFieldInput<F extends Field<unknown, unknown, unknown>> =
F extends Field<unknown, infer T, unknown> ? T : never;
export type ExtractFieldStorage<F extends Field<unknown, unknown, unknown>> =
F extends Field<unknown, unknown, infer T> ? T : never;
export type RequiredField<DecodedType, InputType, StorageType> = Field<
DecodedType,
InputType,
StorageType
> & { required: true };
/** The field class itself. Stores information on the field name, and
* methods that decide waht values are valid and how they are
* stored. The {@link Field} class describes a type of field in
* general (like "Text" and "Number"), and a {@link Field} instance
* describes one particular field in a collection (like "name" and
* "age").
*
* Extend this class to create fields with custom behavior.
*
* **The recommended way to create a field for a collection is {@link
* FieldDefinitionHelper}, as it performs type checking of the
* field params.**
*
* Some of the most useful field types include:
* * {@link Boolean}
* * {@link DateField | Date}
* * {@link Datetime}
* * {@link Email}
* * {@link Enum}
* * {@link FileField | Field}
* * {@link Float}
* * {@link Html}
* * {@link Image}
* * {@link Int}
* * {@link SingleReference}
* * {@link Text}
*/
export abstract class Field<
DecodedType,
InputType = DecodedType,
StorageType = DecodedType,
> {
/** the name of the field */
name: string;
/** the app that the field exists in
* @internal
*/
app: App;
/** The display hints specified for this field */
display_hints: any;
/** Whether or not the field handles large data
* @todo: see if there's any viability in storing this
*/
handles_large_data = false;
/** The collection this field is attached to */
collection: Collection;
/** Whether or not this field should always have a value. Creating
* a resource with a value missing for a required field will throw
* an error */
required: boolean;
/** Sets the collection @internal */
setCollection(collection: Collection) {
this.collection = collection;
}
setRequired(
required: boolean
): RequiredField<DecodedType, InputType, StorageType> {
this.required = required;
return this as RequiredField<DecodedType, InputType, StorageType>;
}
/** Sets the name @internal */
setName(name: string) {
+ if (name.includes(":")) {
+ throw new Error("Field names cannot contain the `:` character.");
+ }
this.name = name;
}
/** This method is used to set and process the params upon the
* field's creation when the app starts up. The type of argument
* of this method determines type checking that's performed by
* @{link FieldDefinitionHelper}. */
setParams(_: any): void {}
/** Return a summary of this field */
getSpecification() {
return {
name: this.name,
type: this.typeName,
display_hints: this.display_hints,
};
}
/** Whether or not this field should have a dedicated index in the
* database */
async hasIndex(): Promise<
boolean | "text" | { [subfield_name: string]: boolean | "text" }
> {
return false;
}
/** Value path is where inside a single record should the DB look
* for the field's value when filtering resources. Some fields use
* complex objects for storage and overwrite this method, and
* thanks to that they don't have to reimplement {@link
* Field.getAggregationStages} */
async getValuePath(): Promise<string> {
return this.name;
}
abstract typeName: string;
// base open api type - sets type & format
abstract open_api_type: OpenApiTypes;
// generates field schema - can be overriden to add extra fields
// (for example to add max_length & min_length fields to text types)
async getOpenApiSchema(context: Context): Promise<Record<string, unknown>> {
return {
...OpenApiTypeMapping[this.open_api_type],
default:
this.hasDefaultValue?.() &&
(await this.getDefaultValue(context)),
// nullable: ? // not sure if/when applicable
// readOnly: ? // not sure if/when applicable
};
}
protected abstract isProperValue(
context: Context,
new_value: unknown,
old_value: unknown,
new_value_blessing_token: symbol | null
): Promise<ValidationResult>;
public async checkValue(
context: Context,
new_value: unknown,
old_value: unknown,
new_value_blessing_token: symbol | null
): Promise<ValidationResult> {
if (isEmpty(new_value) && this.required) {
return Field.invalid(`Missing value for field '${this.name}'.`);
} else if (isEmpty(new_value)) {
return Field.valid();
} else {
return this.isProperValue(
context,
new_value,
old_value,
new_value_blessing_token
);
}
}
/** Decides how to store the given value in the database, based on
* the context and previous value of the field */
async encode(
_: Context,
value: InputType | null,
__?: any
): Promise<StorageType | null> {
return value as any;
}
/** Reverse to the {@link Field.encode} function. Takes what's inside the database and returns the value in a given format */
async decode(
context: Context,
storage_value: StorageType,
old_value: any,
format_params: any,
is_http_api_request = false
): Promise<DecodedType | null> {
context.app.Logger.debug3("FIELD DECODE", this.name, {
storage_value,
old_value,
});
return storage_value as unknown as DecodedType;
}
/** Generates a mongo query based on the filter value */
async getMatchQueryValue(context: Context, filter: any): Promise<any> {
return this.encode(context, filter);
}
async getMatchQuery(
context: Context,
filter: any,
value_path: string
): Promise<any> {
return {
[value_path]: await this.getMatchQueryValue(context, filter),
};
}
/** Whether or not the db should create a fulltext index on this field */
async fullTextSearchEnabled(): Promise<boolean> {
return false;
}
/** Whether or not a field has a default value - that is, a value
* given to the field if no value is provided */
hasDefaultValue() {
return true;
}
/** The default value that will be assigned to the field if no
* value is given */
async getDefaultValue(_: Context): Promise<InputType | null> {
return null;
}
/** Whether or not any of the methods of the field depend on the
* previous value of the field */
isOldValueSensitive(_: ActionName) {
return false;
}
/** Used to signal a positive decision from within {@link
* Field.isProperValue}. */
static valid(): ValidationResult {
return { valid: true };
}
/** Used to signal a negative decition from within {@link
* Field.isProperValue}. */
static invalid(reason: string): ValidationResult {
return { valid: false, reason };
}
/** Runs when the app is being started. Hooks can be set up within
* this function */
async init(app: App, collection: Collection): Promise<void> {
this.app = app;
this.collection = collection;
}
async getAttachments(
context: Context,
values: any[], // this method gets called once for multiple resources, to limit the number of queries. Field values of all the resources are passed in this array
attachment_options: any,
format_params: any
): Promise<ItemListResult<any>> {
if (attachment_options !== undefined) {
throw new BadSubjectAction(
`Field '${this.name}' does not support attachments`
);
}
return new ItemListResult([], [], {});
}
/** Creates parts of a Mongo Pipieline that will be used to filter
* the items when listing items of a collection */
async getAggregationStages(
context: Context,
field_filter: unknown
): Promise<QueryStage[]> {
context.app.Logger.debug2(
"FIELD",
`${this.name}.getAggregationStages`,
field_filter
);
if (field_filter === undefined) return [];
const value_path = await this.getValuePath();
let $match: MatchBody = {};
if (field_filter === null) {
$match = {
$or: [
{ [value_path]: { $exists: false } },
{ [value_path]: null },
],
};
} else if (field_filter instanceof Array) {
$match = {
[value_path]: {
$in: await Promise.all(
field_filter.map((value) => this.encode(context, value))
),
},
};
} else {
$match = await this.getMatchQuery(
context,
field_filter,
await this.getValuePath()
);
context.app.Logger.debug3("FIELD", "getAggregationStages", {
value_path,
$match,
field_type: this.typeName,
});
}
return [{ $match }];
}
getPostgreSqlFieldDefinitions(): string[] {
return [`"${this.name}" JSONB`];
}
}
diff --git a/src/chip-types/field-hybrid.ts b/src/chip-types/field-hybrid.ts
index d35d7be9..0d03bcfd 100644
--- a/src/chip-types/field-hybrid.ts
+++ b/src/chip-types/field-hybrid.ts
@@ -1,117 +1,121 @@
import Field from "./field.js";
import type Context from "../context.js";
import type { App, Collection, ItemListResult } from "../main.js";
import { OpenApiTypes } from "../schemas/open-api-types.js";
/*
A hybrid field is one that takes a field type as a param. All
uncustomized methods should be taken from that given field type
*/
export default abstract class HybridField<
ParsedType,
InputType,
StorageType,
InnerParsedType,
InnerInputType,
InnerStorageType,
T extends Field<InnerParsedType, InnerInputType, InnerStorageType>,
> extends Field<ParsedType, InputType, StorageType> {
virtual_field: T;
open_api_type = OpenApiTypes.NONE;
async getOpenApiSchema(context: Context): Promise<Record<string, unknown>> {
return await this.virtual_field.getOpenApiSchema(context);
}
constructor(base_field: T) {
super();
this.virtual_field = base_field;
this.open_api_type = base_field.open_api_type;
}
setName(name: string) {
super.setName(name);
this.virtual_field.setName(name);
}
setCollection(collection: Collection) {
super.setCollection(collection);
this.virtual_field.setCollection(collection);
}
async encode(
context: Context,
value: InputType | null,
old_value?: InputType
) {
return this.virtual_field.encode(
context,
value as unknown as InnerInputType,
old_value
) as StorageType;
}
async getMatchQueryValue(context: Context, filter: any) {
return this.virtual_field.getMatchQueryValue(context, filter);
}
async getMatchQuery(context: Context, filter: any) {
return this.virtual_field.getMatchQuery(
context,
filter,
await this.getValuePath()
);
}
async isProperValue(
context: Context,
new_value: Parameters<T["checkValue"]>[1],
old_value: Parameters<T["checkValue"]>[2],
new_value_blessing_token: symbol | null
) {
return this.virtual_field.checkValue(
context,
new_value,
old_value,
new_value_blessing_token
);
}
async decode(
context: Context,
storage_value: StorageType,
old_value: Parameters<T["decode"]>[2],
format: Parameters<T["decode"]>[3]
) {
return this.virtual_field.decode(
context,
storage_value as unknown as InnerStorageType,
old_value,
format
) as ParsedType;
}
async getAttachments(
context: Context,
values: any[],
attachment_options: any,
format: Parameters<T["decode"]>[3]
): Promise<ItemListResult<any>> {
return this.virtual_field.getAttachments(
context,
values,
attachment_options,
format
);
}
async init(app: App, collection: Collection) {
await super.init(app, collection);
await this.virtual_field.init(app, collection);
}
+
+ getPostgreSqlFieldDefinitions(): string[] {
+ return this.virtual_field.getPostgreSqlFieldDefinitions();
+ }
}
diff --git a/src/datastore/datastore-postgres.test.ts b/src/datastore/datastore-postgres.test.ts
index 4cce3a4d..a120f902 100644
--- a/src/datastore/datastore-postgres.test.ts
+++ b/src/datastore/datastore-postgres.test.ts
@@ -1,107 +1,107 @@
import assert from "assert";
import { Collection, FieldTypes } from "../main.js";
import { TestApp } from "../test_utils/test-app.js";
import { withRunningApp } from "../test_utils/with-test-app.js";
import PostrgresDatastore from "./datastore-postgres.js";
import PostgresClient from "./postgres-client.js";
describe("datastorepostgres", () => {
it("should connect to database", async () =>
withRunningApp(null, async ({ app }) => {
const config = app.ConfigManager.get("datastore_postgres");
if (!config) {
assert.ok(false);
}
await PostrgresDatastore.executePlainQuery(
app,
config,
`DROP DATABASE IF EXISTS "${config.db_name}"`
);
const datastore = new PostrgresDatastore(app);
await datastore.start();
await datastore.stop();
}));
it("should create a table from the collection when table is missing", async () =>
withRunningApp(
(test_app) => {
return class extends test_app {
collections = {
...TestApp.BaseCollections,
dogs: new (class extends Collection {
fields = {
name: new FieldTypes.Text(),
age: new FieldTypes.Int(),
};
})(),
};
};
},
async ({ app }) => {
const config = app.ConfigManager.get("datastore_postgres");
if (!config) {
assert.ok(false);
}
await PostrgresDatastore.executePlainQuery(
app,
config,
`DROP DATABASE IF EXISTS "${config.db_name}"`
);
const datastore = new PostrgresDatastore(app);
await datastore.start();
await datastore.stop();
const tmpClient = new PostgresClient({
password: config.password,
database: config.db_name,
user: config.username,
host: config.host,
port: config.port,
});
await tmpClient.connect();
const [tablesListResponse, dogTableResponse] =
await Promise.all([
tmpClient.executeQuery(
app,
`SELECT * FROM information_schema.tables WHERE table_schema NOT IN ('information_schema', 'pg_catalog') AND table_type = 'BASE TABLE'`
),
tmpClient.executeQuery(
app,
`SELECT * FROM information_schema.columns WHERE table_name = 'dogs';`
),
]);
const tables = tablesListResponse.rows.map(
(row) => row.table_name
);
const columns = dogTableResponse.rows.map(
(row) => row.column_name
);
const dataTypes = dogTableResponse.rows.map(
(row) => row.data_type
);
assert.deepEqual(tables, [
"users",
"sessions",
"long_running_processes",
"long_running_process_events",
"dogs",
]);
assert.deepEqual(columns, [
"age",
- "name_original",
- "name_safe",
+ "name:original",
+ "name:safe",
]);
assert.ok(dataTypes.includes("integer"));
assert.ok(dataTypes.includes("text"));
await tmpClient.end();
}
));
});
diff --git a/src/http/http.ts b/src/http/http.ts
index 108ace43..e50779fb 100644
--- a/src/http/http.ts
+++ b/src/http/http.ts
@@ -1,53 +1,53 @@
import { default as Koa } from "koa";
import Static from "koa-static";
import Router from "@koa/router";
import type { Server } from "http";
import mount from "koa-mount";
import installQS from "koa-qs";
import handleError from "./handle-error.js";
import type { App } from "../main.js";
export default class HttpServer {
name: "www-serer";
private server: Server;
koa: Koa;
router: Router;
config: {
port: number;
"session-cookie-name": string;
"max-payload-bytes": number;
"api-base": string;
};
constructor(public app: App) {
this.koa = new Koa();
installQS(this.koa);
this.koa.context.$app = this.app;
this.router = new Router();
// const rest_url_base = this.config["api-base"];
}
async start(): Promise<void> {
this.koa.use(handleError());
this.koa.use(this.router.routes());
this.config = this.app.ConfigManager.get("www-server");
this.server = this.koa.listen(this.config.port);
this.app.Logger.info(
"STARTED",
`App running. URL set in manifest: ${this.app.manifest.base_url}`
);
}
async stop(): Promise<void> {
- this.server.close();
+ this.server?.close();
}
addStaticRoute(url_path: string, local_path: string): void {
this.koa.use(mount(url_path, Static(local_path)));
}
}

File Metadata

Mime Type
text/x-diff
Expires
Tue, Jul 8, 07:36 (9 h, 23 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
810381
Default Alt Text
(72 KB)

Event Timeline