// deno-lint-ignore-file ban-types
import dayjs from "npm:dayjs";
import State from "@infinity-beyond/modules/state.ts";
import { Entity, run_on_worker } from "@infinity-beyond/classes/data_types/entity/mod.ts";
import {
AddLedgerEntry,
ConsumeFromLedger,
CreateAddLedgerEntryFunction,
CreateConsumptionFunction,
CreateEntryDataFunction,
CreateUserDataFunction,
GetLedgerEntryData,
GetLedgerUserData,
LedgerMeta,
LedgerPermissions,
LedgerRest,
} from "@ledger/mod.ts";
export class Ledger<
EntityName extends string,
PEMC extends Entity.Meta.Config = {},
> extends Entity<
Ledger.Events,
EntityName,
Ledger.Permissions<EntityName>,
Ledger.Meta,
PEMC,
Ledger.Options<PEMC>
> implements Ledger.Ledger<EntityName> {
readonly REST: LedgerRest<EntityName>;
protected static override Permissions: readonly string[] = LedgerPermissions;
constructor(
name: EntityName,
options: Ledger.Options<PEMC> = {} as Ledger.Options<PEMC>,
) {
super(
name,
options,
LedgerMeta as Ledger.Meta,
);
this.options.expiration ||= {};
this.options.rest ||= {};
this.REST = new LedgerRest(this);
}
format_and_validate(key: string) {
return this.validate_key(this.format_key(key));
}
format_key(key: string) {
return this.options.key_formatter?.(key) ?? key;
}
validate_key(key: string): ValidateKeyResponse {
if (!this.options.key_validator) {
return { key, valid: true, reason: null };
}
const validation_result = this.options.key_validator(key);
if (validation_result === true) {
return { key, valid: true, reason: null };
}
if (validation_result === false) {
return {
key,
valid: false,
reason: "Unspecified: Validator did not return a failure reason.",
};
}
return { key, ...validation_result };
}
@run_on_worker
async AddEntry<S = EntityName>(
params: Ledger.Entry.Unentered & Ledger.Expiration.Override,
): Promise<Ledger.Entry.Response> {
params.key = this.format_key(params.key);
const { valid, reason } = this.validate_key(params.key);
if (!valid) {
this.emit(
"entry_creation_failed",
reason,
params as With<typeof params, { remaining: number }>,
);
return {
added: false,
reason,
};
}
if (params.amount < 0 && !this.options.allow_negative_balances) {
const reason =
`adding negative rows is not allowed for [${this.name}]. Use 'ConsumeEntries' instead.`;
this.emit(
"entry_creation_failed",
reason,
params as With<typeof params, { remaining: number }>,
);
return {
added: false,
reason,
};
}
let expiration_date: Date | undefined;
if (this.options.expiration?.entries_expire) {
const { custom_calculator, period, round_up_to } = this.options.expiration;
expiration_calc: {
if (typeof custom_calculator === "function") {
expiration_date = custom_calculator();
break expiration_calc;
}
if (period) {
let expiration = dayjs().add(period[0], period[1]);
if (round_up_to && round_up_to !== "none") {
expiration = expiration.endOf(round_up_to);
}
expiration_date = expiration.toDate();
}
}
if (!params.override_expiration_date) {
params.expiration_date = expiration_date;
}
params.expired = params.expiration_date && dayjs().isAfter(params.expiration_date);
} else {
params.expiration_date = undefined;
}
delete params.override_expiration_date;
params.remaining = params.amount;
const {rows: [{
new_entry_id = null,
new_entry_timestamp = new Date(),
new_balance = 0 } = {}
]} = await AddLedgerEntry.populate({
entries_table: this.entries_table,
}).execute(
State.PostgresClient,
params.key,
params.amount,
params.remaining,
params.reason,
params.description,
params.reference,
params.meta,
params.source,
params.request_id,
params.expiration_date,
!!params.expired,
);
if (!new_entry_id) {
const reason = `Could not insert into ${this.entries_table}!`;
this.emit(
"entry_creation_failed",
reason,
params as With<typeof params, { remaining: number }>,
);
return {
added: false,
reason,
};
}
const entry = params as Ledger.Entry;
entry.id = new_entry_id;
entry.timestamp = new_entry_timestamp;
this.emit("entry_created", entry);
return {
added: true,
balance: new_balance,
};
}
@run_on_worker
async ConsumeEntries(
params: Ledger.Entry.Unentered,
): Promise<Ledger.Consumption.ConsumptionResponse> {
params.key = this.format_key(params.key);
const key_validation = this.validate_key(params.key);
if (!key_validation.valid) {
this.emit(
"entry_creation_failed",
key_validation.reason,
params as With<typeof params, { remaining: number }>,
);
return {
key: params.key,
success: false,
reason: key_validation.reason,
params,
};
}
let should_run_consumption = true;
let failure_reason: string =
'Consumption cancelled by `.on("before-consumption")` event';
let success_callback: Function = () => Promise<void>;
await this.emit("before-consumption", params, (reason) => {
should_run_consumption = false;
if (reason) failure_reason = reason;
}, (callback) => {
success_callback = callback;
});
if (!should_run_consumption) {
return {
key: params.key,
success: false,
reason: failure_reason,
params,
};
}
let rows: Ledger.Consumption.Function.Response[];
try {
rows = (await ConsumeFromLedger.populate({
slug: this.slug,
}).execute(
State.PostgresClient,
params.key,
params.amount,
params.source,
params.reference,
params.description,
params.request_id,
)).rows;
} catch (e: any) {
return {
key: params.key,
success: false,
reason: e.message || "Unspecified error during consumption query",
params,
};
}
const [{ new_entry_id, updated_entries, new_user_balance, error_message }] =
rows;
if (
new_entry_id === undefined || new_entry_id === null ||
updated_entries === undefined || updated_entries === null ||
new_user_balance === undefined || new_user_balance === null
) {
return {
key: params.key,
success: false,
reason: error_message || "Unspecified error during consumption query",
params,
new_entry_id,
updated_entries,
new_user_balance,
} as any;
}
if (typeof success_callback == "function") await success_callback?.();
await this.emit("consumption", {
new_entry_id,
updated_entries,
new_user_balance,
error_message: error_message || null,
}, params);
return {
key: params.key,
success: true,
balance: new_user_balance,
consumption_data: {
new_entry_id,
updated_entries,
} as Ledger.Consumption.Function.Response,
params,
};
}
@run_on_worker
async History(count = 50, page = 1) {
if (!Number.isSafeInteger(count)) count = 10;
count = Math.max(1, count);
const { rows: entries = [] } = await State.PostgresClient.query<
Ledger.Entry
>(
`select * from ${this.entries_table} order by timestamp desc limit $1 offset $2`,
[count, count * (page - 1)],
);
return entries;
}
@run_on_worker
async UserData(
key: string,
count = 10,
page = 1,
): Promise<Ledger.User.Response> {
key = this.format_key(key);
if (!this.validate_key(key).valid) {
return null;
}
if (!Number.isSafeInteger(count)) count = 10;
count = Math.max(1, count);
const { rows: [{ history, entry_count, balance } = {}] } =
await GetLedgerUserData.populate({
slug: this.slug,
}).execute(State.PostgresClient, key, count, count * (page - 1));
return { history, entry_count, balance };
}
@run_on_worker
async HistoryFor(key: string, count = 10, page = 1) {
key = this.format_key(key);
if (!this.validate_key(key).valid) {
return [];
}
if (!Number.isSafeInteger(count)) count = 10;
count = Math.max(1, count);
const { rows: entries = [] } = await State.PostgresClient.query<
Ledger.Entry
>(
`select * from ${this.entries_table} where key=$1 order by timestamp desc limit $2 offset $3`,
[key, count, count * (page - 1)],
);
return entries;
}
@run_on_worker
async EntryData(id: number) {
const {
rows: [{ entry, deducted_by_entries, deducted_from_entries } = {}],
} = await GetLedgerEntryData.populate({
slug: this.slug,
}).execute(State.PostgresClient, id);
if (!entry) return null;
return {
entry,
deducted_by_entries,
deducted_from_entries,
} as Ledger.Entry.DataResponse;
}
@run_on_worker
async BalanceFor(key: string): Promise<number | undefined> {
key = this.format_key(key);
if (!this.validate_key(key).valid) {
return undefined;
}
const { rows: [{ balance = 0 } = {}] } = await State.PostgresClient.query<
Ledger.User
>(`select balance from ${this.user_table} where key=$1`, [key]);
return balance;
}
@run_on_worker
async Balances(
...keys: string[]
): Promise<Pick<Ledger.User, "key" | "balance">[]> {
const { rows } = await State.PostgresClient.query<Ledger.User>(
`select balance from ${this.user_table} where key = ANY($1)`,
[keys],
);
return rows.map(({ key, balance }) => ({ key, balance }));
}
@run_on_worker
async CountFor(key: string): Promise<number | undefined> {
key = this.format_key(key);
if (!this.validate_key(key).valid) {
return undefined;
}
const { rows: [{ entry_count = 0 } = {}] } = await State.PostgresClient
.query<{ entry_count: number }>(
`select (addition_count + consumption_count) as entry_count from ${this.user_table} where key=$1`,
[key],
);
return entry_count;
}
@run_on_worker
async UserCount() {
const { rows: [{ count = 0 } = {}] } = await State.PostgresClient.query<
{ count: number }
>(`select count(*)::int as count from ${this.user_table}`);
return count;
}
get entries_table() {
return `${this.slug}_entries`;
}
get user_table() {
return `${this.slug}_users`;
}
get consumption_table() {
return `${this.slug}_consumption`;
}
override async Setup() {
await super.Setup();
if (State.IS_BUILDING) return this;
// #region Create DB Tables
const entries_table = this.entries_table;
const key_max_length = this.options.key_max_length ?? 11;
// #region - Entries Table
await State.PostgresClient.CreateTable<Ledger.Entry>(entries_table, {
id: "serial PRIMARY KEY",
key: `varchar(${key_max_length}) NOT NULL`,
amount: "int NOT NULL",
remaining: "int NOT NULL",
timestamp: "timestamptz NOT NULL default NOW()",
reason: "text",
description: "text",
reference: "text",
meta: "text",
source: "text NOT NULL",
request_id: "text",
expiration_date: "timestamptz",
expired: "boolean NOT NULL DEFAULT false",
}, {
async onCreate() {
await State.PostgresClient.query(`
CREATE INDEX ${entries_table}_key_idx ON ${entries_table} using hash(key);
CREATE INDEX ${entries_table}_key_timestamp_idx ON ${entries_table} (key, timestamp desc);
`);
},
});
// #region - Users Table
const user_table = this.user_table;
await State.PostgresClient.CreateTable<Ledger.User>(user_table, {
id: "SERIAL PRIMARY KEY",
balance: "INT NOT NULL DEFAULT 0",
addition_count: "INT NOT NULL default 0",
consumption_count: "INT NOT NULL default 0",
key: `VARCHAR(${key_max_length}) NOT NULL`,
first_entry: "TIMESTAMPTZ NOT NULL DEFAULT NOW()",
last_entry: "TIMESTAMPTZ NOT NULL DEFAULT NOW()",
}, {
async onCreate() {
await State.PostgresClient.query(`
CREATE UNIQUE INDEX ${user_table}_key_idx ON ${user_table} (key);
CREATE INDEX ${user_table}_key_hash_idx ON ${user_table} using hash(key);
CREATE INDEX ${user_table}_balance_idx ON ${user_table} (balance);
`);
},
});
// #region - Consumption Table
const consumption_table = this.consumption_table;
await State.PostgresClient.CreateTable<Ledger.Consumption>(
consumption_table,
{
id: "SERIAL PRIMARY KEY",
consumption_entry_id:
`INT4 REFERENCES ${entries_table}(id) ON DELETE RESTRICT`,
source_entry_id:
`INT4 REFERENCES ${entries_table}(id) ON DELETE RESTRICT`,
amount_deducted: `INT NOT NULL`,
timestamp: `TIMESTAMPTZ DEFAULT NOW()`,
},
{
async onCreate() {
await State.PostgresClient.query(`
CREATE INDEX ${consumption_table}_consumption_id_idx ON ${consumption_table} using hash(consumption_entry_id);
CREATE INDEX ${consumption_table}_source_id_idx ON ${consumption_table} using hash(source_entry_id);
`);
},
},
);
// #region Create DB Functions
await CreateConsumptionFunction.populate({
comsumptions_table: this.consumption_table,
entries_table: this.entries_table,
meta_table: this.meta_table,
slug: this.slug,
users_table: this.user_table,
}).execute(State.PostgresClient);
await CreateAddLedgerEntryFunction.populate({
entries_table: this.entries_table,
meta_table: this.meta_table,
users_table: this.user_table,
}).execute(State.PostgresClient);
await CreateEntryDataFunction.populate({
comsumptions_table: this.consumption_table,
entries_table: this.entries_table,
slug: this.slug,
}).execute(State.PostgresClient);
await CreateUserDataFunction.populate({
entries_table: this.entries_table,
slug: this.slug,
user_table: this.user_table,
}).execute(State.PostgresClient);
return this;
}
}
Ledger.on("ledger created", (instance) => {
instance.entity_class.log(`Ledger initialized`);
});