@egi/smart-db-server
v0.5.0
Published
Validated HTTP server and browser client for registered SmartDB models
Maintainers
Readme
@egi/smart-db-server
Validated HTTP access to explicitly registered @egi/smart-db models. The
package contains a framework-neutral dispatcher, an optional Express router,
and a browser-safe fetch adapter suitable for @egi/smart-table.
Installation
npm install @egi/smart-db-server @egi/smart-db expressExpress is only required by applications importing @egi/smart-db-server/express.
Browser-only consumers can import @egi/smart-db-server/client without a Node
server dependency.
Minimal server
import express from "express";
import {SmartDbBetterSqlite3} from "@egi/smart-db/drivers/smart-db-better-sqlite3";
import {SmartDbServer} from "@egi/smart-db-server";
import {createSmartDbRouter} from "@egi/smart-db-server/express";
import {TaskModel, TaskDictionary} from "./models";
const database = new SmartDbBetterSqlite3(
{filename: "tasks.sqlite"},
{connectOnly: true}
);
await database.databaseReady();
const server = new SmartDbServer({
authorize: async () => true,
database,
dictionary: TaskDictionary,
models: {
TaskModel: {
model: TaskModel,
policy: {
allowDelete: true,
scope: "global",
readableFields: ["id", "title", "done"],
createFields: ["title", "done"],
updateFields: ["title", "done"]
}
}
}
});
const app = express();
app.use("/api", createSmartDbRouter({
context: () => ({principal: {}}),
server
}));
app.listen(3000);The router provides POST /api/:operation/:model. See demo/ for a complete
SQLite application and browser client.
Authorization and model capabilities
The server validates a request before opening a transaction. It then calls the
server-wide authorization gate with a domain-neutral target and the same
SmartDbTransaction used by the operation:
const server = new SmartDbServer({
authorize: async (context, target, transaction) => {
// Map the generic target to the host application's permission model.
// target.mutationIntents contains create/update/delete intent derived
// from validated data, including mixed save requests.
return permissions.allow(
transaction,
context.principal,
target.name,
target.operation,
target.mutationIntents
);
},
// ...
});The authorization target never contains the raw body or application-specific permission types. Invalid requests and static model-policy failures do not open a transaction. A request that reaches authorization uses exactly one transaction through authorization, scopes, hooks, commands, reads, and writes.
Model mutation capabilities are explicit:
- omit
createFieldsto disable create; - omit
updateFieldsto disable update; - set
allowDelete: trueto enable primary-key delete; - additionally set
allowPredicateDelete: trueto enable predicate delete; - set
allowPredicateUpdate: trueto enable predicate update.
All capabilities default to disabled except read, which is constrained by
readableFields and the mandatory scope choice.
Views and other models without a primary key may be registered with a read-only
policy. Their policies must omit all create, update, and delete capabilities;
filters, ordering, projections, limits, and row-level scopes remain available
for reads. The browser client's read() method accepts these keyless model
classes, while its mutation helpers reject them before issuing a request.
Custom commands
Custom commands inherit from SmartDbServerCommand and implement both body
validation and execution. The default authorize() permits the command after
the server-wide authorization gate; override it for command-specific policy.
Both authorization layers and perform() receive the same transaction.
import {SmartDbTransaction} from "@egi/smart-db";
import {
SmartDbServerCommand,
SmartDbServerContext,
SmartDbServerError,
SmartDbServerResult,
SmartDbServerResultDraft
} from "@egi/smart-db-server";
interface Principal {
username: string;
}
class EchoCommand extends SmartDbServerCommand<{message: string}, Principal> {
public async perform(
_transaction: SmartDbTransaction,
_operation: string,
body: {message: string},
_context: SmartDbServerContext<Principal>
): Promise<SmartDbServerResultDraft> {
return SmartDbServerResult.success(body);
}
public validateBody(body: unknown): {message: string} {
if (!body || typeof body != "object" || typeof (body as Record<string, unknown>).message != "string") {
throw new SmartDbServerError("Invalid echo body");
}
return {message: (body as {message: string}).message};
}
}
server.registerCommand("echo", new EchoCommand());Browser client
import {SmartDbServerClient} from "@egi/smart-db-server/client";
const data = new SmartDbServerClient({baseUrl: "/api"});
const rows = await data.read(TaskModel, {orderBy: ["title ASC"]});
const filtered = await data.read(TaskModel, {
orderBy: [{operation: "COALESCE", value: ["status", "title"]}],
where: {
status: {operation: "NOT IN", value: ["done", "cancelled"]},
title: {operation: "NOT LIKE", value: "Draft%"},
dueDate: {operation: ">=", value: "2026-01-01"}
}
});
const summaries = await data.read(TaskModel, {
fields: ["id", "title"],
limit: {limit: 50, offset: 0},
orderBy: ["id ASC"]
});
await data.create(TaskModel, [new TaskModel({title: "New task"})]);
await data.update(TaskModel, [new TaskModel({id: 1, done: true})]);
await data.delete(TaskModel, [{id: 2}]);
await data.deleteWhere(TaskModel, {status: "obsolete"});
const stats = await data.command<{count: number}, Record<string, never>>(
"stats",
"count",
{}
);Read projections must be non-empty subsets of the model policy's
readableFields. Projected rows remain plain objects and are typed as
Pick<Row, Field>[]; complete reads continue to hydrate model instances.
Client limits cannot exceed the server's maxRows, and callers should include
orderBy when using offsets so pagination is deterministic.
Read predicates support SmartDB's validated EQ, NE, GT, GE, LT,
LE, IN, NOT_IN, BETWEEN, IS_NULL, IS_NOT_NULL, LIKE, and
NOT_LIKE operations, plus nested and/or clauses. SQL literals,
subqueries, and arbitrary expressions remain unavailable. Ordering accepts
readable field names and validated COALESCE expressions containing readable
fields and scalar fallbacks. Predicate deletes require a non-empty predicate
and the model policy's allowDelete and allowPredicateDelete capabilities.
The server rejects unknown properties, unsafe keys, unauthorized fields,
oversized queries and batches, missing mutation keys, and reads exceeding the
configured maximum. It never accepts SmartDbSqlOptions or SqlWhere directly
from the network.
Localizable failures
A failure result carries the browser-safe SmartDbServerMessageDescriptor
contract: an optional messageCd translation code and scalar messageParams,
so a host can present the failure in the user's language while
message remains English developer detail that hosts must not show to end
users.
SmartDbServerResult.success() supplies status 200 and
SmartDbServerResult.failure() supplies status 500 unless explicitly
overridden. SmartDbServerResult.hydrate() validates untrusted JSON and throws
SmartDbServerResultHydrationError for malformed envelopes. The browser client
wraps parsing and hydration failures in SmartDbServerProtocolError; it never
fabricates an application failure result.
throw new SmartDbServerError("Domain code already exists.", 409, -21, {
messageCd: "serverError.systemDomainAdmin.domainCodeExists",
messageParams: {domainCd: "LION"}
});Failures that do not already carry a messageCd — including framework
validation errors and unrecognized throwables such as database driver errors —
are offered to the optional normalizeError configuration hook before the
result is produced. The hook returns a SmartDbServerError carrying the host's
own status, application code and translation code, or undefined to keep the
default handling under which an unrecognized throwable becomes a generic 500
without internal detail. A hook that throws is ignored.
normalizeError: (error) => {
if ((error as {code?: string}).code == "23505") {
return new SmartDbServerError("Duplicate key.", 409, -21, {
messageCd: "serverError.db.duplicateKey"
});
}
return undefined;
}Both fields survive the Express transport and the browser client, which
throws a typed SmartDbServerClientError carrying the complete result and HTTP
status. All browser-safe request, response, model, normalization, message, and
client-error types are exported from @egi/smart-db-server/client.
Lossless numbers
Models generated with SmartDB's lossless-number extractor options carry a
losslessNumberType discriminator. Marked decimal and big-integer values remain
canonical JSON strings through requests, database binds, responses, hooks, and
the browser client. The server rejects JavaScript numbers, native bigint, and
calculation objects for these wire fields and rejects LIKE predicates.
The server installs decimal.js and normalizes marked values with SmartDB's
shared canonicalizer. Decimal rounding defaults to ROUND_HALF_UP and can be
configured for trusted server input:
import {DecimalRoundingMode} from "@egi/smart-db";
const server = new SmartDbServer({
// ...
decimalRoundingMode: DecimalRoundingMode.ROUND_HALF_EVEN
});Generated models canonicalize values before the browser client sees them. For plain-object decimal writes, configure the same opt-in normalizer explicitly; the rounding mode remains local and is never sent over the wire:
import {DecimalRoundingMode} from "@egi/smart-db";
import {normalizeDecimal} from "@egi/smart-db/lossless-numbers";
import {SmartDbServerClient} from "@egi/smart-db-server/client";
const data = new SmartDbServerClient({
baseUrl: "/api",
decimalNormalizer: normalizeDecimal,
decimalRoundingMode: DecimalRoundingMode.ROUND_HALF_EVEN
});Primary keys remain JSON numbers for compatibility. Registration requires an unmarked numeric key, and every key is checked at runtime to be an integral JavaScript safe integer. Deploy the lossless-aware server before deploying a client generated with lossless options; older servers treat the string fields as ordinary strings and cannot enforce these semantics.
SQLite fractional lossless decimals require a column with TEXT affinity.
Run the asynchronous storage audit after the database is ready and before
starting the HTTP listener. The constructor cannot run this asynchronous
preflight automatically; applications must explicitly await it:
await database.databaseReady();
await server.auditStorage();
app.listen(port);The audit rejects REAL, NUMERIC, missing, and ambiguous affinity metadata.
allowUnsafeSqliteAffinity is a migration-only override; configure
onStorageAuditWarning to route its startup warning into application logging.
The runtime response check remains a final safeguard, but converting an already
rounded SQLite REAL value to TEXT cannot recover lost digits.
PlainDate fields
SmartDB 4.2 models can opt date-only columns into Temporal.PlainDate with
attribute metadata type: "PlainDate". SmartDbServer accepts exactly
YYYY-MM-DD on the HTTP wire and uses SmartDB's strict plainDate parser, so
timestamps, offsets, calendar annotations, invalid dates, and MySQL zero dates
are rejected. null remains JSON null.
import {plainDate} from "@egi/smart-db";
const dueDate = plainDate.from("2026-08-08");
JSON.stringify({dueDate}); // {"dueDate":"2026-08-08"}Validated writes, trusted scopes, and write hooks use runtime PlainDate
objects. Both beforeWrite current/pending values and afterWrite result rows
therefore have matching date semantics. SmartDbServer converts them back to
canonical strings only when constructing a result.
The browser client serializes generated PlainDate models without configuration.
Complete reads are passed through the generated model's from() factory and
revive PlainDate objects; projected reads remain plain wire objects whose date
fields are canonical strings.
A JavaScript Date represents an instant and is never converted implicitly.
Applications migrating an instant must choose the timezone explicitly before
transport:
const calendarDate = plainDate.fromDate(instant, "Europe/Zurich");SmartDB owns SQLite, MySQL/MariaDB, PostgreSQL, and Oracle date binds and hydration. SmartDbServer adds no database-specific PlainDate conversion.
