feat: kv.remove feature to remove nested elements or list indices (#2258)

* chore: update npm version

* feat: kv.remove feature to remove nested elements or list indices

* chore: update npm version
This commit is contained in:
Daniel Salazar
2026-01-09 15:10:02 -08:00
committed by GitHub
parent 353f163f46
commit f88ca5d4bd
10 changed files with 220 additions and 7 deletions
+1 -1
View File
@@ -18967,7 +18967,7 @@
},
"src/puter-js": {
"name": "@heyputer/puter.js",
"version": "2.2.0",
"version": "2.2.5",
"license": "Apache-2.0",
"dependencies": {
"@heyputer/kv.js": "^0.2.1"
@@ -28,6 +28,7 @@ const BaseService = require('../../services/BaseService');
* @property {function(): Promise<void>} flush - Delete all key-value pairs in the store.
* @property {(params: KVStoreUpdateParams) => Promise<unknown>} update - Update nested values by key.
* @property {(params: KVStoreAddParams) => Promise<unknown>} add - Append values into list paths by key.
* @property {(params: KVStoreRemoveParams) => Promise<unknown>} remove - Remove nested values by key.
* @property {(params: {key:string, pathAndAmountMap: Record<string, number>}) => Promise<unknown>} incr - Increment a numeric value by key.
* @property {(params: {key:string, pathAndAmountMap: Record<string, number>}) => Promise<unknown>} decr - Decrement a numeric value by key.
* @property {function(KVStoreExpireAtParams): Promise<number>} expireAt - Set a key to expire at a specific UNIX timestamp (seconds).
@@ -56,6 +57,10 @@ const BaseService = require('../../services/BaseService');
* @property {string} key - The key to update.
* @property {Object.<string, *>} pathAndValueMap - Map of period-joined paths to values to append.
*
* @typedef {Object} KVStoreRemoveParams
* @property {string} key - The key to update.
* @property {string[]} paths - List of period-joined paths to remove.
*
* @typedef {Object} KVStoreExpireAtParams
* @property {string} key - The key to set expiration for.
* @property {number} timestamp - UNIX timestamp (seconds) when the key should expire.
@@ -137,6 +142,14 @@ class KVStoreInterfaceService extends BaseService {
},
result: { type: 'json', description: 'The updated value' },
},
remove: {
description: 'Remove nested values by key.',
parameters: {
key: { type: 'string', required: true },
paths: { type: 'json', required: true, description: 'list of period-joined paths to remove' },
},
result: { type: 'json', description: 'The updated value' },
},
incr: {
description: 'Increment a value by key.',
parameters: {
@@ -161,13 +161,21 @@ export class DDBClient {
return await this.#documentClient.send(command);
}
async update<T extends Record<string, unknown>> (table: string, key: T, expression: string, expressionValues: Record<string, unknown>, expressionNames: Record<string, string>) {
async update<T extends Record<string, unknown>> (
table: string,
key: T,
expression: string,
expressionValues?: Record<string, unknown>,
expressionNames?: Record<string, string>,
) {
const hasValues = !!expressionValues && Object.keys(expressionValues).length > 0;
const hasNames = !!expressionNames && Object.keys(expressionNames).length > 0;
const command = new UpdateCommand({
TableName: table,
Key: key,
UpdateExpression: expression,
ExpressionAttributeValues: expressionValues,
ExpressionAttributeNames: expressionNames,
...(hasValues ? { ExpressionAttributeValues: expressionValues } : {}),
...(hasNames ? { ExpressionAttributeNames: expressionNames } : {}),
ReturnValues: 'ALL_NEW',
ReturnConsumedCapacity: 'TOTAL',
});
@@ -298,6 +298,94 @@ describe('DynamoKVStore', async () => {
expect((stored as { a?: { b?: number[][] } }).a?.b).toEqual([[1], [2, 3]]);
});
it('supports nested list indexing for add, update, remove, and incr', async () => {
const actor = makeActor(21);
const key = 'nested-list-index';
await su.sudo(actor, () => kvStore.set({
key,
value: { a: [1, { b: { c: [1] } }, 2] },
}));
const added = await su.sudo(actor, () => kvStore.add({
key,
pathAndValueMap: { 'a[1].b.c': 2 },
}));
expect((added as { a?: Array<unknown> }).a).toEqual([1, { b: { c: [1, 2] } }, 2]);
const updated = await su.sudo(actor, () => kvStore.update({
key,
pathAndValueMap: { 'a[1].b.c': [9] },
}));
expect((updated as { a?: Array<unknown> }).a).toEqual([1, { b: { c: [9] } }, 2]);
const removed = await su.sudo(actor, () => kvStore.remove({
key,
paths: ['a[1].b.c'],
}));
expect((removed as { a?: Array<unknown> }).a).toEqual([1, { b: {} }, 2]);
await su.sudo(actor, () => kvStore.set({
key,
value: { a: [1, { b: { c: 1 } }, 2] },
}));
const incrRes = await su.sudo(actor, () => kvStore.incr({
key,
pathAndAmountMap: { 'a[1].b.c': 3 },
}));
expect((incrRes as { a?: Array<unknown> }).a).toEqual([1, { b: { c: 4 } }, 2]);
});
it('removes nested values including indexed list paths', async () => {
const actor = makeActor(19);
const key = 'remove-list-index';
await su.sudo(actor, () => kvStore.set({
key,
value: { a: { b: [1, 2, 3], c: { d: 4 }, e: 'keep' } },
}));
const updated = await su.sudo(actor, () => kvStore.remove({
key,
paths: ['a.b[1]', 'a.c'],
}));
expect((updated as { a?: { b?: number[]; e?: string } }).a).toEqual({ b: [1, 3], e: 'keep' });
const stored = await su.sudo(actor, () => kvStore.get({ key }));
expect((stored as { a?: { b?: number[]; e?: string } }).a).toEqual({ b: [1, 3], e: 'keep' });
});
it('rejects overlapping parent/child paths in a single request', async () => {
const actor = makeActor(20);
const key = 'overlap-paths';
await su.sudo(actor, () => kvStore.set({
key,
value: { a: { b: { c: 1 } } },
}));
await expect(su.sudo(actor, () => kvStore.incr({
key,
pathAndAmountMap: { 'a.b': 1, 'a.b.c': 1 },
}))).rejects.toThrow(/paths overlap/i);
await expect(su.sudo(actor, () => kvStore.add({
key,
pathAndValueMap: { 'a.b': 1, 'a.b.c': 2 },
}))).rejects.toThrow(/paths overlap/i);
await expect(su.sudo(actor, () => kvStore.update({
key,
pathAndValueMap: { 'a.b': 1, 'a.b.c': 2 },
}))).rejects.toThrow(/paths overlap/i);
await expect(su.sudo(actor, () => kvStore.remove({
key,
paths: ['a.b', 'a.b.c'],
}))).resolves.not.toThrow();
});
it('incr initializes nested maps for missing keys', async () => {
const actor = makeActor(14);
const key = 'incr-missing';
@@ -527,6 +527,67 @@ export class DynamoKVStore {
return res.Attributes?.value;
}
async remove ({ key, paths }: { key: string; paths: string[]; }): Promise<unknown> {
if ( !paths || paths.length === 0 ) {
throw new Error('invalid use of #remove: no paths');
}
if ( key === '' ) {
throw APIError.create('field_empty', null, {
key: 'key',
});
}
const actor = Context.get('actor');
const user = actor.type?.user ?? undefined;
if ( ! user ) throw new Error('User not found');
const namespace = this.#getNameSpace(actor);
if ( this.#enableMigrationFromSQL ) {
// trigger get to move element if exists
await this.get({ key });
}
const cleanerRegex = /[:\-+/*]/g;
const removeStatements = paths.map((valPath) => {
const path = ['value', ...valPath.split('.')].filter(Boolean).join('.');
return path.split('.').map((chunk) => {
const cleanedChunk = chunk.split(/\[\d*\]/g)[0];
const indexSuffix = chunk.slice(cleanedChunk.length);
return `${`#${cleanedChunk}`.replaceAll(cleanerRegex, '')}${indexSuffix}`;
}).join('.');
});
const valueAttributeNames = paths.reduce((acc, valPath) => {
const path = ['value', ...valPath.split('.')].filter(Boolean).join('.');
path.split('.').forEach((chunk) => {
const cleanedChunk = chunk.split(/\[\d*\]/g)[0];
acc[`#${cleanedChunk}`.replaceAll(cleanerRegex, '')] = cleanedChunk;
});
return acc;
}, {} as Record<string, string>);
try {
const res = await this.#ddbClient.update(this.#tableName,
{ key, namespace },
`REMOVE ${removeStatements.join(', ')}`,
undefined,
{ ...valueAttributeNames, '#value': 'value' });
this.#meteringService.incrementUsage(actor, 'kv:write', res?.ConsumedCapacity?.CapacityUnits ?? 1);
return res.Attributes?.value;
} catch ( e ) {
const message = (e as Error)?.message ?? '';
if ( (e as Error)?.name === 'ValidationException' && /document path|invalid updateexpression/i.test(message) ) {
this.#meteringService.incrementUsage(actor, 'kv:write', 1);
return await this.get({ key });
}
throw e;
}
}
async update ({ key, pathAndValueMap, ttl }: { key: string; pathAndValueMap: Record<string, unknown>; ttl?: number; }): Promise<unknown> {
if ( !pathAndValueMap || Object.keys(pathAndValueMap).length === 0 ) {
throw new Error('invalid use of #update: no pathAndValueMap');
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "puter",
"version": "2.1.15",
"version": "2.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "puter",
"version": "2.1.15",
"version": "2.2.5",
"license": "Apache-2.0",
"dependencies": {
"@heyputer/kv.js": "^0.1.92",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@heyputer/puter.js",
"version": "2.2.0",
"version": "2.2.5",
"description": "Puter.js - A JavaScript library for interacting with Puter services.",
"homepage": "https://developer.puter.com",
"main": "src/index.js",
+32
View File
@@ -233,6 +233,38 @@ class KV {
return utils.make_driver_method(['key'], 'puter-kvstore', undefined, 'add').call(this, options);
};
remove = async (...args) => {
if ( !args || args.length < 2 ) {
throw ({ message: 'At least one path is required', code: 'arguments_required' });
}
const key = args[0];
const paths = args.slice(1);
if ( Array.isArray(paths[0]) && paths.length === 1 ) {
throw ({ message: 'Paths must be provided as separate arguments', code: 'paths_invalid' });
}
if ( key === undefined || key === null ) {
throw ({ message: 'Key cannot be undefined', code: 'key_undefined' });
}
if ( key.length > this.MAX_KEY_SIZE ) {
throw ({ message: `Key size cannot be larger than ${this.MAX_KEY_SIZE}`, code: 'key_too_large' });
}
if ( paths.length === 0 ) {
throw ({ message: 'At least one path is required', code: 'arguments_required' });
}
if ( paths.some((path) => typeof path !== 'string') ) {
throw ({ message: 'All paths must be strings', code: 'paths_invalid' });
}
return utils.make_driver_method(['key', 'paths'], 'puter-kvstore', undefined, 'remove')
.call(this, { key, paths });
};
update = utils.make_driver_method(['key', 'pathAndValueMap', 'ttl'], 'puter-kvstore', undefined, 'update', {
preprocess: (args) => {
if ( args.key === undefined || args.key === null ) {
+2
View File
@@ -1,3 +1,4 @@
/* eslint-disable no-unused-vars */
export type KVValue = string | number | boolean | object | unknown;
export type KVScalar = KVValue | KVValue[];
@@ -28,6 +29,7 @@ export class KV {
incr (key: string, amount?: number | KVIncrementPath): Promise<number>;
decr (key: string, amount?: number | KVIncrementPath): Promise<number>;
add (key: string, value?: KVValue | KVAddPath): Promise<KVValue>;
remove (key: string, ...paths: string[]): Promise<KVValue>;
update (key: string, pathAndValueMap: KVUpdatePath, ttlSeconds?: number): Promise<KVValue>;
expire (key: string, ttlSeconds: number): Promise<boolean>;
expireAt (key: string, timestampSeconds: number): Promise<boolean>;
+9
View File
@@ -132,6 +132,15 @@ describe('Puter KV Module', () => {
expect(finalGet).toEqual({ a: { b: [[1], [2, 3]] } });
});
it('should remove nested values including indexed list paths', async () => {
const removeKey = `${TEST_KEY}-remove`;
await puter.kv.set(removeKey, { a: { b: [1, 2, 3], c: { d: 4 }, e: 'keep' } });
const removeRes = await puter.kv.remove(removeKey, 'a.b[1]', 'a.c');
expect(removeRes).toEqual({ a: { b: [1, 3], e: 'keep' } });
const finalGet = await puter.kv.get(removeKey);
expect(finalGet).toEqual({ a: { b: [1, 3], e: 'keep' } });
});
it('should list keys', async () => {
const listRes = await puter.kv.list();
expect(Array.isArray(listRes)).toBe(true);