mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-04 08:30:39 +00:00
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:
Generated
+1
-1
@@ -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');
|
||||
|
||||
Generated
+2
-2
@@ -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,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",
|
||||
|
||||
@@ -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 ) {
|
||||
|
||||
Vendored
+2
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user