Page MenuHomeSealhub

No OneTemporary

diff --git a/src/app/base-chips/field-types/disallow-update.test.ts b/src/app/base-chips/field-types/disallow-update.test.ts
index dbb8b8bb..61bbd534 100644
--- a/src/app/base-chips/field-types/disallow-update.test.ts
+++ b/src/app/base-chips/field-types/disallow-update.test.ts
@@ -1,128 +1,128 @@
import {
Field,
Context,
Collection,
FieldTypes,
Policies,
} from "../../../main.js";
import assert from "assert";
import {
type TestAppConstructor,
withRunningApp,
} from "../../../test_utils/with-test-app.js";
import { assertThrowsAsync } from "../../../test_utils/assert-throws-async.js";
import { TestApp } from "../../../test_utils/test-app.js";
import { OpenApiTypes } from "../../../schemas/open-api-types.js";
const url = "/api/v1/collections/constseals";
class NullOrFive extends Field<number> {
typeName = "null-or-five";
open_api_type = OpenApiTypes.NONE;
async isProperValue(_: Context, new_value: any, __: any) {
if (new_value === null || new_value === 5) {
return Field.valid();
}
return Field.invalid("Null or five, you got it?");
}
}
function extend(t: TestAppConstructor) {
return class extends t {
collections = {
...TestApp.BaseCollections,
constseals: new (class extends Collection {
fields = {
age: new FieldTypes.DisallowUpdate(
new FieldTypes.Int({ min: 0 })
),
attribute: new FieldTypes.DisallowUpdate(new NullOrFive()),
};
defaultPolicy = new Policies.Public();
})(),
};
};
}
describe("disallow-update", () => {
it("Respects target field type", () =>
withRunningApp(extend, async ({ app, rest_api }) => {
const age = "abc";
await assertThrowsAsync(
() => rest_api.post(url, { age: "abc", attribute: 5 }),
(error) => {
assert.deepStrictEqual(
error.response.data.data.field_messages.age.message,
"Value 'abc' is not a int number format."
);
}
);
}));
it("Respects target field params", () =>
withRunningApp(extend, async ({ app, rest_api }) => {
const age = -2;
await assertThrowsAsync(
() => rest_api.post(url, { age: age }),
(error) =>
assert.deepEqual(
error.response.data.data.field_messages.age.message,
"Value -2 should be larger than or equal to 0."
)
);
}));
it("Initially allows to insert a value", () =>
withRunningApp(extend, async ({ rest_api }) => {
await rest_api.post(url, { age: 2, attribute: 5 });
}));
it("Rejects a new value if there's an old value", () =>
withRunningApp(extend, async ({ app, rest_api }) => {
const { id } = await rest_api.post(url, {
age: 18,
attribute: null,
});
await assertThrowsAsync(
() => rest_api.patch(`${url}/${id}`, { age: 21 }),
(error) =>
assert.deepEqual(
error.response.data.data.field_messages.age.message,
`You cannot change a previously set value.`
)
);
}));
it("Rejects a new value if the old value is `null`", () =>
withRunningApp(extend, async ({ app, rest_api }) => {
const { id } = await rest_api.post(url, {
age: 21,
attribute: null,
});
await assertThrowsAsync(
() => rest_api.patch(`${url}/${id}`, { attribute: 5 }),
(error) => {
assert.deepEqual(
error.response.data.data.field_messages.attribute
.message,
`You cannot change a previously set value.`
);
}
);
}));
- it("rejects a new value if the old value is `null`", () =>
+ it("rejects a new null value if the old value is non-null", () =>
withRunningApp(extend, async ({ app }) => {
const item = await app.collections.constseals.create(
new app.Context(),
{
age: 33,
attribute: 5,
}
);
console.log("created");
item.set("age", null);
await assertThrowsAsync(() => item.save(new app.Context()));
}));
});
diff --git a/src/app/base-chips/field-types/settable-by.test.ts b/src/app/base-chips/field-types/settable-by.test.ts
index 3b5172b4..8a54dd86 100644
--- a/src/app/base-chips/field-types/settable-by.test.ts
+++ b/src/app/base-chips/field-types/settable-by.test.ts
@@ -1,295 +1,321 @@
import assert from "assert";
import { assertThrowsAsync } from "../../../test_utils/assert-throws-async.js";
import {
type TestAppConstructor,
withRunningApp,
} from "../../../test_utils/with-test-app.js";
import {
App,
Collection,
Context,
FieldTypes,
Policies,
Policy,
QueryTypes,
} from "../../../main.js";
import { TestApp } from "../../../test_utils/test-app.js";
import { post } from "../../../test_utils/http_request.js";
import Users from "../../collections/users.js";
function extend(t: TestAppConstructor) {
return class extends t {
collections = {
...TestApp.BaseCollections,
"forbidden-collection": new (class extends Collection {
fields = {
any: new FieldTypes.SettableBy(
new FieldTypes.Int(),
new Policies.Noone()
),
};
})(),
"allowed-collection": new (class extends Collection {
fields = {
any: new FieldTypes.SettableBy(
new FieldTypes.Int(),
new Policies.Public()
),
};
})(),
};
};
}
describe("settable-by", () => {
it("should not allow any value when rejected by access strategy", async () =>
withRunningApp(extend, async ({ base_url }) => {
await assertThrowsAsync(
() =>
post(
`${base_url}/api/v1/collections/forbidden-collection`,
{
any: "thing",
}
),
(e) =>
assert.equal(
e.response.data.data.field_messages.any.message,
`Noone is allowed.`
)
);
}));
it("should allow proper value when accepted by access strategy", async () =>
withRunningApp(extend, async ({ base_url, rest_api }) => {
const response = await post(
`${base_url}/api/v1/collections/allowed-collection`,
{
any: 1,
}
);
assert.equal(response.any, 1);
const {
items: [created_item],
} = await rest_api.get(
`/api/v1/collections/allowed-collection/${response.id}`
);
assert.equal(created_item.any, 1);
}));
it("should not allow invalid value when access strategy allows", async () =>
withRunningApp(extend, async ({ base_url }) => {
const value = "thing";
await assertThrowsAsync(
() =>
post(`${base_url}/api/v1/collections/allowed-collection`, {
any: value,
}),
(e) => {
assert.equal(
e.response.data.data.field_messages.any.message,
`Value '${value}' is not a int number format.`
);
}
);
}));
it("uses the transition checker from the inner field", async () => {
await withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
history: new (class extends Collection {
fields = {
title: new FieldTypes.Text(),
timestamp: new FieldTypes.SettableBy(
new FieldTypes.Int().setTransitionChecker(
async ({ old_value, new_value }) => {
return old_value == undefined ||
parseInt(String(new_value)) >
old_value
? { valid: true }
: {
valid: false,
reason: "timestamps cannot go back in time",
};
}
),
new Policies.Public()
),
};
})(),
};
},
async ({ app }) => {
const event = await app.collections.history.suCreate({
timestamp: 0,
});
event.set("timestamp", 1);
await event.save(new app.SuperContext());
await assertThrowsAsync(async () => {
event.set("timestamp", 0);
await event.save(new app.SuperContext());
});
}
);
});
it("allows creating an item with an empty value when setting the value is forbidden", async () => {
class Roles extends Policy {
static type_name = "roles";
allowed_roles: string[];
constructor(allowed_roles: string[]) {
super(allowed_roles);
this.allowed_roles = allowed_roles;
}
async countMatchingRoles(context: Context) {
const user_id = context.user_id as string;
context.app.Logger.debug2(
"ROLES",
"Checking the roles for user",
user_id
);
const roles = await context.getRoles();
return this.allowed_roles.filter((allowed_role) =>
roles.includes(allowed_role)
).length;
}
async _getRestrictingQuery(context: Context) {
if (context.is_super) {
return new QueryTypes.AllowAll();
}
if (context.user_id === null) {
return new QueryTypes.DenyAll();
}
const matching_roles_count =
await this.countMatchingRoles(context);
return matching_roles_count > 0
? new QueryTypes.AllowAll()
: new QueryTypes.DenyAll();
}
async checkerFunction(context: Context) {
if (context.user_id === null) {
return Policy.deny(`You are not logged in.`);
}
const matching_roles_count =
await this.countMatchingRoles(context);
return matching_roles_count > 0
? Policy.allow(
`You have one of the roles: ${this.allowed_roles.join(", ")}.`
)
: Policy.deny(
`You don't have any of the roles: ${this.allowed_roles.join(", ")}.`
);
}
}
class _Users extends Users {
fields = {
...App.BaseCollections.users.fields,
email: new FieldTypes.Email().setRequired(true),
roles: new FieldTypes.SettableBy(
new FieldTypes.StructuredArray({
role: new FieldTypes.Text(),
}),
new Roles(["admin"])
),
};
defaultPolicy = new Policies.Or([
new Policies.Themselves(),
new Roles(["admin"]),
]);
policies = {
create: new Policies.Public(),
show: new Policies.Or([
new Policies.Themselves(),
new Roles(["admin"]),
]),
};
}
await withRunningApp(
(t) => {
return class extends t {
collections = {
...App.BaseCollections,
users: new _Users(),
history: new (class extends Collection {
fields = {
title: new FieldTypes.Text(),
event: new FieldTypes.SettableBy(
new FieldTypes.StructuredArray({
type: new FieldTypes.Text(),
}),
new Roles(["admin"])
),
};
})(),
};
};
},
async ({ app }) => {
const user = await app.collections.users.suCreate({
username: "test",
password: "testtest",
email: "test@test.com",
});
const context = new app.Context({ user_id: user.id });
await app.collections.history.create(context, {
title: "Some title",
});
}
);
});
it("cooperates with item-sensitive access policies", async () => {
await withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
history: new (class extends Collection {
fields = {
title: new FieldTypes.Text(),
label: new FieldTypes.SettableBy(
new FieldTypes.Text(),
new Policies.Owner()
),
};
})(),
};
},
async ({ app }) => {
const user1 = await app.collections.users.suCreate({
username: "user1",
});
const user2 = await app.collections.users.suCreate({
username: "user2",
});
const event = await app.collections.history.create(
new app.Context({ user_id: user1.id }),
{ title: "some event" }
);
await assertThrowsAsync(async () => {
event.set("label", "some label from another person");
await event.save(new app.Context({ user_id: user2.id }));
});
}
);
});
+
+ it("allows creating a resource when passing an undefined value to a non-settable field", async () => {
+ await withRunningApp(
+ (t) =>
+ class extends t {
+ collections = {
+ ...App.BaseCollections,
+ history: new (class extends Collection {
+ fields = {
+ title: new FieldTypes.Text(),
+ label: new FieldTypes.SettableBy(
+ new FieldTypes.Text(),
+ new Policies.Owner()
+ ),
+ };
+ })(),
+ };
+ },
+ async ({ app }) => {
+ await app.collections.history.create(new app.Context(), {
+ title: "some event",
+ label: undefined, // this is important - we're testing what happens when the key is present, but the value set explicitly to undefined. This should not throw an error, but just ignore the label value
+ });
+ }
+ );
+ });
});
diff --git a/src/chip-types/collection-item.ts b/src/chip-types/collection-item.ts
index 026fa163..b8056c8c 100644
--- a/src/chip-types/collection-item.ts
+++ b/src/chip-types/collection-item.ts
@@ -1,543 +1,543 @@
import Collection, {
type CollectionValidationResult,
type Fieldnames,
} from "./collection.js";
import type Context from "../context.js";
import {
DeveloperError,
BadContext,
ValidationError,
FieldsError,
} from "../response/errors.js";
import { nanoid } from "nanoid";
import type { AttachmentOptions } from "./item-list.js";
import type { PolicyDecision } from "./policy.js";
import isEmpty from "../utils/is-empty.js";
import type { Fieldset, FieldsetInput, FieldsetOutput } from "./fieldset.js";
import { CollectionItemBody } from "./collection-item-body.js";
import type { ItemListResult } from "./item-list-result.js";
import { FieldValue } from "../app/base-chips/field-types/field-value.js";
export type ItemMetadata = {
modified_at: number;
created_at: number;
created_by: string | null;
};
export type SerializedItem = ReturnType<CollectionItem["serialize"]>;
export type SerializedItemBody = ReturnType<CollectionItem["serializeBody"]>;
export default class CollectionItem<T extends Collection = any> {
id: string;
fields_with_attachments: string[] = [];
attachments: Record<string, CollectionItem>;
private attachments_loaded = false;
private save_mode: "update" | "insert" = "insert";
public original_body: CollectionItemBody;
public has_been_replaced = false;
public parent_list: ItemListResult<T> | null;
constructor(
public collection: T,
public body: CollectionItemBody,
public _metadata: ItemMetadata = {
modified_at: Date.now(),
created_at: Date.now(),
created_by: null,
},
id?: string,
attachments?: Record<string, CollectionItem>
) {
collection.app.Logger.debug3("ITEM", "Creating an item from fieldset", {
body,
});
this.id = nanoid();
if (id) {
this.id = id;
this.save_mode = "update";
}
if (attachments) {
this.attachments_loaded = true;
}
this.original_body = body.copy();
this.attachments = attachments || {};
}
/** Checks whether or not it is allowed to save the given item to the DB using given context */
private async canSave(context: Context): Promise<PolicyDecision> {
const action = this.save_mode === "insert" ? "create" : "edit";
return this.collection
.getPolicy(action)
.check(context, async () => this);
}
private async canDelete(context: Context): Promise<PolicyDecision> {
return this.collection
.getPolicy("delete")
.check(context, async () => this);
}
public async throwIfInvalid(
action: "create" | "edit",
context: Context,
replace_mode: boolean //if true, meaning that if a field has no value, it should be deleted
) {
context.app.Logger.debug3("ITEM", "Saving item/about to validate");
const { valid, errors } = await this.body.validate(
context,
this.original_body,
replace_mode,
this
);
- await this.gatherDefaultValues(context);
context.app.Logger.debug3("ITEM", "Saving item/validation result", {
valid,
errors,
});
+ await this.gatherDefaultValues(context);
if (!valid) {
throw new FieldsError(this.collection, errors);
}
const collection_validation_errors = await this.collection.validate(
context,
this.body,
this.original_body,
action
);
if (collection_validation_errors.length > 0) {
const field_messages = {} as Record<string, string[]>;
const other_messages = [] as string[];
for (const collection_validation_error of collection_validation_errors) {
if ((collection_validation_error.fields || []).length == 0) {
other_messages.push(collection_validation_error.error);
} else {
for (const field of collection_validation_error.fields ||
[]) {
if (!field_messages[field]) {
field_messages[field] = [];
}
field_messages[field]!.push(
collection_validation_error.error
);
}
}
}
throw new FieldsError(
this.collection,
Object.fromEntries(
Object.entries(field_messages).map(([field, value]) => [
field,
{ message: value.join(" | ") },
])
),
other_messages
);
}
}
/** Save the item to the database */
async save(context: Context, is_http_api_request = false) {
context.app.Logger.debug2("ITEM", "Saving item", this.body);
const can_save = await this.canSave(context);
if (can_save === null) {
throw new DeveloperError("Policy didn't give a verdict");
}
if (!can_save?.allowed) {
throw new BadContext(can_save.reason);
}
if (this.save_mode === "insert") {
this._metadata = {
created_at: Date.now(),
modified_at: Date.now(),
created_by: context.user_id,
};
await this.collection.emit("before:create", [context, this]);
await this.throwIfInvalid("create", context, true);
const encoded = await this.body.encode(context, {});
context.app.Logger.debug3("ITEM", "creating a new item", {
metadata: this._metadata,
});
await context.app.Datastore.insert(this.collection.name, {
id: this.id,
...encoded,
_metadata: this._metadata,
});
await this.decode(context, {}, is_http_api_request);
this.save_mode = "update";
await this.collection.emit("after:create", [context, this]);
} else {
// save mode is "edit"
this._metadata.modified_at = Date.now();
context.app.Logger.debug3("ITEM", "updating an existing item", {
metadata: this._metadata,
});
await this.collection.emit("before:edit", [context, this]);
await this.throwIfInvalid("edit", context, this.has_been_replaced);
await this.original_body.decode(context);
const encoded = await this.body.encode(
context,
this.original_body.decoded,
true
);
await context.app.Datastore.update(
this.collection.name,
{ id: this.id },
{
$set: { ...encoded, _metadata: this._metadata },
}
);
await this.decode(context, {}, is_http_api_request);
await this.collection.emit("after:edit", [context, this]);
}
this.body.clearChangedFields();
this.original_body = this.body;
this.body = this.body.copy();
await this.decode(context, {}, is_http_api_request);
return this;
}
async gatherDefaultValues(context: Context) {
context.app.Logger.debug2("ITEM", "Gathering default values");
const promises = [];
for (const field_name of Collection.getFieldnames(this.collection)) {
const field = this.collection.fields[field_name];
if (!field) {
throw new Error(`field is missing: "${field_name}"`);
}
if (
isEmpty(this.body.getInput(field_name)) &&
isEmpty(this.body.getEncoded(field_name)) &&
field.hasDefaultValue()
) {
context.app.Logger.debug3(
"ITEM",
`Gathering default values/${field_name}`
);
promises.push(
field.getDefaultValue(context).then((value) => {
this.set(field_name, value);
})
);
}
}
await Promise.all(promises);
}
/** sets a value */
set<FieldName extends Fieldnames<T>>(
field_name: FieldName,
field_value: FieldsetInput<T["fields"]>[FieldName],
blessed_symbol?: symbol
): CollectionItem<T> {
if (field_value !== undefined) {
this.body.set(field_name, field_value, blessed_symbol);
}
return this;
}
setMultiple(values: Partial<FieldsetInput<T["fields"]>>): this {
for (const [field_name, value] of Object.entries(values)) {
this.set(field_name as Fieldnames<T>, value as any);
}
return this;
}
/** Ensures that every field value is considered. This is useful for forms,
where you can create a form with all fields for a collection, then add a
new field to the collection and want to be notified by typescript about
all forms that need to be updated */
setMultipleExtraSafe(values: FieldsetInput<T["fields"]>): this {
return this.setMultiple(values);
}
replace(values: Partial<FieldsetInput<T["fields"]>>) {
this.body = CollectionItemBody.empty<T>(this.collection);
this.setMultiple(values);
this.has_been_replaced = true;
}
get<FieldName extends Fieldnames<T>>(
field_name: FieldName,
include_raw = false
): FieldsetOutput<T["fields"]>[FieldName] {
if (include_raw) {
if (this.body.raw_input[field_name]) {
return this.body.raw_input[field_name] as FieldsetOutput<
T["fields"]
>[FieldName];
}
}
return this.body.getDecoded(field_name) as FieldsetOutput<
T["fields"]
>[FieldName];
}
/**
* if has decoded value it return this otherwise it decode it in first place
* @param field_name name of field we want to get decoded
* @param context
*/
async getDecoded<FieldName extends Fieldnames<T>>(
field_name: FieldName,
context: Context
): Promise<unknown> {
if (this.body.raw_input[field_name]) {
await this.body.encode(context);
await this.body.decode(context);
return this.body.decoded[field_name];
}
if (this.body.encoded[field_name]) {
await this.body.decode(context);
return this.body.decoded[field_name];
}
}
async getDecodedBody(
context: Context,
format: Parameters<Fieldset<T["fields"]>["decode"]>[1],
is_http: boolean = false
) {
if (!this.body.is_decoded) {
await this.body.decode(context, format, is_http);
}
return this.body.decoded;
}
async remove(context: Context) {
context.app.Logger.debug("ITEM", "remove", this.collection.name);
if (this.save_mode === "insert") {
throw new Error("This item does not yet exist in the database");
}
const decision = await this.canDelete(context);
if (!decision?.allowed) {
throw new BadContext(decision?.reason || "Not allowed");
}
await this.collection.emit("before:remove", [context, this]);
await context.app.Datastore.remove(
this.collection.name,
{ id: this.id },
true
);
await this.collection.emit("after:remove", [context, this]);
}
async delete(context: Context) {
return this.remove(context);
}
serialize(): {
items: any[];
attachments: Record<string, any>;
fields_with_attachments: string[];
} {
if (!this.body.is_decoded) {
throw new Error("First decode the item");
}
return {
items: [this.serializeBody()],
attachments: Object.fromEntries(
Object.values(this.attachments).map((item) => [
item.id,
item.serializeBody(),
])
),
fields_with_attachments: this.fields_with_attachments,
};
}
async decode(
context: Context,
format: { [field_name: string]: any } = {},
is_http_api_request = false
): Promise<CollectionItem<T>> {
await this.body.decode(context, format, is_http_api_request);
return this;
}
serializeBody(): { id: string } & FieldsetOutput<T["fields"]> {
return {
id: this.id,
...Object.fromEntries(
Object.entries(this.body.decoded).map(([key, value]) => [
key,
value instanceof FieldValue
? value.getRestAPIValue()
: value,
])
),
} as {
id: string;
} & FieldsetOutput<T["fields"]>;
}
async safeLoadAttachments(
context: Context,
attachment_options: unknown,
format: Parameters<Fieldset<T["fields"]>["decode"]>[1]
) {
if (attachment_options === undefined) {
attachment_options = {};
}
if (typeof attachment_options != "object") {
throw new ValidationError(
`Expected attachment params to be an object, got ${JSON.stringify(
attachment_options
)}`
);
}
for (const key in attachment_options) {
if (!(key in this.collection.fields)) {
throw new ValidationError(
`Unknown field name in attachments param: ${key}`
);
}
}
return this.loadAttachments(
context,
attachment_options as AttachmentOptions<T>,
format
);
}
async loadAttachments(
context: Context,
attachment_options: AttachmentOptions<T> = {},
format: Parameters<Fieldset<T["fields"]>["decode"]>[1]
): Promise<this> {
// TODO: This function is kinda like a duplicate of `fetchAttachments` from ItemList?
if (this.attachments_loaded) {
throw new Error("Attachments already loaded");
}
this.attachments = {};
const promises = [];
for (const field_name of Object.keys(attachment_options)) {
const field = this.collection.fields[field_name];
if (!field) {
throw new Error(
`Unknown field: ${field_name} in ${this.collection.name}`
);
}
const promise = field
.getAttachments(
context,
[
this.get(
field.name as Fieldnames<typeof this.collection>
),
],
attachment_options[field.name as keyof T["fields"]],
(format || {})[field.name] || null
)
.then((attachmentsList) => {
this.fields_with_attachments.push(field.name);
this.attachments = {
...this.attachments,
...attachmentsList.flattenWithAttachments(),
};
});
promises.push(promise);
}
await Promise.all(promises);
this.attachments_loaded = true;
return this;
}
static fromSerialized<T extends Collection>(
collection: T,
data: { id: string; _metadata: ItemMetadata },
attachments: { [id: string]: any }
): CollectionItem<T> {
const fieldset = CollectionItemBody.fromDecoded<T>(
collection,
data as Partial<FieldsetOutput<T["fields"]>>
);
return new CollectionItem<T>(
collection,
fieldset,
data._metadata,
(data as { id: string }).id,
attachments
);
}
fetchAs(context: Context): Promise<CollectionItem<T>> {
return this.collection.getByID(context, this.id);
}
getAttachmentIDs<FieldName extends keyof T["fields"]>(
field_name: FieldName
): string[] {
const value = this.body.getDecoded(field_name) as string | string[];
return (
this.collection.fields[field_name as string]?.getAttachmentIDs(
value
) || []
);
}
getAttachments<FieldName extends keyof T["fields"]>(
field_name: FieldName
): CollectionItem[] {
if (
!this.fields_with_attachments.includes(field_name as string) &&
!this.parent_list?.fields_with_attachments.includes(
field_name as string
)
) {
throw new Error("No attachments loaded for this field");
}
if (!this.body.decoded) {
throw new Error("Decode first!");
}
const ids = this.getAttachmentIDs(field_name);
let attachments_source: Record<string, CollectionItem>;
if (this.parent_list) {
attachments_source = this.parent_list.attachments;
} else if (this.attachments_loaded) {
attachments_source = this.attachments;
} else {
throw new Error("Attachments list could not be reached");
}
return ids
.map((id) => attachments_source[id])
.filter((e) => !!e) as CollectionItem<any>[];
}
setParentList(list: ItemListResult<T>) {
this.parent_list = list;
}
getBlessing<FieldName extends keyof T["fields"]>(
field_name: FieldName
): symbol | null {
return this.body.getBlessing(field_name);
}
async summarizeChanges(
context: Context
): Promise<Record<keyof T["fields"], { was: unknown; is: unknown }>> {
if (!this.original_body.is_decoded) {
await this.original_body.decode(context);
}
const changed_keys = this.body.changed_fields;
const result = {} as Record<
keyof T["fields"],
{ was: unknown; is: unknown }
>;
for (const changed_key of changed_keys as Set<keyof T["fields"]>) {
const was = this.original_body.getDecoded(changed_key);
const is = this.body.getInput(changed_key as string);
if (was != is) {
result[changed_key] = {
was,
is,
};
}
}
return result;
}
}
diff --git a/src/chip-types/field.test.ts b/src/chip-types/field.test.ts
index 8ab385bf..bdddecd7 100644
--- a/src/chip-types/field.test.ts
+++ b/src/chip-types/field.test.ts
@@ -1,130 +1,153 @@
import { App } from "../app/app.js";
import { withRunningApp } from "../test_utils/with-test-app.js";
import { Collection, FieldTypes } from "../main.js";
import { assertThrowsAsync } from "../test_utils/assert-throws-async.js";
import assert from "assert";
describe("field class", () => {
describe("transition behavior", () => {
it("uses the transition checker to allow / disallow certain value changes", () =>
withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
history: new (class extends Collection {
fields = {
timestamp:
new FieldTypes.Int().setTransitionChecker(
async ({
old_value,
new_value,
}) => {
return old_value == undefined ||
new_value > old_value
? { valid: true }
: {
valid: false,
reason: "timestamps cannot go back in time",
};
}
),
};
})(),
};
},
async ({ app }) => {
const event = await app.collections.history.suCreate({
timestamp: 0,
});
event.set("timestamp", 1);
await event.save(new app.SuperContext());
await assertThrowsAsync(async () => {
event.set("timestamp", 0);
await event.save(new app.SuperContext());
});
}
));
it("doesn't call the transition checker if the value didn't change", async () => {
let call_count = 0;
await withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
history: new (class extends Collection {
fields = {
title: new FieldTypes.Text(),
timestamp:
new FieldTypes.Int().setTransitionChecker(
async () => {
call_count++;
return {
valid: true,
};
}
),
};
})(),
};
},
async ({ app }) => {
// one
const event = await app.collections.history.suCreate({
timestamp: 0,
});
// two
event.set("timestamp", 1);
await event.save(new app.SuperContext());
// but not three
event.set("title", "hehe");
await event.save(new app.SuperContext());
assert.strictEqual(call_count, 2);
}
);
});
it("doesn't call the transition checker if the value didn't change (was empty and is empty)", async () => {
await withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
history: new (class extends Collection {
fields = {
title: new FieldTypes.Text(),
// this is the field that will be kept empty
parent: new FieldTypes.SingleReference(
"history"
).setTransitionChecker(() => {
throw new Error(
"THIS SHOULD NEVER RUN"
);
}),
};
})(),
};
},
async ({ app }) => {
const event = await app.collections.history.suCreate({
title: "big bang",
});
event.set("title", "second bang");
await event.save(new app.SuperContext());
// but not three
event.set("title", "bankg returns");
await event.save(new app.SuperContext()); // if the transition checker is triggered here, it will throw an error
}
);
});
+
+ it("throws when a required field is missing a value", async () => {
+ await withRunningApp(
+ (t) =>
+ class extends t {
+ collections = {
+ ...App.BaseCollections,
+ history: new (class extends Collection {
+ fields = {
+ title: new FieldTypes.Text().setRequired(
+ true
+ ),
+ };
+ })(),
+ };
+ },
+ async ({ app }) => {
+ await assertThrowsAsync(() =>
+ app.collections.history.suCreate({} as any)
+ );
+ }
+ );
+ });
});
});
diff --git a/src/chip-types/fieldset.ts b/src/chip-types/fieldset.ts
index 5877b4ad..5445f271 100644
--- a/src/chip-types/fieldset.ts
+++ b/src/chip-types/fieldset.ts
@@ -1,290 +1,292 @@
import type Context from "../context.js";
import type CollectionItem from "./collection-item.js";
import type {
ExtractFieldDecoded,
ExtractFieldInput,
ExtractFieldStorage,
RequiredField,
} from "./field.js";
import type Field from "./field.js";
export type FieldNames<T extends Record<string, Field<any, any, any>>> =
keyof T & string;
export type FieldsetOutput<T extends Record<string, Field<any, any>>> = {
[field in keyof T]: T[field] extends RequiredField<any, any, any>
? ExtractFieldDecoded<T[field]>
: ExtractFieldDecoded<T[field]> | null;
};
export type FieldsetInput<T extends Record<string, Field<any, any, any>>> = {
[field in keyof T & string as T[field] extends RequiredField<any, any, any>
? field
: never]: ExtractFieldInput<T[field]>;
} & {
[field in keyof T & string as T[field] extends RequiredField<any, any, any>
? never
: field]?: ExtractFieldInput<T[field]> | null;
};
export type FieldsetInputWithAllKeys<
T extends Record<string, Field<any, any, any>>,
> = {
[field in keyof T & string as T[field] extends RequiredField<any, any, any>
? field
: never]: ExtractFieldInput<T[field]>;
} & {
[field in keyof T & string as T[field] extends RequiredField<any, any, any>
? never
: field]: ExtractFieldInput<T[field]> | undefined | null;
};
export type FieldsetEncoded<T extends Record<string, Field<any, any, any>>> = {
[field in keyof T]: ExtractFieldStorage<T[field]>;
};
// a quick test for the types, shoud know which fields can be undefined
// const f = {
// a: new FieldTypes.Text().setRequired(true),
// b: new FieldTypes.Text(),
// };
// type fi = FieldsetInput<typeof f>;
export class Fieldset<Fields extends Record<string, Field<any, any, any>>> {
changed_fields: Set<keyof Fields> = new Set();
is_decoded = false;
is_encoded = false;
blessings: Partial<Record<keyof Fields, symbol | null>> = {};
constructor(
public fields: Fields,
public raw_input: Partial<FieldsetInput<Fields>> = {},
public decoded: Partial<FieldsetOutput<Fields>> = {},
public encoded: Partial<FieldsetEncoded<Fields>> = {}
) {
for (const field_name in raw_input) {
if (!encoded[field_name as keyof Fields]) {
this.changed_fields.add(field_name as keyof Fields);
}
}
}
getRequiredFields(): Field<unknown>[] {
return Object.values(this.fields).filter((f) => f.required);
}
set<FieldName extends keyof FieldsetInput<Fields> & string>(
field_name: FieldName,
field_value: FieldsetInput<Fields>[FieldName],
blessing_symbol: symbol | null = null // those symbols can be used as a proof that a value came from e.g. an internal callback, and not from user input
): this {
this.raw_input[field_name] = field_value;
this.is_decoded = false;
this.is_encoded = false;
this.changed_fields.add(field_name);
this.blessings[field_name] = blessing_symbol;
return this;
}
clearChangedFields() {
this.changed_fields.clear();
}
getDecoded<FieldName extends keyof Fields>(field_name: FieldName) {
if (!this.is_decoded) {
throw new Error("Decode first!");
}
return this.decoded[field_name as keyof typeof this.decoded];
}
getInput<FieldName extends keyof FieldsetInput<Fields>>(
field_name: FieldName
) {
return this.raw_input[field_name];
}
getEncoded<FieldName extends keyof Fields>(field_name: FieldName) {
return this.encoded[field_name];
}
/** Returns encoded values for every field */
async encode(
context: Context,
original_body: FieldsetInput<Fields> = {} as FieldsetInput<Fields>,
only_changed = false
): Promise<Partial<FieldsetOutput<Fields>>> {
context.app.Logger.debug3(
"ITEM BODY",
"encode",
this.changed_fields.values()
);
const new_encoded: Partial<FieldsetOutput<Fields>> = {};
const promises = [];
for (const field_name of this.changed_fields.values()) {
const to_encode =
this.raw_input[field_name as keyof FieldsetInput<Fields>];
context.app.Logger.debug3("ITEM BODY", "encoding value", {
[field_name]:
this.raw_input[field_name as keyof FieldsetInput<Fields>],
is_the_value_empty:
to_encode === undefined || to_encode === null,
});
if (!this.fields[field_name as string]) {
// field does not exist in this collection
continue;
}
if (to_encode === undefined || to_encode === null) {
new_encoded[field_name] = null as any;
continue;
}
if (!this.fields[field_name as string]) {
throw new Error("field name is missing");
}
promises.push(
this.fields[field_name as string]!.encode(
context,
to_encode,
original_body[field_name as keyof FieldsetInput<Fields>]
).then((value) => {
new_encoded[field_name as keyof typeof new_encoded] = value;
})
);
}
await Promise.all(promises);
this.encoded = { ...this.encoded, ...new_encoded };
this.is_encoded = true;
context.app.Logger.debug2("ITEM BODY", "encode result", this.encoded);
return only_changed ? new_encoded : this.encoded;
}
async decode(
context: Context,
format: { [field_name: string]: any } = {},
is_http_api_request = false
): Promise<Fieldset<Fields>> {
if (this.is_decoded) return this;
context.app.Logger.debug3("ITEM BODY", "Decoding item", {
format,
body: this.encoded,
});
const promises: Promise<any>[] = [];
for (const [field_name, encoded_value] of Object.entries(
this.encoded
)) {
const field = this.fields?.[field_name];
const encoded = this.encoded;
if (!field) {
continue;
}
if (encoded[field_name] == null) {
this.decoded = {
...this.decoded,
[field_name]: null,
};
continue;
}
promises.push(
field
.decode(
context,
encoded[field_name],
null,
format?.[field_name],
is_http_api_request
)
.then((decoded_value) => {
this.decoded = {
...this.decoded,
[field_name]: decoded_value,
};
context.app.Logger.debug3(
"ITEM BODY",
"Decoded value",
{
[field_name]: decoded_value,
}
);
})
);
}
await Promise.all(promises);
this.is_decoded = true;
return this;
}
copy(): Fieldset<Fields> {
return new Fieldset<Fields>(
this.fields,
{ ...this.raw_input },
{ ...this.decoded },
{ ...this.encoded }
);
}
async validate(
context: Context,
original_body: Fieldset<Fields>,
replace_mode: boolean, //if true, meaning that if a field has no value, it should be deleted
item: CollectionItem | undefined
): Promise<{
valid: boolean;
errors: { [f in keyof Fields]?: { message: string } };
}> {
const promises = [];
const errors: { [f in keyof Fields]?: { message: string } } = {};
let valid = true;
const fields_to_check = new Set(this.changed_fields.values());
if (replace_mode) {
for (const field of this.getRequiredFields()) {
fields_to_check.add(field.name as keyof Fields);
}
}
for (const field_name of fields_to_check) {
const field = this.fields[field_name as string];
if (!field) {
// field does not exist
continue;
}
- promises.push(
- field
- .checkValue(
- context,
- this.raw_input[
- field_name as keyof FieldsetInput<Fields>
- ],
- original_body.encoded[field_name],
- this.blessings[field_name] || null,
- item
- )
- .then(async (result) => {
- if (!result.valid) {
- valid = false;
- errors[field_name] = {
- message: result.reason as string,
- };
- }
- })
- );
+ const input =
+ this.raw_input[field_name as keyof FieldsetInput<Fields>];
+ if (input === undefined && !field.required) {
+ continue;
+ }
+ const promise = field
+ .checkValue(
+ context,
+ input,
+ original_body.encoded[field_name],
+ this.blessings[field_name] || null,
+ item
+ )
+ .then(async (result) => {
+ if (!result.valid) {
+ valid = false;
+ errors[field_name] = {
+ message: result.reason as string,
+ };
+ }
+ });
+ promises.push(promise);
}
await Promise.all(promises);
return { valid, errors };
}
getBlessing<FieldName extends keyof Fields>(
field_name: FieldName
): symbol | null {
return this.blessings[field_name] || null;
}
setMultiple(values: Partial<FieldsetInput<Fields>>): this {
for (const [field_name, value] of Object.entries(values)) {
this.set(field_name as any, value as any);
}
return this;
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Oct 11, 11:11 (9 h, 39 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
984266
Default Alt Text
(40 KB)

Event Timeline