refactor: replace array-based expressions with typed class hierarchy

Replace the old Lisp-like array expression representation
(["AND", ["PARAM", ...], ...]) with a class hierarchy rooted in
an abstract Expression class (Literal, Parameter, Binary, Unary,
FunctionCall, Conditional). This gives compile-time type safety
to expression construction, evaluation, and pattern matching.

Key changes:
- New lib/common/expression.ts defines the class hierarchy with
  map/evaluate/toString methods
- Rewrite parser as hand-rolled recursive descent, removing the
  parsimmon dependency
- Extend Path with a colon separator to unify parameter identity
  and attributes into a single address space (e.g.
  "Device.WiFi.SSID.1.Name:writable"). The colon field tracks
  how many trailing segments are attribute qualifiers. PathSet
  rewritten with separate param/attr segment indexes
- Flatten device attribute representation in the UI to
  colon-delimited keys mirroring the new path syntax
This commit is contained in:
Zaid Humaimidi committed 2026-03-12 14:58:20 -07:00
1 parent 75908e624f
commit 6f3ce6f7c8
71 files changed
+4045 -3449

No files matched your search

+21 -26
View File
@@ -7,7 +7,6 @@ import {
Task,
Operation,
} from "../types.ts";
import Path from "../common/path.ts";
import { collections } from "../db/db.ts";
import { optimizeProjection } from "../db/util.ts";
import * as MongoTypes from "../db/types.ts";
@@ -23,21 +22,17 @@ function compareAccessLists(list1: string[], list2: string[]): boolean {
export async function fetchDevice(
id: string,
timestamp: number,
): Promise<[Path, number, Attributes?][]> {
const res: [Path, number, Attributes?][] = [
): Promise<[string, number, Attributes?][]> {
const res: [string, number, Attributes?][] = [
["Events", timestamp, { object: [timestamp, 1], writable: [timestamp, 0] }],
[
Path.parse("Events"),
timestamp,
{ object: [timestamp, 1], writable: [timestamp, 0] },
],
[
Path.parse("DeviceID"),
"DeviceID",
timestamp,
{ object: [timestamp, 1], writable: [timestamp, 0] },
],
];
const device = await collections.devices.findOne({ _id: id });
const device = (await collections.devices.findOne({ _id: id })) as any;
if (!device) return null;
function storeParams(
@@ -77,7 +72,7 @@ export async function fetchDevice(
attrs.accessList = [obj["_attributesTimestamp"] || 1, obj["_accessList"]];
try {
res.push([Path.parse(path), t, attrs]);
res.push([path, t, attrs]);
} catch {
// The path parser is now more strict so we might be in a situation where
// the database contains invalid paths from before this change So here we
@@ -86,7 +81,7 @@ export async function fetchDevice(
splits[splits.length - 1] =
encodeTag(splits[splits.length - 1]) + INVALID_PATH_SUFFIX;
path = splits.join(".");
res.push([Path.parse(path), t, attrs]);
res.push([path, t, attrs]);
return;
}
@@ -98,17 +93,17 @@ export async function fetchDevice(
}
if (obj["_object"] && obj["_timestamp"])
res.push([Path.parse(path + ".*"), obj["_timestamp"]]);
res.push([path + ".*", obj["_timestamp"]]);
}
const ts: number = +device["_timestamp"] || 0;
if (ts) res.push([Path.parse("*"), ts]);
if (ts) res.push(["*", ts]);
for (const [k, v] of Object.entries(device)) {
switch (k) {
case "_lastInform":
res.push([
Path.parse("Events.Inform"),
"Events.Inform",
+v,
{
object: [+v, 0],
@@ -119,7 +114,7 @@ export async function fetchDevice(
break;
case "_lastBoot":
res.push([
Path.parse("Events.1_BOOT"),
"Events.1_BOOT",
+v,
{
object: [+v, 0],
@@ -130,7 +125,7 @@ export async function fetchDevice(
break;
case "_lastBootstrap":
res.push([
Path.parse("Events.0_BOOTSTRAP"),
"Events.0_BOOTSTRAP",
+v,
{
object: [+v, 0],
@@ -142,7 +137,7 @@ export async function fetchDevice(
case "_registered":
// Use current timestamp for registered event attribute timestamps
res.push([
Path.parse("Events.Registered"),
"Events.Registered",
timestamp,
{
object: [timestamp, 0],
@@ -153,7 +148,7 @@ export async function fetchDevice(
break;
case "_id":
res.push([
Path.parse("DeviceID.ID"),
"DeviceID.ID",
timestamp,
{
object: [timestamp, 0],
@@ -165,7 +160,7 @@ export async function fetchDevice(
case "_tags":
if ((v as string[]).length) {
res.push([
Path.parse("Tags"),
"Tags",
timestamp,
{ object: [timestamp, 1], writable: [timestamp, 0] },
]);
@@ -173,7 +168,7 @@ export async function fetchDevice(
for (const t of v as string[]) {
res.push([
Path.parse("Tags." + encodeTag(t)),
"Tags." + encodeTag(t),
timestamp,
{
object: [timestamp, 0],
@@ -186,7 +181,7 @@ export async function fetchDevice(
case "_deviceId":
if (v["_Manufacturer"] != null) {
res.push([
Path.parse("DeviceID.Manufacturer"),
"DeviceID.Manufacturer",
timestamp,
{
object: [timestamp, 0],
@@ -198,7 +193,7 @@ export async function fetchDevice(
if (v["_OUI"] != null) {
res.push([
Path.parse("DeviceID.OUI"),
"DeviceID.OUI",
timestamp,
{
object: [timestamp, 0],
@@ -210,7 +205,7 @@ export async function fetchDevice(
if (v["_ProductClass"] != null) {
res.push([
Path.parse("DeviceID.ProductClass"),
"DeviceID.ProductClass",
timestamp,
{
object: [timestamp, 0],
@@ -222,7 +217,7 @@ export async function fetchDevice(
if (v["_SerialNumber"] != null) {
res.push([
Path.parse("DeviceID.SerialNumber"),
"DeviceID.SerialNumber",
timestamp,
{
object: [timestamp, 0],
@@ -257,7 +252,7 @@ export async function saveDevice(
)
continue;
const parent = deviceData.paths.get(diff[0].slice(0, -1));
const parent = deviceData.paths.get(diff[0].slice(0, -1).toString());
// Param timestamps may be greater than session timestamp to track revisions
if (diff[2] > sessionTimestamp) diff[2] = sessionTimestamp;
+72 -56
View File
@@ -1,15 +1,12 @@
import * as vm from "node:vm";
import * as crypto from "node:crypto";
import * as config from "../config.ts";
import { collections } from "../db/db.ts";
import { convertOldPrecondition } from "../db/util.ts";
import * as logger from "../logger.ts";
import * as scheduling from "../scheduling.ts";
import * as expression from "../common/expression/util.ts";
import { parse } from "../common/expression/parser.ts";
import Expression, { Value } from "../common/expression.ts";
import {
Preset,
Expression,
Provisions,
VirtualParameters,
Files,
@@ -93,10 +90,10 @@ async function fetchPresets(): Promise<[string, Preset[]]> {
}
const events = preset["events"] || {};
let precondition = true as Expression;
let precondition: Expression = new Expression.Literal(true);
if (preset["precondition"]) {
try {
precondition = parse(preset["precondition"]);
precondition = Expression.parse(preset["precondition"]);
} catch {
precondition = convertOldPrecondition(
JSON.parse(preset["precondition"]),
@@ -104,7 +101,7 @@ async function fetchPresets(): Promise<[string, Preset[]]> {
}
// Simplify expression
precondition = expression.evaluate(precondition);
precondition = precondition.evaluate((e) => e);
}
const _provisions: Preset["provisions"] = [];
@@ -113,23 +110,42 @@ async function fetchPresets(): Promise<[string, Preset[]]> {
for (const c of preset.configurations) {
switch (c.type) {
case "age":
_provisions.push(["refresh", c.name, +c.age]);
_provisions.push([
"refresh",
new Expression.Literal(c.name),
new Expression.Literal(+c.age),
]);
break;
case "value":
_provisions.push(["value", c.name, c.value]);
_provisions.push([
"value",
new Expression.Literal(c.name),
new Expression.Literal(c.value),
]);
break;
case "add_tag":
_provisions.push(["tag", c.tag, true]);
_provisions.push([
"tag",
new Expression.Literal(c.tag),
new Expression.Literal(true),
]);
break;
case "delete_tag":
_provisions.push(["tag", c.tag, false]);
_provisions.push([
"tag",
new Expression.Literal(c.tag),
new Expression.Literal(false),
]);
break;
case "provision":
_provisions.push([c.name, ...(c.args || [])]);
_provisions.push([
c.name,
...(c.args || []).map((a) => Expression.parse(a)),
]);
break;
case "add_object":
@@ -139,11 +155,19 @@ async function fetchPresets(): Promise<[string, Preset[]]> {
.map((k) => `${k}:${JSON.stringify(obj[k])}`)
.join(",");
const p = `${c.name}.[${alias}]`;
_provisions.push(["instances", p, 1]);
_provisions.push([
"instances",
new Expression.Literal(p),
new Expression.Literal(1),
]);
for (const k in obj) {
if (!k.startsWith("_") && !(obj["_keys"].indexOf(k) !== -1))
_provisions.push(["value", `${p}.${k}`, obj[k]]);
_provisions.push([
"value",
new Expression.Literal(`${p}.${k}`),
new Expression.Literal(obj[k]),
]);
}
}
}
@@ -157,7 +181,11 @@ async function fetchPresets(): Promise<[string, Preset[]]> {
.map((k) => `${k}:${JSON.stringify(obj[k])}`)
.join(",");
const p = `${c.name}.[${alias}]`;
_provisions.push(["instances", p, 0]);
_provisions.push([
"instances",
new Expression.Literal(p),
new Expression.Literal(0),
]);
}
}
@@ -251,7 +279,7 @@ async function fetchConfig(): Promise<[string, Config]> {
for (const c of conf) {
// Evaluate expressions to simplify them
_config[c._id] = expression.evaluate(parse(c.value));
_config[c._id] = Expression.parse(c.value).evaluate((e) => e);
}
return [h, _config];
@@ -303,49 +331,37 @@ export function getFiles(revision: string): Files {
}
export function getConfig(
snapshotKey: string,
revision: string,
key: string,
context: Record<string, unknown>,
now: number,
cb?: (e: Expression) => Expression,
): string | number | boolean | null {
const snapshot = localCache.get(snapshotKey);
dflt: string,
fn: (e: Expression) => Expression.Literal,
): string;
export function getConfig(
revision: string,
key: string,
dflt: number,
fn: (e: Expression) => Expression.Literal,
): number;
export function getConfig(
revision: string,
key: string,
dflt: boolean,
fn: (e: Expression) => Expression.Literal,
): boolean;
export function getConfig(
revision: string,
key: string,
dflt: Value,
fn: (e: Expression) => Expression.Literal,
): Value {
const snapshot = localCache.get(revision);
if (!snapshot) throw new Error("Cache snapshot does not exist");
const oldOpts = {
"cwmp.downloadTimeout": "DOWNLOAD_TIMEOUT",
"cwmp.debug": "DEBUG",
"cwmp.retryDelay": "RETRY_DELAY",
"cwmp.sessionTimeout": "SESSION_TIMEOUT",
"cwmp.connectionRequestTimeout": "CONNECTION_REQUEST_TIMEOUT",
"cwmp.gpnNextLevel": "GPN_NEXT_LEVEL",
"cwmp.gpvBatchSize": "GPV_BATCH_SIZE",
"cwmp.cookiesPath": "COOKIES_PATH",
"cwmp.datetimeMilliseconds": "DATETIME_MILLISECONDS",
"cwmp.booleanLiteral": "BOOLEAN_LITERAL",
"cwmp.connectionRequestAllowBasicAuth":
"CONNECTION_REQUEST_ALLOW_BASIC_AUTH",
"cwmp.maxCommitIterations": "MAX_COMMIT_ITERATIONS",
"cwmp.deviceOnlineThreshold": "DEVICE_ONLINE_THRESHOLD",
"cwmp.udpConnectionRequestPort": "UDP_CONNECTION_REQUEST_PORT",
};
if (!(key in snapshot.config)) {
if (key in oldOpts) {
let id;
if (context?.["id"]) {
id = context["id"];
} else if (cb) {
id = cb(["PARAM", "DeviceID.ID"]);
if (Array.isArray(id)) id = null;
}
return config.get(oldOpts[key], id);
}
return null;
}
const v = expression.evaluate(snapshot.config[key], context, now, cb);
return Array.isArray(v) ? null : v;
const e = snapshot.config[key];
if (!e) return dflt;
const v = e.evaluate(fn).value;
if (typeof v !== typeof dflt) return dflt;
return v;
}
export function getConfigExpression(