* [LIQ] Add new aggregate functions, aliases, and queryable aggregate registry * Extend with 13 new built-in aggregates: `product`, `string_agg`, `yaml_agg`, `json_agg`, `bit_and`, `bit_or`, `bit_xor`, `bool_and`, `bool_or`, `stddev_pop`, `stddev_samp`, `var_pop` and `var_samp`. * Introduce `aggregate.alias` API allowing users to define custom aliases for any aggregate. Standard aliases (`every`, `std`, `stddev` and `variance`) are now defined via this API rather than hardcoded. * Add `index.aggregates` queryable collection so users can discover all available aggregates directly from LIQ queries. Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix config pass through query path so custom aggregates work Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix: Preserve `LuaTable`/`LuaFunction` values in aggregate config storage `config.set` uses `LuaNativeJSFunction` which calls `luaValueToJS` on all arguments. This converted the aggregate `LuaTable` to a plain JS object and wrapped `LuaFunction` callbacks in JS functions that also converted their returned values via `luaValueToJS`. The result was that state returned by initialize (a `LuaTable`) got converted to a plain JS object before being passed to `iterate`. Therefor Lua operations like `table.insert` on that were failing because they expected a `LuaTable` and not a plain JS array. Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix formatting Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Improve aggregate functions descriptions, fix `sum` divergence Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Align `product` with `sum` Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix: extract `alias` from `LuaTable` via `rawGet` in `aggregates()` registry Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Rename `alias` in `aggregates()` to `target` for clarity Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix: Add a null guard at the top of `jsToLuaValue` This preserves `null`/`undefined` as-is (both map to Lua nil) and prevents them from falling through to the `typeof` "object" branch. For this PR it means that null `target` in our `aggregates` entries will correctly show as empty/`nil` in query results rather than `{}`. Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix: Documentation reflects recent changes Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix: make `sum`/`product` return null on empty input; stop `LIQ_NULL` leaks * `sum(`) and` product(`) now return null when no rows match (matching Postgres semantics) instead of returning 0 and 1 respectively. * Query result columns that hold null are internally preserved using a `LIQ_NULL` sentinel so that column keys survive in `LuaTable` storage. This sentinel was leaking into Lua code as "userdata" through three read paths: * `luaIndexValue`: `rawGet` returned the sentinel directly to Lua when accessing table fields, * `rawget` (stdlib): the builtin `rawget` function exposed the sentinel without converting it back to `nil`, * `createAugmentedEnv`: string interpolation unpacked table values via `rawGet` into local variables, making the sentinel visible in template expressions like `${var}`. All three now convert `LIQ_NULL` to `nil` at the read boundary, keeping the sentinel internal to table storage where it belongs. * Update affected test expectations accordingly. Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix: Remove duplicated LIQ_NULL hazard, add guard for all builtin aggregate `iterate`s Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix: `array_agg` preserves NULL positions Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix: Add symbol guard to `json_agg` `JSON.stringify(Symbol(...))` in an array produces null by accident. That is a JS implementation detail we **MUST NOT** rely on. Explicit null push makes intent clear and avoids surprises if the `Symbol` representation ever changes. Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix: Add symbol guard to `yaml_agg` (ditto) `js-yaml` has no knowledge of the `LIQ_NULL` symbol. Passing null makes it emit YAML null (or `~`), which is the correct YAML representation of a missing value and matches standard `json_agg`/`yaml_agg` NULL-inclusion semantics. Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix: Add intra-aggregate ordering null guards Without this, `LIQ_NULL` sort keys would fall through to `valA < valB` which is always false for `Symbol`s which is breaking the `nulls first`/`nulls last` contract... Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix: Ditto, but for `order by` null comparisons Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix: Guard `luaTypeName`, `luaTypeOf` and `luaToString` against `LIQ_NULL` sentinel Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix: Guard presentation layer against `LIQ_NULL` sentinel leaking as visible text Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Fix: Evaluate extra args per-item in `executeAggregate`; add new aggregates Extra arguments (2nd, 3rd, etc.) to aggregate functions were evaluated against the outer query environment where the object variable is not bound. This caused multi-argument aggregates like `covar_samp(data.y, data.x)` to fail with nil reference errors. This commit addresses this by evaluating extra args per-item inside the iterate loop using the item environment so all arguments resolve correctly. We also add few common aggregates: - `covar_pop`, `covar_samp`, `corr`: population/sample covariance and correlation coefficient using online co-moment algorithm. - `quantile(value, q, method)`: general quantile with interpolation methods: lower, higher, nearest, midpoint and default linear. - `percentile_cont(value, q)`: continuous percentile (linear) - `percentile_disc(value, q)`: discrete percentile (lower) Note: `percentile_cont` and `percentile_disc` share the `quantile` implementation through `ctx.name` at initialize time. Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Update docs Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Make the ordering for quantile aggregates explicit Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Update docs Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Improve docs Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> --------- Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
523 lines
16 KiB
TypeScript
523 lines
16 KiB
TypeScript
import type { ObjectValue } from "@silverbulletmd/silverbullet/type/index";
|
|
import type { Config } from "../config.ts";
|
|
import {
|
|
ArrayQueryCollection,
|
|
type LuaCollectionQuery,
|
|
type LuaQueryCollection,
|
|
} from "../space_lua/query_collection.ts";
|
|
import {
|
|
jsToLuaValue,
|
|
LuaEnv,
|
|
LuaStackFrame,
|
|
LuaTable,
|
|
} from "../space_lua/runtime.ts";
|
|
import type { DataStore } from "./datastore.ts";
|
|
import type { KV, KvKey } from "@silverbulletmd/silverbullet/type/datastore";
|
|
import type { EventHook } from "../plugos/hooks/event.ts";
|
|
import type { DataStoreMQ } from "./mq.datastore.ts";
|
|
import type { Space } from "../space.ts";
|
|
import { validateObject } from "../plugos/syscalls/jsonschema.ts";
|
|
import {
|
|
getAggregateSpec,
|
|
getBuiltinAggregateEntries,
|
|
} from "../space_lua/aggregates.ts";
|
|
|
|
const indexKey = "idx";
|
|
const pageKey = "ridx";
|
|
|
|
const indexVersionKey = ["$indexVersion"];
|
|
|
|
// Bump this one every time a full reindex is needed
|
|
const desiredIndexVersion = 9;
|
|
|
|
type TagDefinition = {
|
|
metatable?: any;
|
|
mustValidate?: boolean;
|
|
schema?: any;
|
|
validate?: (o: ObjectValue) => Promise<string | null | undefined>;
|
|
transform?: (
|
|
o: ObjectValue,
|
|
) =>
|
|
| Promise<ObjectValue[] | ObjectValue>
|
|
| ObjectValue[]
|
|
| ObjectValue
|
|
| null;
|
|
};
|
|
|
|
export class ObjectValidationError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly object: ObjectValue,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
export class ObjectIndex {
|
|
constructor(
|
|
private ds: DataStore,
|
|
private config: Config,
|
|
private eventHook: EventHook,
|
|
private mq: DataStoreMQ,
|
|
) {
|
|
// Clear any entries for deleted files
|
|
this.eventHook.addLocalListener("file:deleted", (path: string) => {
|
|
return this.clearFileIndex(path);
|
|
});
|
|
|
|
// Tracks if the file:listed event has been triggered,
|
|
// which is fired after all file:changed events have been dispatched
|
|
// resulting in new index entries (if any) being queued in the index queue
|
|
// this is later used to track if the index is complete
|
|
let indexStarted = false;
|
|
this.eventHook.addLocalListener("file:listed", () => {
|
|
indexStarted = true;
|
|
});
|
|
|
|
// Handle initial index completion
|
|
void this.hasFullIndexCompleted().then((hasCompleted) => {
|
|
if (!hasCompleted) {
|
|
const emptyQueueHandler = async () => {
|
|
console.log("Index queue empty, checking if index is complete");
|
|
// Theoretically we could get empty queue notifications before the file:listed event has been triggered, so let's account for this
|
|
if (indexStarted) {
|
|
// Indexing has just finished for the first time for this client
|
|
console.info("Initial index complete, reloading editor state");
|
|
await this.markFullIndexComplete();
|
|
// Unsubscribe yourself
|
|
this.eventHook.removeLocalListener(
|
|
"mq:emptyQueue:indexQueue",
|
|
emptyQueueHandler,
|
|
);
|
|
// Trigger an editor:reloadState event to reload the editor state (render widgets etc.)
|
|
void this.eventHook.dispatchEvent("editor:reloadState");
|
|
}
|
|
};
|
|
this.eventHook.addLocalListener(
|
|
"mq:emptyQueue:indexQueue",
|
|
emptyQueueHandler,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
tag(tagName: string): LuaQueryCollection {
|
|
if (!tagName) {
|
|
throw new Error("Tag name is required");
|
|
}
|
|
return {
|
|
query: (
|
|
query: LuaCollectionQuery,
|
|
env: LuaEnv,
|
|
sf: LuaStackFrame,
|
|
config?: Config,
|
|
): Promise<any[]> => {
|
|
return this.ds.luaQuery(
|
|
["idx", tagName],
|
|
query,
|
|
env,
|
|
sf,
|
|
(key, value: any) => {
|
|
const tag = key[1];
|
|
const mt = this.config.get<LuaTable | undefined>(
|
|
["tags", tag, "metatable"],
|
|
undefined,
|
|
);
|
|
if (!mt) {
|
|
// Return as is
|
|
return value;
|
|
}
|
|
// Convert to LuaTable
|
|
value = jsToLuaValue(value);
|
|
value.metatable = mt;
|
|
return value;
|
|
},
|
|
config,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns a queryable collection of all aggregate functions:
|
|
*
|
|
* - builtin,
|
|
* - user-defined, and
|
|
* - aliases.
|
|
*
|
|
* Every row has all columns: `builtin`, `name`, `description`,
|
|
* `initialize`, `iterate`, `finish` and `target`.
|
|
*/
|
|
aggregates(): LuaQueryCollection {
|
|
const entries: Record<string, any>[] = [];
|
|
|
|
// Builtins are always listed (even if overridden)
|
|
for (const entry of getBuiltinAggregateEntries()) {
|
|
entries.push({
|
|
builtin: true,
|
|
name: entry.name,
|
|
description: entry.description,
|
|
initialize: true,
|
|
iterate: true,
|
|
finish: entry.hasFinish,
|
|
target: null,
|
|
});
|
|
}
|
|
|
|
// Config entries (user-defined overrides and aliases)
|
|
const userAggs: Record<string, any> = this.config.get("aggregates", {});
|
|
for (const [key, spec] of Object.entries(userAggs)) {
|
|
const aliasTarget = spec instanceof LuaTable
|
|
? spec.rawGet("alias")
|
|
: spec?.alias ?? null;
|
|
if (typeof aliasTarget === "string") {
|
|
const resolved = getAggregateSpec(aliasTarget, this.config);
|
|
entries.push({
|
|
builtin: false,
|
|
name: key,
|
|
description: spec instanceof LuaTable
|
|
? spec.rawGet("description") ?? resolved?.description ?? ""
|
|
: spec?.description ?? resolved?.description ?? "",
|
|
initialize: resolved ? !!resolved.initialize : false,
|
|
iterate: resolved ? !!resolved.iterate : false,
|
|
finish: resolved ? !!resolved.finish : false,
|
|
target: aliasTarget,
|
|
});
|
|
} else {
|
|
let hasInit = false;
|
|
let hasIter = false;
|
|
let hasFin = false;
|
|
let desc = "";
|
|
if (spec instanceof LuaTable) {
|
|
hasInit = !!spec.rawGet("initialize");
|
|
hasIter = !!spec.rawGet("iterate");
|
|
hasFin = !!spec.rawGet("finish");
|
|
desc = spec.rawGet("description") ?? "";
|
|
} else if (spec) {
|
|
hasInit = !!spec.initialize;
|
|
hasIter = !!spec.iterate;
|
|
hasFin = !!spec.finish;
|
|
desc = spec.description ?? "";
|
|
}
|
|
entries.push({
|
|
builtin: false,
|
|
name: key,
|
|
description: desc,
|
|
initialize: hasInit,
|
|
iterate: hasIter,
|
|
finish: hasFin,
|
|
target: null,
|
|
});
|
|
}
|
|
}
|
|
return new ArrayQueryCollection(entries);
|
|
}
|
|
|
|
getObjectByRef(page: string, tag: string, ref: string) {
|
|
return this.ds.get([indexKey, tag, this.cleanKey(ref, page), page]);
|
|
}
|
|
|
|
async ensureFullIndex(space: Space) {
|
|
const currentIndexVersion = await this.getCurrentIndexVersion();
|
|
|
|
if (!currentIndexVersion) {
|
|
console.log("No index version found, assuming fresh install");
|
|
return;
|
|
}
|
|
|
|
if (
|
|
// If the index version is less than the desired version
|
|
currentIndexVersion < desiredIndexVersion &&
|
|
// And the index queue is empty (meaning no indexing is ongoing)
|
|
(await this.mq.isQueueEmpty("indexQueue"))
|
|
) {
|
|
console.info(
|
|
"[index]",
|
|
"Performing a full space reindex, this could take a while...",
|
|
currentIndexVersion,
|
|
desiredIndexVersion,
|
|
);
|
|
|
|
await this.reindexSpace(space);
|
|
|
|
// Dispatch an editor:reloadState event to reload the editor state (render widgets etc.)
|
|
void this.eventHook.dispatchEvent("editor:reloadState");
|
|
}
|
|
}
|
|
|
|
async reindexSpace(space: Space) {
|
|
console.log("Clearing page index...");
|
|
await this.clearIndex();
|
|
await this.markFullIndexInComplete();
|
|
|
|
const files = await space.deduplicatedFileList();
|
|
|
|
console.log("Queing", files.length, "pages to be indexed.");
|
|
// Queue all file names to be indexed
|
|
const startTime = Date.now();
|
|
await this.mq.batchSend(
|
|
"indexQueue",
|
|
files.map((file) => file.name),
|
|
);
|
|
await this.mq.awaitEmptyQueue("indexQueue");
|
|
await this.markFullIndexComplete();
|
|
console.log("Full index completed after", Date.now() - startTime, "ms");
|
|
}
|
|
|
|
public async hasFullIndexCompleted() {
|
|
return (await this.ds.get(indexVersionKey)) >= desiredIndexVersion;
|
|
}
|
|
|
|
private getCurrentIndexVersion() {
|
|
return this.ds.get(indexVersionKey);
|
|
}
|
|
|
|
async awaitIndexQueueDrain(): Promise<void> {
|
|
await this.mq.awaitEmptyQueue("indexQueue");
|
|
}
|
|
|
|
async markFullIndexComplete() {
|
|
await this.ds.set(indexVersionKey, desiredIndexVersion);
|
|
}
|
|
|
|
async markFullIndexInComplete() {
|
|
await this.ds.delete(indexVersionKey);
|
|
}
|
|
|
|
cleanKey(ref: string, page: string) {
|
|
if (ref.startsWith(`${page}@`)) {
|
|
return ref.substring(page.length + 1);
|
|
} else {
|
|
return ref;
|
|
}
|
|
}
|
|
|
|
queryLuaObjects<T>(
|
|
globalEnv: LuaEnv,
|
|
tag: string,
|
|
query: LuaCollectionQuery,
|
|
scopedVariables?: Record<string, any>,
|
|
): Promise<ObjectValue<T>[]> {
|
|
const sf = LuaStackFrame.createWithGlobalEnv(globalEnv);
|
|
let env = globalEnv;
|
|
if (scopedVariables) {
|
|
env = new LuaEnv(globalEnv);
|
|
for (const [key, value] of Object.entries(scopedVariables)) {
|
|
env.setLocal(key, jsToLuaValue(value));
|
|
}
|
|
}
|
|
return this.ds.luaQuery([indexKey, tag], query, env, sf);
|
|
}
|
|
|
|
batchSet(page: string, kvs: KV[]): Promise<void> {
|
|
const finalBatch: KV[] = [];
|
|
for (const { key, value } of kvs) {
|
|
finalBatch.push(
|
|
{
|
|
key: [indexKey, ...key, page],
|
|
value,
|
|
},
|
|
{
|
|
key: [pageKey, page, ...key],
|
|
value: true,
|
|
},
|
|
);
|
|
}
|
|
return this.ds.batchSet(finalBatch);
|
|
}
|
|
|
|
batchDelete(page: string, keys: KvKey[]): Promise<void> {
|
|
const finalBatch: KvKey[] = [];
|
|
for (const key of keys) {
|
|
finalBatch.push([indexKey, ...key, page]);
|
|
}
|
|
return this.ds.batchDelete(finalBatch);
|
|
}
|
|
|
|
/**
|
|
* Clears all keys for a given file
|
|
* @param file
|
|
*/
|
|
public async clearFileIndex(file: string): Promise<void> {
|
|
if (file.endsWith(".md")) {
|
|
file = file.replace(/\.md$/, "");
|
|
}
|
|
// console.log("Clearing index for", file);
|
|
const allKeys: KvKey[] = [];
|
|
for await (const { key } of this.ds.query({
|
|
prefix: [pageKey, file],
|
|
})) {
|
|
allKeys.push(key);
|
|
allKeys.push([indexKey, ...key.slice(2), file]);
|
|
}
|
|
await this.ds.batchDelete(allKeys);
|
|
}
|
|
|
|
/**
|
|
* Clears the entire index
|
|
*/
|
|
public async clearIndex(): Promise<void> {
|
|
const allKeys: KvKey[] = [];
|
|
for await (const { key } of this.ds.query({ prefix: [indexKey] })) {
|
|
allKeys.push(key);
|
|
}
|
|
for await (const { key } of this.ds.query({ prefix: [pageKey] })) {
|
|
allKeys.push(key);
|
|
}
|
|
await this.ds.batchDelete(allKeys);
|
|
console.log("Deleted", allKeys.length, "keys from the index");
|
|
}
|
|
|
|
/**
|
|
* Indexes entities in the data store
|
|
*/
|
|
public async indexObjects<T>(
|
|
page: string,
|
|
objects: ObjectValue<T>[],
|
|
): Promise<void> {
|
|
const kvs = await this.processObjectsToKVs<T>(page, objects, false);
|
|
if (kvs.length > 0) {
|
|
return this.batchSet(page, kvs);
|
|
} else {
|
|
return Promise.resolve();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate and transform objects, throws a ValidationError when it fails
|
|
* @param page
|
|
* @param objects
|
|
* @throw ValidationError
|
|
*/
|
|
public async validateObjects<T>(page: string, objects: ObjectValue<T>[]) {
|
|
await this.processObjectsToKVs(page, objects, true);
|
|
}
|
|
|
|
private async processObjectsToKVs<T>(
|
|
page: string,
|
|
objects: ObjectValue<T>[],
|
|
throwOnValidationErrors: boolean,
|
|
): Promise<KV<T>[]> {
|
|
const kvs: KV<T>[] = [];
|
|
const tagDefinitions: Record<string, TagDefinition> = this.config.get(
|
|
"tags",
|
|
{},
|
|
);
|
|
// Taking this iteration approach as new objects may be pushed into this array on the fly
|
|
while (objects.length > 0) {
|
|
const obj = objects.shift()!;
|
|
if (!obj.tag) {
|
|
console.error("Object has no tag", obj, "this shouldn't happen");
|
|
continue;
|
|
}
|
|
// Index as all the tag + any additional tags specified
|
|
const allTags = [obj.tag, ...(obj.tags || [])];
|
|
for (const tag of allTags) {
|
|
const tagDefinition = tagDefinitions[tag];
|
|
// Validate object based on schema if required
|
|
if (
|
|
tagDefinition?.schema &&
|
|
(tagDefinition?.mustValidate || throwOnValidationErrors)
|
|
) {
|
|
const validationError = validateObject(tagDefinition?.schema, obj);
|
|
if (validationError) {
|
|
if (!throwOnValidationErrors) {
|
|
console.warn(
|
|
`Object failed ${tag} validation so won't be indexed:`,
|
|
obj,
|
|
"Validation error:",
|
|
validationError,
|
|
);
|
|
continue;
|
|
} else {
|
|
throw new ObjectValidationError(validationError, obj);
|
|
}
|
|
}
|
|
}
|
|
// Validate object based on validate callback if required
|
|
if (
|
|
tagDefinition?.validate &&
|
|
(tagDefinition?.mustValidate || throwOnValidationErrors)
|
|
) {
|
|
const validationError = await tagDefinition.validate(obj);
|
|
if (validationError) {
|
|
if (!throwOnValidationErrors) {
|
|
console.warn(
|
|
`Object failed ${tag} validation so won't be indexed:`,
|
|
obj,
|
|
"Validation error:",
|
|
validationError,
|
|
);
|
|
continue;
|
|
} else {
|
|
throw new ObjectValidationError(validationError, obj);
|
|
}
|
|
}
|
|
}
|
|
// Transform object
|
|
if (tagDefinition?.transform) {
|
|
let newObjects;
|
|
try {
|
|
newObjects = await tagDefinition.transform(obj);
|
|
} catch (e: any) {
|
|
throw new ObjectValidationError(e.message, obj);
|
|
}
|
|
|
|
if (!newObjects) {
|
|
// null value returned, just index as usual
|
|
kvs.push({
|
|
key: [tag, this.cleanKey(obj.ref, page)],
|
|
value: obj,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (!Array.isArray(newObjects)) {
|
|
// Probably returned single object, let's normalize
|
|
newObjects = [newObjects];
|
|
}
|
|
// A transform function _must_ either return an empty list of objects to index, or return at least one object with the same ref
|
|
// If this doesn't happen, we may end up in an infinite loop.
|
|
let foundAssignedRef = false;
|
|
for (const newObj of newObjects) {
|
|
if (!newObj.ref) {
|
|
console.error(
|
|
"transform result object did not contain ref",
|
|
newObj,
|
|
);
|
|
continue;
|
|
}
|
|
if (newObj.ref === obj.ref) {
|
|
// Got the same object back here, let's just index it without further processing
|
|
kvs.push({
|
|
key: [tag, this.cleanKey(newObj.ref, page)],
|
|
value: newObj,
|
|
});
|
|
foundAssignedRef = true;
|
|
} else {
|
|
// Some other object
|
|
objects.push(newObj);
|
|
}
|
|
}
|
|
if (!foundAssignedRef && newObjects.length) {
|
|
throw new Error(
|
|
`transform() result objects for ${tag} did not contain result with original ref.`,
|
|
);
|
|
}
|
|
} else {
|
|
// Just insert it directly
|
|
kvs.push({
|
|
key: [tag, this.cleanKey(obj.ref, page)],
|
|
value: obj,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return kvs;
|
|
}
|
|
|
|
deleteObject(page: string, tag: string, ref: string): Promise<void> {
|
|
return this.batchDelete(page, [[tag, this.cleanKey(ref, page)]]);
|
|
}
|
|
}
|